Compare commits

..

4 Commits

Author SHA1 Message Date
Anthony LC fc56d0e692 🔖(minor) release 2.2.0
Added:
- 📝(doc) Add security.md and codeofconduct.md
- (frontend) add home page
- (frontend) cursor display on activity
- (frontend) Add export page break

Changed:
- 🔧(backend) make AI feature reach configurable

Fixed:
- 🌐(CI) Fix email partially translated
- 🐛(frontend) fix cursor breakline
- 🐛(frontend) fix style pdf export
2025-02-11 13:09:57 +01:00
Anthony LC 68337c500f 🩹(frontend) fine tuning v2.2.0
Fix minor issues:
- Add some height to the carret in the editor
- Improve css in mail templates
- Improve images resolution on homepage
2025-02-11 13:09:22 +01:00
Anthony LC d89e3dc6d4 🛂(frontend) display the AI buttons depend abilities
Anybody with edit right could use the AI.
We changed this behavior, now we have to be
authentified with edit right.
We update the UI to display the AI buttons
only if the user has the correct AI ability.
2025-02-11 10:54:55 +01:00
Samuel Paccoud - DINUM 91cf5f9367 🔧(backend) make AI feature reach configurable
We want to be able to define whether AI features are available to
anonymous users who gained editor access on a document, or if we
demand that they be authenticated or even if we demand that they
gained their editor access via a specific document access.

Being authenticated is now the default value. This will change the
default behavior on your existing instance (see UPGRADE.md)
2025-02-11 10:54:55 +01:00
56 changed files with 15060 additions and 41244 deletions
+10 -3
View File
@@ -9,16 +9,22 @@ and this project adheres to
## [Unreleased]
## [2.2.0] - 2025-02-10
## Added
- 📝(doc) Add security.md and codeofconduct.md #604
- ✨(frontend) add home page #553
- ✨(frontend) add home page #608
- ✨(frontend) cursor display on activity #609
- ✨(frontend) Add export page break #623
## Changed
- 🔧(backend) make AI feature reach configurable #628
## Fixed
🌐(CI) Fix email partially translated #616
- 🌐(CI) Fix email partially translated #616
- 🐛(frontend) fix cursor breakline #609
- 🐛(frontend) fix style pdf export #609
@@ -405,7 +411,8 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.1.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.2.0...main
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
+5
View File
@@ -15,3 +15,8 @@ the following command inside your docker container:
(Note : in your development environment, you can `make migrate`.)
## [Unreleased]
- AI features are now limited to users who are authenticated. Before this release, even anonymous
users who gained editor access on a document with link reach used to get AI feature.
IF you want anonymous users to keep access on AI features, you must now define the
`AI_ALLOW_REACH_FROM` setting to "public".
+12 -13
View File
@@ -15,14 +15,14 @@ services:
- "1081:1080"
minio:
user: ${DOCKER_USER:-1000}
# user: ${DOCKER_USER:-1000}
image: minio/minio
environment:
- MINIO_ROOT_USER=impress
- MINIO_ROOT_PASSWORD=password
ports:
- "9000:9000"
- "9001:9001"
- '9000:9000'
- '9001:9001'
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
@@ -59,11 +59,11 @@ services:
- ./src/backend:/app
- ./data/static:/data/static
depends_on:
- postgresql
- mailcatcher
- redis
- createbuckets
- postgresql
- mailcatcher
- redis
- createbuckets
celery-dev:
user: ${DOCKER_USER:-1000}
image: impress:backend-development
@@ -122,7 +122,7 @@ services:
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-production
@@ -151,13 +151,13 @@ services:
image: node:18
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
HOME: /tmp
volumes:
- ".:/app"
y-provider:
user: ${DOCKER_USER:-1000}
build:
build:
context: .
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
@@ -169,7 +169,6 @@ services:
kc_postgresql:
image: postgres:14.3
platform: linux/amd64
ports:
- "5433:5432"
env_file:
@@ -197,7 +196,7 @@ services:
KC_DB_PASSWORD: pass
KC_DB_USERNAME: impress
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: "true"
PROXY_ADDRESS_FORWARDING: 'true'
ports:
- "8080:8080"
depends_on:
-7
View File
@@ -10,10 +10,6 @@ LOGGING_LEVEL_HANDLERS_CONSOLE=INFO
LOGGING_LEVEL_LOGGERS_ROOT=INFO
LOGGING_LEVEL_LOGGERS_APP=INFO
# Y-Provider
Y_PROVIDER_API_KEY="yprovider-api-key"
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
# Python
PYTHONPATH=/app
@@ -58,9 +54,6 @@ AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
# Accessibility API
ACCESSIBILITY_API_BASE_URL=https://localhost:8000
# Collaboration
COLLABORATION_API_URL=http://nginx:8083/collaboration/api/
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
+1 -52
View File
@@ -16,7 +16,6 @@ from core.services.converter_services import (
ConversionError,
YdocConverter,
)
from core.services.ai_services import AIService
class UserSerializer(serializers.ModelSerializer):
@@ -307,7 +306,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
if user:
email = user.email
language = user.language or language
try:
document_content = YdocConverter().convert_markdown(
validated_data["content"]
@@ -569,56 +568,6 @@ class AITranslateSerializer(serializers.Serializer):
if len(value.strip()) == 0:
raise serializers.ValidationError("Text field cannot be empty.")
return value
class AIPdfTranscribeSerializer(serializers.Serializer):
"""Serializer for AI PDF transcribe requests."""
pdfUrl = serializers.CharField(required=True)
def __init__(self, *args, **kwargs):
"""Initialize with user."""
self.user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
def validate_pdfUrl(self, value):
"""Ensure the pdfUrl field is a valid URL."""
if not value.startswith(settings.MEDIA_BASE_URL):
raise serializers.ValidationError("Invalid PDF URL format.")
return value
def create(self, validated_data):
"""Create a new document for the transcribed content."""
if not self.user:
raise serializers.ValidationError("User is required")
# Get the transcribed content from AI service
pdf_url = validated_data["pdfUrl"]
response = AIService().transcribe_pdf(pdf_url)
try:
# Convert the markdown content to YDoc format
document_content = YdocConverter().convert_markdown(response)
except ConversionError as err:
raise serializers.ValidationError(
{"content": [f"Could not convert transcribed content: {str(err)}"]}
) from err
# Create the document as root node with converted content
document = models.Document.add_root(
title="PDF Transcription",
content=document_content,
creator=self.user,
)
# Create owner access for the user
models.DocumentAccess.objects.create(
document=document,
role=models.RoleChoices.OWNER,
user=self.user,
)
return document
class MoveDocumentSerializer(serializers.Serializer):
-61
View File
@@ -1079,41 +1079,6 @@ class DocumentViewSet(
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
@drf.decorators.action(
detail=True,
methods=["post"],
name="Just proxy ai call",
url_path="ai-proxy"
)
def ai_proxy(self, request, *args, **kwargs):
"""
POST /api/v1.0/documents/<resource_id>/ai-transform
with expected data:
- text: str
- action: str [prompt, correct, rephrase, summarize]
Return JSON response with the processed text.
"""
print('PROXY 1')
# Check permissions first
# self.get_object()
print('PROXY 2')
print(request.data)
# serializer = serializers.AITransformSerializer(data=request.data)
# serializer.is_valid(raise_exception=True)
print('PROXY 3')
system_content = request.data["system"]
text = request.data["text"]
print('PROXY 4')
response = AIService().call_proxy(system_content, text)
print('PROXY 5')
print(response)
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
@drf.decorators.action(
detail=True,
methods=["post"],
@@ -1142,32 +1107,6 @@ class DocumentViewSet(
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
@drf.decorators.action(
detail=True,
methods=["post"],
name="Transcribe PDF with AI",
url_path="ai-pdf-transcribe",
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
)
def ai_pdf_transcribe(self, request, *args, **kwargs):
"""
POST /api/v1.0/documents/<resource_id>/ai-pdf-transcribe
with expected data:
- pdfUrl: str
Return JSON response with the new document ID containing the transcription.
"""
serializer = serializers.AIPdfTranscribeSerializer(
data=request.data,
user=request.user
)
serializer.is_valid(raise_exception=True)
document = serializer.save()
return drf.response.Response(
{"document_id": str(document.id)},
status=drf.status.HTTP_201_CREATED
)
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
+17 -2
View File
@@ -629,6 +629,9 @@ class Document(MP_Node, BaseModel):
# which date to allow them anyway)
# Anonymous users should also not see document accesses
has_access_role = bool(roles) and not is_deleted
can_update_from_access = (
is_owner_or_admin or RoleChoices.EDITOR in roles
) and not is_deleted
# Add roles provided by the document link, taking into account its ancestors
@@ -647,11 +650,23 @@ class Document(MP_Node, BaseModel):
is_owner_or_admin or RoleChoices.EDITOR in roles
) and not is_deleted
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
ai_access = any(
[
ai_allow_reach_from == LinkReachChoices.PUBLIC and can_update,
ai_allow_reach_from == LinkReachChoices.AUTHENTICATED
and user.is_authenticated
and can_update,
ai_allow_reach_from == LinkReachChoices.RESTRICTED
and can_update_from_access,
]
)
return {
"accesses_manage": is_owner_or_admin,
"accesses_view": has_access_role,
"ai_transform": can_update,
"ai_translate": can_update,
"ai_transform": ai_access,
"ai_translate": ai_access,
"attachment_upload": can_update,
"children_list": can_get,
"children_create": can_update and user.is_authenticated,
-44
View File
@@ -2,18 +2,13 @@
import json
import re
import os
import requests
import botocore
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.core.files.storage import default_storage
from openai import OpenAI
from core import enums
from core.models import Document
AI_ACTIONS = {
"prompt": (
@@ -60,22 +55,6 @@ class AIService:
raise ImproperlyConfigured("AI configuration not set")
self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY)
def call_proxy(self, system_content, text):
messages = [
{"role": "system", "content": system_content},
{"role": "user", "content": text},
]
print('REQUEST', messages)
response = self.client.chat.completions.create(
model=settings.AI_MODEL,
messages=messages,
)
print('RESPONSE', response)
content = response.choices[0].message.content
print('CONTENT', content)
return content
def call_ai_api(self, system_content, text):
"""Helper method to call the OpenAI API and process the response."""
response = self.client.chat.completions.create(
@@ -117,26 +96,3 @@ class AIService:
language_display = enums.ALL_LANGUAGES.get(language, language)
system_content = AI_TRANSLATE.format(language=language_display)
return self.call_ai_api(system_content, text)
def transcribe_pdf(self, pdf_url):
"""Transcribe PDF using the accessibility hackathon API and create a new document."""
try:
media_prefix = os.path.join(settings.MEDIA_BASE_URL, "media")
key = pdf_url[len(media_prefix):]
pdf_response = default_storage.connection.meta.client.get_object(
Bucket=default_storage.bucket_name,
Key=key
)
pdf_content = pdf_response['Body'].read()
api_url = f"{settings.ACCESSIBILITY_API_BASE_URL}/transcribe/pdf"
files = {'file': ('document.pdf', pdf_content, 'application/pdf')}
headers = {'Accept': 'application/json'}
response = requests.post(api_url, files=files, headers=headers)
response.raise_for_status()
transcribed_text = response.json()['markdown_content']
return transcribed_text
except Exception as e:
raise RuntimeError(f"Failed to transcribe PDF: {str(e)}")
@@ -2,6 +2,7 @@
Test AI transform API endpoint for users in impress's core app.
"""
import random
from unittest.mock import MagicMock, patch
from django.core.cache import cache
@@ -31,6 +32,9 @@ def ai_settings():
yield
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"reach, role",
[
@@ -57,6 +61,7 @@ def test_api_documents_ai_transform_anonymous_forbidden(reach, role):
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_success(mock_create):
@@ -93,6 +98,27 @@ def test_api_documents_ai_transform_anonymous_success(mock_create):
)
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_limited_by_setting(mock_create):
"""
Anonymous users should be able to request AI transform to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = APIClient().post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 401
@pytest.mark.parametrize(
"reach, role",
[
@@ -2,6 +2,7 @@
Test AI translate API endpoint for users in impress's core app.
"""
import random
from unittest.mock import MagicMock, patch
from django.core.cache import cache
@@ -51,6 +52,9 @@ def test_api_documents_ai_translate_viewset_options_metadata():
}
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"reach, role",
[
@@ -77,6 +81,7 @@ def test_api_documents_ai_translate_anonymous_forbidden(reach, role):
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_success(mock_create):
@@ -113,6 +118,27 @@ def test_api_documents_ai_translate_anonymous_success(mock_create):
)
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_limited_by_setting(mock_create):
"""
Anonymous users should be able to request AI translate to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = APIClient().post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 401
@pytest.mark.parametrize(
"reach, role",
[
@@ -28,8 +28,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"ai_transform": False,
"ai_translate": False,
"attachment_upload": document.link_role == "editor",
"children_create": False,
"children_list": True,
@@ -84,8 +84,8 @@ def test_api_documents_retrieve_anonymous_public_parent():
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": grand_parent.link_role == "editor",
"ai_translate": grand_parent.link_role == "editor",
"ai_transform": False,
"ai_translate": False,
"attachment_upload": grand_parent.link_role == "editor",
"children_create": False,
"children_list": True,
+77 -10
View File
@@ -12,6 +12,7 @@ from django.core import mail
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.test.utils import override_settings
from django.utils import timezone
import pytest
@@ -124,6 +125,9 @@ def test_models_documents_soft_delete(depth):
# get_abilities
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"is_authenticated,reach,role",
[
@@ -175,6 +179,9 @@ def test_models_documents_get_abilities_forbidden(
assert document.get_abilities(user) == expected_abilities
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"is_authenticated,reach",
[
@@ -243,8 +250,8 @@ def test_models_documents_get_abilities_editor(
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": True,
"ai_translate": True,
"ai_transform": is_authenticated,
"ai_translate": is_authenticated,
"attachment_upload": True,
"children_create": is_authenticated,
"children_list": True,
@@ -271,6 +278,9 @@ def test_models_documents_get_abilities_editor(
assert all(value is False for value in document.get_abilities(user).values())
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
def test_models_documents_get_abilities_owner(django_assert_num_queries):
"""Check abilities returned for the owner of a document."""
user = factories.UserFactory()
@@ -300,12 +310,16 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
}
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
expected_abilities["move"] = False
assert document.get_abilities(user) == expected_abilities
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
def test_models_documents_get_abilities_administrator(django_assert_num_queries):
"""Check abilities returned for the administrator of a document."""
user = factories.UserFactory()
@@ -335,11 +349,15 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
}
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(value is False for value in document.get_abilities(user).values())
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"""Check abilities returned for the editor of a document."""
user = factories.UserFactory()
@@ -369,23 +387,31 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
}
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(value is False for value in document.get_abilities(user).values())
def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"])
def test_models_documents_get_abilities_reader_user(
ai_access_setting, django_assert_num_queries
):
"""Check abilities returned for the reader of a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "reader")])
access_from_link = (
document.link_reach != "restricted" and document.link_role == "editor"
)
expected_abilities = {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": access_from_link,
"ai_translate": access_from_link,
# If you get your editor rights from the link role and not your access role
# You should not access AI if it's restricted to users with specific access
"ai_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link,
"children_create": access_from_link,
"children_list": True,
@@ -404,11 +430,14 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
"versions_list": True,
"versions_retrieve": True,
}
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(value is False for value in document.get_abilities(user).values())
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(value is False for value in document.get_abilities(user).values())
def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
@@ -446,6 +475,44 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.parametrize(
"is_authenticated,reach",
[
(True, "public"),
(False, "public"),
(True, "authenticated"),
],
)
def test_models_document_get_abilities_ai_access_authenticated(is_authenticated, reach):
"""Validate AI abilities when AI is available to any anonymous user with editor rights."""
user = factories.UserFactory() if is_authenticated else AnonymousUser()
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
abilities = document.get_abilities(user)
assert abilities["ai_transform"] is True
assert abilities["ai_translate"] is True
@override_settings(AI_ALLOW_REACH_FROM="authenticated")
@pytest.mark.parametrize(
"is_authenticated,reach",
[
(True, "public"),
(False, "public"),
(True, "authenticated"),
],
)
def test_models_document_get_abilities_ai_access_public(is_authenticated, reach):
"""Validate AI abilities when AI is available only to authenticated users with editor rights."""
user = factories.UserFactory() if is_authenticated else AnonymousUser()
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
abilities = document.get_abilities(user)
assert abilities["ai_transform"] == is_authenticated
assert abilities["ai_translate"] == is_authenticated
def test_models_documents_get_versions_slice_pagination(settings):
"""
The "get_versions_slice" method should allow navigating all versions of
+4 -5
View File
@@ -516,13 +516,12 @@ class Base(Configuration):
AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None)
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
ACCESSIBILITY_API_BASE_URL = values.Value(
None,
environ_name="ACCESSIBILITY_API_BASE_URL",
AI_ALLOW_REACH_FROM = values.Value(
choices=("public", "authenticated", "restricted"),
default="authenticated",
environ_name="AI_ALLOW_REACH_FROM",
environ_prefix=None,
)
AI_DOCUMENT_RATE_THROTTLE_RATES = {
"minute": 5,
"hour": 100,
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "2.1.0"
version = "2.2.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -1,3 +1,5 @@
/* eslint-disable playwright/no-conditional-expect */
/* eslint-disable playwright/no-conditional-in-test */
import path from 'path';
import { expect, test } from '@playwright/test';
@@ -368,4 +370,77 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
});
[
{ ai_transform: false, ai_translate: false },
{ ai_transform: true, ai_translate: false },
{ ai_transform: false, ai_translate: true },
].forEach(({ ai_transform, ai_translate }) => {
test(`it checks AI buttons when can transform is at "${ai_transform}" and can translate is at "${ai_translate}"`, async ({
page,
}) => {
await mockedDocument(page, {
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
full_name: 'Super Owner',
},
},
],
abilities: {
destroy: true, // Means owner
link_configuration: true,
ai_transform,
ai_translate,
accesses_manage: true,
accesses_view: true,
update: true,
partial_update: true,
retrieve: true,
},
link_reach: 'public',
link_role: 'editor',
created_at: '2021-09-01T09:00:00Z',
});
await goToGridDoc(page);
await verifyDocName(page, 'Mocked document');
await page.locator('.bn-block-outer').last().fill('Hello World');
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').dblclick();
if (!ai_transform && !ai_translate) {
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
return;
}
await page.getByRole('button', { name: 'AI' }).click();
if (ai_transform) {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeHidden();
}
if (ai_translate) {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeHidden();
}
});
});
});
@@ -10,7 +10,7 @@ test.describe('Header', () => {
test('checks all the elements are visible', async ({ page }) => {
const header = page.locator('header').first();
await expect(header.getByAltText('Docs Logo')).toBeVisible();
await expect(header.getByLabel('Docs Logo')).toBeVisible();
await expect(header.locator('h2').getByText('Docs')).toHaveCSS(
'color',
'rgb(0, 0, 145)',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "2.1.0",
"version": "2.2.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "2.1.0",
"version": "2.2.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -34,7 +34,6 @@
"idb": "8.0.2",
"lodash": "4.17.21",
"luxon": "3.5.0",
"marked": "^15.0.7",
"next": "15.1.6",
"posthog-js": "1.215.6",
"react": "*",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

@@ -1,11 +1,10 @@
import { Button } from '@openfun/cunningham-react';
import Image from 'next/image';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { BoxButton } from '@/components';
import ProConnectImg from '../assets/button-proconnect.svg?url';
import ProConnectImg from '../assets/button-proconnect.svg';
import { useAuth } from '../hooks';
import { gotoLogin, gotoLogout } from '../utils';
@@ -41,8 +40,9 @@ export const ProConnectButton = () => {
background-color: var(--c--theme--colors--primary-action);
}
`}
$radius="4px"
>
<Image src={ProConnectImg} alt={t('ProConnect Image')} />
<ProConnectImg />
</BoxButton>
);
};
@@ -1,39 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
export type DocAIPdfTranscribe = {
docId: string;
pdfUrl: string;
};
export type DocAIPdfTranscribeResponse = {
document_id: string;
};
export const docAIPdfTranscribe = async ({
docId,
...params
}: DocAIPdfTranscribe): Promise<DocAIPdfTranscribeResponse> => {
const response = await fetchAPI(`documents/${docId}/ai-pdf-transcribe/`, {
method: 'POST',
body: JSON.stringify({
...params,
}),
});
if (!response.ok) {
throw new APIError(
'Failed to request pdf transcription',
await errorCauses(response),
);
}
return response.json() as Promise<DocAIPdfTranscribeResponse>;
};
export function useDocAIPdfTranscribe() {
return useMutation<DocAIPdfTranscribeResponse, APIError, DocAIPdfTranscribe>({
mutationFn: docAIPdfTranscribe,
});
}
@@ -92,6 +92,13 @@ export function AIGroupButton() {
return null;
}
const canAITransform = currentDoc.abilities.ai_transform;
const canAITranslate = currentDoc.abilities.ai_translate;
if (!canAITransform && !canAITranslate) {
return null;
}
return (
<Components.Generic.Menu.Root>
<Components.Generic.Menu.Trigger>
@@ -111,79 +118,85 @@ export function AIGroupButton() {
className="bn-menu-dropdown bn-drag-handle-menu"
sub={true}
>
<AIMenuItemTransform
action="prompt"
docId={currentDoc.id}
icon={
<Text $isMaterialIcon $size="s">
text_fields
</Text>
}
>
{t('Use as prompt')}
</AIMenuItemTransform>
<AIMenuItemTransform
action="rephrase"
docId={currentDoc.id}
icon={
<Text $isMaterialIcon $size="s">
refresh
</Text>
}
>
{t('Rephrase')}
</AIMenuItemTransform>
<AIMenuItemTransform
action="summarize"
docId={currentDoc.id}
icon={
<Text $isMaterialIcon $size="s">
summarize
</Text>
}
>
{t('Summarize')}
</AIMenuItemTransform>
<AIMenuItemTransform
action="correct"
docId={currentDoc.id}
icon={
<Text $isMaterialIcon $size="s">
check
</Text>
}
>
{t('Correct')}
</AIMenuItemTransform>
<Components.Generic.Menu.Root position="right" sub={true}>
<Components.Generic.Menu.Trigger sub={false}>
<Components.Generic.Menu.Item
className="bn-menu-item"
subTrigger={true}
>
<Box $direction="row" $gap="0.6rem">
{canAITransform && (
<>
<AIMenuItemTransform
action="prompt"
docId={currentDoc.id}
icon={
<Text $isMaterialIcon $size="s">
translate
text_fields
</Text>
{t('Language')}
</Box>
</Components.Generic.Menu.Item>
</Components.Generic.Menu.Trigger>
<Components.Generic.Menu.Dropdown
sub={true}
className="bn-menu-dropdown"
>
{languages.map((language) => (
<AIMenuItemTranslate
key={language.value}
language={language.value}
docId={currentDoc.id}
}
>
{t('Use as prompt')}
</AIMenuItemTransform>
<AIMenuItemTransform
action="rephrase"
docId={currentDoc.id}
icon={
<Text $isMaterialIcon $size="s">
refresh
</Text>
}
>
{t('Rephrase')}
</AIMenuItemTransform>
<AIMenuItemTransform
action="summarize"
docId={currentDoc.id}
icon={
<Text $isMaterialIcon $size="s">
summarize
</Text>
}
>
{t('Summarize')}
</AIMenuItemTransform>
<AIMenuItemTransform
action="correct"
docId={currentDoc.id}
icon={
<Text $isMaterialIcon $size="s">
check
</Text>
}
>
{t('Correct')}
</AIMenuItemTransform>
</>
)}
{canAITranslate && (
<Components.Generic.Menu.Root position="right" sub={true}>
<Components.Generic.Menu.Trigger sub={false}>
<Components.Generic.Menu.Item
className="bn-menu-item"
subTrigger={true}
>
{language.display_name}
</AIMenuItemTranslate>
))}
</Components.Generic.Menu.Dropdown>
</Components.Generic.Menu.Root>
<Box $direction="row" $gap="0.6rem">
<Text $isMaterialIcon $size="s">
translate
</Text>
{t('Language')}
</Box>
</Components.Generic.Menu.Item>
</Components.Generic.Menu.Trigger>
<Components.Generic.Menu.Dropdown
sub={true}
className="bn-menu-dropdown"
>
{languages.map((language) => (
<AIMenuItemTranslate
key={language.value}
language={language.value}
docId={currentDoc.id}
>
{language.display_name}
</AIMenuItemTranslate>
))}
</Components.Generic.Menu.Dropdown>
</Components.Generic.Menu.Root>
)}
</Components.Generic.Menu.Dropdown>
</Components.Generic.Menu.Root>
);
@@ -1,88 +0,0 @@
import {
useBlockNoteEditor,
useComponentsContext,
useSelectedBlocks,
} from '@blocknote/react';
import {
Loader,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Text } from '@/components';
import { useDocStore } from '@/features/docs/doc-management/';
import { useDocAIPdfTranscribe } from '../api/useDocAIPdfTranscribe';
export const AIPdfButton = () => {
const editor = useBlockNoteEditor();
const Components = useComponentsContext();
const selectedBlocks = useSelectedBlocks(editor);
const { t } = useTranslation();
const { currentDoc } = useDocStore();
const { toast } = useToastProvider();
const router = useRouter();
const { mutateAsync: requestAIPdf, isPending } = useDocAIPdfTranscribe();
const [isLoading, setIsLoading] = useState(false);
if (!Components || !currentDoc) {
return null;
}
const show = selectedBlocks.length === 1 && selectedBlocks[0].type === 'file';
if (!show) {
return null;
}
const handlePdfTranscription = async () => {
console.log('selectedBlocks', selectedBlocks);
const pdfBlock = selectedBlocks[0];
const props = pdfBlock.props as { url?: string };
const pdfUrl = props?.url;
console.log('pdfUrl', pdfUrl);
if (!props || !pdfUrl) {
toast(t('No PDF file found'), VariantType.ERROR);
return;
}
setIsLoading(true);
try {
const response = await requestAIPdf({
docId: currentDoc.id,
pdfUrl,
});
setTimeout(() => {
// router.push causes the following error:
// TypeError: Cannot read properties of undefined (reading 'isDestroyed')
// void router.push(`/docs/${response.document_id}`);
window.location.href = `/docs/${response.document_id}?albert=true`;
}, 1000);
} catch (error) {
console.error('error', error);
toast(t('Failed to transcribe PDF'), VariantType.ERROR);
}
};
return (
<Components.FormattingToolbar.Button
className="bn-button bn-menu-item"
data-test="ai-pdf-transcribe"
label="AI"
mainTooltip={t('Transcribe PDF')}
icon={
isLoading ? (
<Loader size="small" />
) : (
<Text $isMaterialIcon $size="l">
auto_awesome
</Text>
)
}
onClick={() => void handlePdfTranscription()}
/>
);
};
@@ -1,303 +0,0 @@
import { Button, Input, Loader } from '@openfun/cunningham-react';
import { marked } from 'marked';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
import styled from 'styled-components';
import { fetchAPI } from '@/api';
import { Box, Text } from '@/components';
import { Doc } from '@/features/docs';
import { useEditorStore } from '../../stores/useEditorStore';
export const AIButtonEl = styled.button`
background-image: url('/assets/ia_baguette.png');
background-size: cover;
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
cursor: pointer;
background-color: transparent;
`;
export const SuggestionButton = styled.button`
display: flex;
align-items: center;
border: none;
cursor: pointer;
background-color: white;
color: var(--c--theme--colors--greyscale-900);
height: 32px;
padding: 0 0.5rem;
border-radius: 4px;
font-weight: 600;
&:hover,
&:focus {
background-color: var(--c--theme--colors--greyscale-100);
}
span.material-icons {
margin-right: 4px;
}
span.sub {
color: var(--c--theme--colors--greyscale-600);
margin-left: 4px;
font-weight: 500;
}
`;
export const AiButton = ({ doc }: { doc: Doc }) => {
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(searchParams.get('albert') === 'true');
return (
<>
<Box
$position="absolute"
$css={`
right: 0;
bottom: 0;
padding: 1rem;
margin: 1rem;
z-index: 1;
`}
>
<AIButtonEl
aria-label="Posez une question à Albert à propos de ce document"
onClick={() => setIsOpen(true)}
/>
</Box>
<AiChat doc={doc} isOpen={isOpen} onClose={() => setIsOpen(false)} />
</>
);
};
type Message = {
role: 'user' | 'assistant';
content: string;
};
const AiChat = (props: { isOpen: boolean; onClose: () => void; doc: Doc }) => {
const [prompt, setPrompt] = useState('');
const { editor } = useEditorStore();
const [isLoading, setIsLoading] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
if (!props.isOpen) {
return null;
}
const newPrompt = async (prompt: string) => {
if (!editor) {
return;
}
setIsLoading(true);
setMessages([...messages, { role: 'user', content: prompt }]);
const editorContentFormatted = await editor.blocksToMarkdownLossy();
const response = await fetchAPI(`documents/${props.doc.id}/ai-proxy/`, {
method: 'POST',
body: JSON.stringify({
system:
'You are a helpful assistant. You are given a text in markdown format and you need to answer the question. Here is the text: ' +
editorContentFormatted,
text: prompt,
}),
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const data = (await response.json()) as string;
console.log('response', data);
setMessages((messages) => [
...messages,
{ role: 'assistant', content: data },
]);
setIsLoading(false);
};
const submit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setPrompt(''); // Clear the prompt after submitting the form
await newPrompt(prompt);
};
return (
<Box
$position="absolute"
$css={`
right: 0;
bottom: 0;
padding: 1rem;
width: 450px;
min-height: min(61vh, 365px);
box-shadow: rgba(15, 15, 15, 0.04) 0px 0px 0px 1px, rgba(15, 15, 15, 0.03) 0px 3px 6px, rgba(15, 15, 15, 0.06) 0px 9px 24px;
max-height: max(-180px + 100vh, 365px);
overflow-y: auto;
border-radius: 16px;
background-color: white;
margin: 1rem;
z-index: 2;
`}
$direction="column"
>
<Box $direction="row" $align="center" $justify="space-between">
<Text $theme="greyscale" $variation="1000" $weight="bold" $size="s">
{messages.length == 0 ? '' : 'Demander à Albert'}
</Text>
<Button
size="small"
onClick={props.onClose}
color="tertiary-text"
icon={<span className="material-icons">close</span>}
/>
</Box>
{messages.length == 0 && (
<Box $gap="1rem" $position="relative" $css="top: -24px;">
<Box $gap="0.5rem">
<Box
$css={`
background-image: url('/assets/ia_baguette_question_mark.png');
background-size: cover;
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
cursor: pointer;
`}
></Box>
<Text $theme="primary" $variation="800">
Bonjour, comment puis-je vous aider ?
</Text>
</Box>
<Box $gap="0.5rem">
<Text $theme="greyscale" $variation="1000" $weight="bold" $size="s">
Suggestions
</Text>
<Box>
<SuggestionButton
onClick={() =>
void newPrompt(
'Resume ce document sous forme textuelle uniquement',
)
}
>
<span className="material-icons">description</span>
Résumer <span className="sub">cette page</span>
</SuggestionButton>
<SuggestionButton
onClick={() =>
void newPrompt('Quel est le sujet principal de ce document ?')
}
>
<span className="material-icons">help_center</span>
Poser des questions <span className="sub">sur cette page</span>
</SuggestionButton>
</Box>
</Box>
</Box>
)}
<Box
$flex={1}
$direction="column"
$gap="1rem"
$css={`
overflow-y: auto;
font-size: 14px;
mask-image: linear-gradient(black calc(100% - 32px), transparent calc(100% - 4px));
padding-bottom: 32px;
`}
aria-live="polite"
>
{messages.map((message, index) => (
<Message key={index} message={message} />
))}
{(isLoading || false) && (
<Box $display="flex" $direction="row" $align="center" $gap="0.5rem">
<Loader size="small" />
Albert réfléchit ...
</Box>
)}
</Box>
<Box>
<form onSubmit={(e) => void submit(e)} style={{ width: '100%' }}>
<Input
type="text"
label="Posez votre question"
name="prompt"
fullWidth={true}
onChange={(e) => setPrompt(e.target.value)}
value={prompt} // Ensure the input value is updated with the state
rightIcon={<span className="material-icons">send</span>}
/>
</form>
</Box>
</Box>
);
};
const Message = ({ message }: { message: Message }) => {
return (
<Box>
<Box $direction="row" $align="center" $gap="0.5rem">
{message.role === 'user' ? (
<Box
aria-hidden={true}
$css={`
background-color:#417DC4;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
cursor: pointer;
color: white;
font-size: 10px;
align-items: center;
justify-content: center;
display: flex;
`}
>
VD
</Box>
) : (
<Box
aria-hidden={true}
$css={`
background-image: url('/assets/ia_baguette.png');
background-size: cover;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
cursor: pointer;
`}
></Box>
)}
<Text $weight="bold">
{message.role === 'user' ? 'Vous' : 'Albert'}
</Text>
</Box>
<Box
$css={`
font-size: 12px;
padding-left: 34px;
color: var(--c--theme--colors--greyscale-700);
p {
margin: 0;
}
`}
dangerouslySetInnerHTML={{
__html: marked.parse(message.content) as string,
}}
></Box>
</Box>
);
};
@@ -8,27 +8,21 @@ import {
import React, { useCallback } from 'react';
import { AIGroupButton } from './AIButton';
import { AIPdfButton } from './AIPdfButton';
import { MarkdownButton } from './MarkdownButton';
export const BlockNoteToolbar = () => {
const formattingToolbar = useCallback(
({ blockTypeSelectItems }: FormattingToolbarProps) => {
console.log('formattingToolbar', blockTypeSelectItems);
return (
<FormattingToolbar>
{getFormattingToolbarItems(blockTypeSelectItems)}
({ blockTypeSelectItems }: FormattingToolbarProps) => (
<FormattingToolbar>
{getFormattingToolbarItems(blockTypeSelectItems)}
{/* Extra button to do some AI powered actions */}
<AIGroupButton key="AIButton" />
{/* Extra button to do some AI powered actions */}
<AIGroupButton key="AIButton" />
{/* Extra button to convert from markdown to json */}
<MarkdownButton key="customButton" />
<AIPdfButton />
</FormattingToolbar>
);
},
{/* Extra button to convert from markdown to json */}
<MarkdownButton key="customButton" />
</FormattingToolbar>
),
[],
);
@@ -16,7 +16,6 @@ import { TableContent } from '@/features/docs/doc-table-content/';
import { Versions, useDocVersion } from '@/features/docs/doc-versioning/';
import { useResponsiveStore } from '@/stores';
import { AiButton } from './Ai/AiButton';
import { BlockNoteEditor, BlockNoteEditorVersion } from './BlockNoteEditor';
interface DocEditorProps {
@@ -39,7 +38,6 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
return (
<>
<AiButton doc={doc} />
{isDesktop && !isVersion && (
<Box
$position="absolute"
@@ -67,7 +65,7 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
$css="overflow-x: clip; flex: 1;"
$position="relative"
>
<Box $css="flex:1;" $overflow="auto" $position="relative">
<Box $css="flex:1;" $position="relative" $width="100%">
{isVersion ? (
<DocVersionEditor docId={doc.id} versionId={versionId} />
) : (
@@ -11,7 +11,7 @@ export const cssEditor = (readonly: boolean) => css`
}
.collaboration-cursor-custom__caret {
position: absolute;
height: 85%;
height: 100%;
width: 2px;
bottom: 4%;
left: -1px;
@@ -48,10 +48,20 @@ export interface Doc {
abilities: {
accesses_manage: boolean;
accesses_view: boolean;
attachment_upload: true;
ai_transform: boolean;
ai_translate: boolean;
attachment_upload: boolean;
children_create: boolean;
children_list: boolean;
collaboration_auth: boolean;
destroy: boolean;
favorite: boolean;
invite_owner: boolean;
link_configuration: boolean;
media_auth: boolean;
move: boolean;
partial_update: boolean;
restore: boolean;
retrieve: boolean;
update: boolean;
versions_destroy: boolean;
@@ -1,8 +1,7 @@
import Image from 'next/image';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { default as IconDocs } from '@/assets/icons/icon-docs.svg?url';
import IconDocs from '@/assets/icons/icon-docs.svg';
import { Box, StyledLink } from '@/components/';
import { useCunninghamTheme } from '@/cunningham';
import { ButtonLogin } from '@/features/auth';
@@ -51,7 +50,7 @@ export const Header = () => {
$height="fit-content"
$margin={{ top: 'auto' }}
>
<Image priority src={IconDocs} alt={t('Docs Logo')} width={25} />
<IconDocs aria-label={t('Docs Logo')} width={25} />
<Title />
</Box>
</StyledLink>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 407 KiB

@@ -1,7 +1,7 @@
import Image from 'next/image';
import { useTranslation } from 'react-i18next';
import { default as IconDocs } from '@/assets/icons/icon-docs.svg?url';
import IconDocs from '@/assets/icons/icon-docs.svg';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { ButtonTogglePanel, Title } from '@/features/header/';
@@ -61,7 +61,7 @@ export const HomeHeader = () => {
$position="relative"
$height="fit-content"
>
<Image priority src={IconDocs} alt={t('Docs Logo')} width={32} />
<IconDocs aria-label={t('Docs Logo')} width={32} />
<Title />
</Box>
</Box>
@@ -175,7 +175,7 @@ export const HomeSection = ({
height: 'fit-content',
margin: 'auto',
overflow: 'auto',
flexBasis: direction === 'column' ? '100%' : '50%',
flexBasis: direction === 'column' ? 'fit-content' : '50%',
}}
/>
)}
@@ -195,10 +195,20 @@ export class ApiPlugin implements WorkboxPlugin {
abilities: {
accesses_manage: true,
accesses_view: true,
ai_transform: true,
ai_translate: true,
attachment_upload: true,
children_create: true,
children_list: true,
collaboration_auth: true,
destroy: true,
favorite: true,
invite_owner: true,
link_configuration: true,
media_auth: true,
move: true,
partial_update: true,
restore: true,
retrieve: true,
update: true,
versions_destroy: true,
-25849
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "impress",
"version": "2.1.0",
"version": "2.2.0",
"private": true,
"workspaces": {
"packages": [
@@ -1,6 +1,6 @@
{
"name": "eslint-config-impress",
"version": "2.1.0",
"version": "2.2.0",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "2.1.0",
"version": "2.2.0",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:impress",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "server-y-provider",
"version": "2.1.0",
"version": "2.2.0",
"description": "Y.js provider for docs",
"repository": "https://github.com/numerique-gouv/impress",
"license": "MIT",
+14666 -14650
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -93,4 +93,4 @@ releases:
environments:
dev:
values:
- version: 2.1.0
- version: 2.2.0
+1 -1
View File
@@ -1,5 +1,5 @@
apiVersion: v2
type: application
name: docs
version: 2.1.0-beta-1
version: 2.2.0-beta.1
appVersion: latest
+2 -2
View File
@@ -2,7 +2,7 @@
<mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100">
<mj-wrapper css-class="wrapper" padding="0 25px 0px 25px">
<mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px">
<mj-section css-class="wrapper-logo">
<mj-column>
<mj-image
@@ -14,7 +14,7 @@
/>
</mj-column>
</mj-section>
<mj-section mj-class="bg--white-100" padding="30px 20px 60px 20px">
<mj-section mj-class="bg--white-100" padding="0px 20px 60px 20px">
<mj-column>
<mj-text align="center">
<h1>{{title|capfirst}}</h1>
+1 -1
View File
@@ -13,7 +13,7 @@
<mj-all
font-family="Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif"
font-size="16px"
line-height="1.3em"
line-height="normal"
color="#3A3A3A"
/>
</mj-attributes>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "2.1.0",
"version": "2.2.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {