mirror of
https://github.com/suitenumerique/docs.git
synced 2026-10-01 05:55:16 +02:00
Merge remote-tracking branch 'origin/main' into feature/doc-import
This commit is contained in:
@@ -1018,3 +1018,13 @@ class ThreadSerializer(serializers.ModelSerializer):
|
||||
if request:
|
||||
return thread.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
class SearchDocumentSerializer(serializers.Serializer):
|
||||
"""Serializer for fulltext search requests through Find application"""
|
||||
|
||||
q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
|
||||
page_size = serializers.IntegerField(
|
||||
required=False, min_value=1, max_value=50, default=20
|
||||
)
|
||||
page = serializers.IntegerField(required=False, min_value=1, default=1)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Throttling modules for the API."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from lasuite.drf.throttling import MonitoredScopedRateThrottle
|
||||
from rest_framework.throttling import UserRateThrottle
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
@@ -19,3 +22,30 @@ class UserListThrottleSustained(UserRateThrottle):
|
||||
"""Throttle for the user list endpoint."""
|
||||
|
||||
scope = "user_list_sustained"
|
||||
|
||||
|
||||
class DocumentThrottle(MonitoredScopedRateThrottle):
|
||||
"""
|
||||
Throttle for document-related endpoints, with an exception for requests from the
|
||||
collaboration server.
|
||||
"""
|
||||
|
||||
scope = "document"
|
||||
|
||||
def allow_request(self, request, view):
|
||||
"""
|
||||
Override to skip throttling for requests from the collaboration server.
|
||||
|
||||
Verifies the X-Y-Provider-Key header contains a valid Y_PROVIDER_API_KEY.
|
||||
Using a custom header instead of Authorization to avoid triggering
|
||||
authentication middleware.
|
||||
"""
|
||||
|
||||
y_provider_header = request.headers.get("X-Y-Provider-Key", "")
|
||||
|
||||
# Check if this is a valid y-provider request and exempt from throttling
|
||||
y_provider_key = getattr(settings, "Y_PROVIDER_API_KEY", None)
|
||||
if y_provider_key and y_provider_header == y_provider_key:
|
||||
return True
|
||||
|
||||
return super().allow_request(request, view)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""API endpoints"""
|
||||
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import base64
|
||||
@@ -18,10 +19,11 @@ from django.core.validators import URLValidator
|
||||
from django.db import connection, transaction
|
||||
from django.db import models as db
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Left, Length
|
||||
from django.db.models.functions import Greatest, Left, Length
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.text import capfirst, slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -32,11 +34,13 @@ from botocore.exceptions import ClientError
|
||||
from csp.constants import NONE
|
||||
from csp.decorators import csp_update
|
||||
from lasuite.malware_detection import malware_detection
|
||||
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework import response as drf_response
|
||||
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
|
||||
@@ -50,12 +54,23 @@ from core.services.converter_services import (
|
||||
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,
|
||||
)
|
||||
from core.tasks.mail import send_ask_for_access_mail
|
||||
from core.utils import extract_attachments, filter_descendants
|
||||
|
||||
from . import permissions, serializers, utils
|
||||
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
|
||||
from .throttling import UserListThrottleBurst, UserListThrottleSustained
|
||||
from .throttling import (
|
||||
DocumentThrottle,
|
||||
UserListThrottleBurst,
|
||||
UserListThrottleSustained,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -190,13 +205,15 @@ class UserViewSet(
|
||||
queryset = queryset.exclude(documentaccess__document_id=document_id)
|
||||
|
||||
filter_data = filterset.form.cleaned_data
|
||||
query = filter_data["q"]
|
||||
query = remove_accents(filter_data["q"])
|
||||
|
||||
# For emails, match emails by Levenstein distance to prevent typing errors
|
||||
if "@" in query:
|
||||
return (
|
||||
queryset.annotate(
|
||||
distance=RawSQL("levenshtein(email::text, %s::text)", (query,))
|
||||
distance=RawSQL(
|
||||
"levenshtein(unaccent(email::text), %s::text)", (query,)
|
||||
)
|
||||
)
|
||||
.filter(distance__lte=3)
|
||||
.order_by("distance", "email")[: settings.API_USERS_LIST_LIMIT]
|
||||
@@ -205,11 +222,15 @@ class UserViewSet(
|
||||
# Use trigram similarity for non-email-like queries
|
||||
# For performance reasons we filter first by similarity, which relies on an
|
||||
# index, then only calculate precise similarity scores for sorting purposes
|
||||
|
||||
return (
|
||||
queryset.filter(email__trigram_word_similar=query)
|
||||
.annotate(similarity=TrigramSimilarity("email", query))
|
||||
queryset.annotate(
|
||||
sim_email=TrigramSimilarity("email", query),
|
||||
sim_name=TrigramSimilarity("full_name", query),
|
||||
)
|
||||
.annotate(similarity=Greatest("sim_email", "sim_name"))
|
||||
.filter(similarity__gt=0.2)
|
||||
.order_by("-similarity", "email")[: settings.API_USERS_LIST_LIMIT]
|
||||
.order_by("-similarity")[: settings.API_USERS_LIST_LIMIT]
|
||||
)
|
||||
|
||||
@drf.decorators.action(
|
||||
@@ -367,15 +388,18 @@ class DocumentViewSet(
|
||||
permission_classes = [
|
||||
permissions.DocumentPermission,
|
||||
]
|
||||
throttle_classes = [DocumentThrottle]
|
||||
throttle_scope = "document"
|
||||
queryset = models.Document.objects.select_related("creator").all()
|
||||
serializer_class = serializers.DocumentSerializer
|
||||
ai_translate_serializer_class = serializers.AITranslateSerializer
|
||||
all_serializer_class = serializers.ListDocumentSerializer
|
||||
children_serializer_class = serializers.ListDocumentSerializer
|
||||
descendants_serializer_class = serializers.ListDocumentSerializer
|
||||
list_serializer_class = serializers.ListDocumentSerializer
|
||||
trashbin_serializer_class = serializers.ListDocumentSerializer
|
||||
tree_serializer_class = serializers.ListDocumentSerializer
|
||||
search_serializer_class = serializers.ListDocumentSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get queryset performing all annotation and filtering on the document tree structure."""
|
||||
@@ -862,6 +886,60 @@ class DocumentViewSet(
|
||||
},
|
||||
)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=False,
|
||||
methods=["get"],
|
||||
)
|
||||
def all(self, request, *args, **kwargs):
|
||||
"""
|
||||
Returns all documents (including descendants) that the user has access to.
|
||||
|
||||
Unlike the list endpoint which only returns top-level documents, this endpoint
|
||||
returns all documents including children, grandchildren, etc.
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
accessible_documents = self.get_queryset()
|
||||
accessible_paths = list(accessible_documents.values_list("path", flat=True))
|
||||
|
||||
if not accessible_paths:
|
||||
return self.get_response_for_queryset(self.queryset.none())
|
||||
|
||||
# Build query to include all descendants using path prefix matching
|
||||
descendants_clause = db.Q()
|
||||
for path in accessible_paths:
|
||||
descendants_clause |= db.Q(path__startswith=path)
|
||||
|
||||
queryset = self.queryset.filter(
|
||||
descendants_clause, ancestors_deleted_at__isnull=True
|
||||
)
|
||||
|
||||
# Apply existing filters
|
||||
filterset = ListDocumentFilter(
|
||||
self.request.GET, queryset=queryset, request=self.request
|
||||
)
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
filter_data = filterset.form.cleaned_data
|
||||
|
||||
# Filter as early as possible on fields that are available on the model
|
||||
for field in ["is_creator_me", "title"]:
|
||||
queryset = filterset.filters[field].filter(queryset, filter_data[field])
|
||||
|
||||
queryset = queryset.annotate_user_roles(user)
|
||||
|
||||
# Annotate favorite status and filter if applicable as late as possible
|
||||
queryset = queryset.annotate_is_favorite(user)
|
||||
for field in ["is_favorite", "is_masked"]:
|
||||
queryset = filterset.filters[field].filter(queryset, filter_data[field])
|
||||
|
||||
# Apply ordering only now that everything is filtered and annotated
|
||||
queryset = filters.OrderingFilter().filter_queryset(
|
||||
self.request, queryset, self
|
||||
)
|
||||
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
@@ -1089,6 +1167,83 @@ class DocumentViewSet(
|
||||
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
def _search_simple(self, request, text):
|
||||
"""
|
||||
Returns a queryset filtered by the content of the document title
|
||||
"""
|
||||
# As the 'list' view we get a prefiltered queryset (deleted docs are excluded)
|
||||
queryset = self.get_queryset()
|
||||
filterset = DocumentFilter({"title": text}, queryset=queryset)
|
||||
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.filter_queryset(queryset)
|
||||
|
||||
return self.get_response_for_queryset(
|
||||
queryset.order_by("-updated_at"),
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
def _search_fulltext(self, indexer, request, params):
|
||||
"""
|
||||
Returns a queryset from the results the fulltext search of Find
|
||||
"""
|
||||
access_token = request.session.get("oidc_access_token")
|
||||
user = request.user
|
||||
text = params.validated_data["q"]
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
# Retrieve the documents ids from Find.
|
||||
results = indexer.search(
|
||||
text=text,
|
||||
token=access_token,
|
||||
visited=get_visited_document_ids_of(queryset, user),
|
||||
)
|
||||
|
||||
docs_by_uuid = {str(d.pk): d for d in queryset.filter(pk__in=results)}
|
||||
ordered_docs = [docs_by_uuid[id] for id in results]
|
||||
|
||||
page = self.paginate_queryset(ordered_docs)
|
||||
|
||||
serializer = self.get_serializer(
|
||||
page if page else ordered_docs,
|
||||
many=True,
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
|
||||
@method_decorator(refresh_oidc_access_token)
|
||||
def search(self, request, *args, **kwargs):
|
||||
"""
|
||||
Returns a DRF response containing the filtered, annotated and ordered document list.
|
||||
|
||||
Applies filtering based on request parameter 'q' from `SearchDocumentSerializer`.
|
||||
Depending of the configuration it can be:
|
||||
- A fulltext search through the opensearch indexation app "find" if the backend is
|
||||
enabled (see SEARCH_INDEXER_CLASS)
|
||||
- A filtering by the model field 'title'.
|
||||
|
||||
The ordering is always by the most recent first.
|
||||
"""
|
||||
params = serializers.SearchDocumentSerializer(data=request.query_params)
|
||||
params.is_valid(raise_exception=True)
|
||||
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if indexer:
|
||||
return self._search_fulltext(indexer, request, params=params)
|
||||
|
||||
# The indexer is not configured, we fallback on a simple icontains filter by the
|
||||
# model field 'title'.
|
||||
return self._search_simple(request, text=params.validated_data["q"])
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
def versions_list(self, request, *args, **kwargs):
|
||||
"""
|
||||
@@ -2124,6 +2279,7 @@ class ConfigView(drf.views.APIView):
|
||||
"ENVIRONMENT",
|
||||
"FRONTEND_CSS_URL",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
|
||||
"FRONTEND_JS_URL",
|
||||
"FRONTEND_THEME",
|
||||
"MEDIA_BASE_URL",
|
||||
"POSTHOG_KEY",
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
"""Impress Core application"""
|
||||
# from django.apps import AppConfig
|
||||
# from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
# class CoreConfig(AppConfig):
|
||||
# """Configuration class for the impress core app."""
|
||||
class CoreConfig(AppConfig):
|
||||
"""Configuration class for the impress core app."""
|
||||
|
||||
# name = "core"
|
||||
# app_label = "core"
|
||||
# verbose_name = _("impress core application")
|
||||
name = "core"
|
||||
app_label = "core"
|
||||
verbose_name = _("Impress core application")
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Import signals when the app is ready.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel, unused-import
|
||||
from . import signals # noqa: PLC0415
|
||||
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
from lasuite.marketing.tasks import create_or_update_contact
|
||||
from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
@@ -57,3 +58,22 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
|
||||
except DuplicateEmailError as err:
|
||||
raise SuspiciousOperation(err.message) from err
|
||||
|
||||
def post_get_or_create_user(self, user, claims, is_new_user):
|
||||
"""
|
||||
Post-processing after user creation or retrieval.
|
||||
|
||||
Args:
|
||||
user (User): The user instance.
|
||||
claims (dict): The claims dictionary.
|
||||
is_new_user (bool): Indicates if the user was newly created.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
|
||||
"""
|
||||
|
||||
if is_new_user and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
create_or_update_contact.delay(
|
||||
email=user.email, attributes={"DOCS_SOURCE": ["SIGNIN"]}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Handle search setup that needs to be done at bootstrap time.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from core.services.search_indexers import get_document_indexer
|
||||
|
||||
logger = logging.getLogger("docs.search.bootstrap_search")
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Index all documents to remote search service"""
|
||||
|
||||
help = __doc__
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""Add argument to require forcing execution when not in debug mode."""
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
action="store",
|
||||
dest="batch_size",
|
||||
type=int,
|
||||
default=50,
|
||||
help="Indexation query batch size",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Launch and log search index generation."""
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if not indexer:
|
||||
raise CommandError("The indexer is not enabled or properly configured.")
|
||||
|
||||
logger.info("Starting to regenerate Find index...")
|
||||
start = time.perf_counter()
|
||||
batch_size = options["batch_size"]
|
||||
|
||||
try:
|
||||
count = indexer.index(batch_size=batch_size)
|
||||
except Exception as err:
|
||||
raise CommandError("Unable to regenerate index") from err
|
||||
|
||||
duration = time.perf_counter() - start
|
||||
logger.info(
|
||||
"Search index regenerated from %d document(s) in %.2f seconds.",
|
||||
count,
|
||||
duration,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-20 09:56
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0026_comments"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
CREATE OR REPLACE FUNCTION public.immutable_unaccent(regdictionary, text)
|
||||
RETURNS text
|
||||
LANGUAGE c IMMUTABLE PARALLEL SAFE STRICT AS
|
||||
'$libdir/unaccent', 'unaccent_dict';
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.f_unaccent(text)
|
||||
RETURNS text
|
||||
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
|
||||
RETURN public.immutable_unaccent(regdictionary 'public.unaccent', $1);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_email_unaccent_trgm_idx
|
||||
ON impress_user
|
||||
USING gin (f_unaccent(email) gin_trgm_ops);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_full_name_unaccent_trgm_idx
|
||||
ON impress_user
|
||||
USING gin (f_unaccent(full_name) gin_trgm_ops);
|
||||
""",
|
||||
reverse_sql="""
|
||||
DROP INDEX IF EXISTS user_email_unaccent_trgm_idx;
|
||||
DROP INDEX IF EXISTS user_full_name_unaccent_trgm_idx;
|
||||
""",
|
||||
),
|
||||
]
|
||||
+27
-23
@@ -432,32 +432,35 @@ class Document(MP_Node, BaseModel):
|
||||
def save(self, *args, **kwargs):
|
||||
"""Write content to object storage only if _content has changed."""
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if self._content:
|
||||
file_key = self.file_key
|
||||
bytes_content = self._content.encode("utf-8")
|
||||
self.save_content(self._content)
|
||||
|
||||
# Attempt to directly check if the object exists using the storage client.
|
||||
try:
|
||||
response = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file_key
|
||||
)
|
||||
except ClientError as excpt:
|
||||
# If the error is a 404, the object doesn't exist, so we should create it.
|
||||
if excpt.response["Error"]["Code"] == "404":
|
||||
has_changed = True
|
||||
else:
|
||||
raise
|
||||
def save_content(self, content):
|
||||
"""Save content to object storage."""
|
||||
|
||||
file_key = self.file_key
|
||||
bytes_content = content.encode("utf-8")
|
||||
|
||||
# Attempt to directly check if the object exists using the storage client.
|
||||
try:
|
||||
response = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file_key
|
||||
)
|
||||
except ClientError as excpt:
|
||||
# If the error is a 404, the object doesn't exist, so we should create it.
|
||||
if excpt.response["Error"]["Code"] == "404":
|
||||
has_changed = True
|
||||
else:
|
||||
# Compare the existing ETag with the MD5 hash of the new content.
|
||||
has_changed = (
|
||||
response["ETag"].strip('"')
|
||||
!= hashlib.md5(bytes_content).hexdigest() # noqa: S324
|
||||
)
|
||||
raise
|
||||
else:
|
||||
# Compare the existing ETag with the MD5 hash of the new content.
|
||||
has_changed = (
|
||||
response["ETag"].strip('"') != hashlib.md5(bytes_content).hexdigest() # noqa: S324
|
||||
)
|
||||
|
||||
if has_changed:
|
||||
content_file = ContentFile(bytes_content)
|
||||
default_storage.save(file_key, content_file)
|
||||
if has_changed:
|
||||
content_file = ContentFile(bytes_content)
|
||||
default_storage.save(file_key, content_file)
|
||||
|
||||
def is_leaf(self):
|
||||
"""
|
||||
@@ -903,7 +906,8 @@ class Document(MP_Node, BaseModel):
|
||||
|
||||
# Mark all descendants as soft deleted
|
||||
self.get_descendants().filter(ancestors_deleted_at__isnull=True).update(
|
||||
ancestors_deleted_at=self.ancestors_deleted_at
|
||||
ancestors_deleted_at=self.ancestors_deleted_at,
|
||||
updated_at=self.updated_at,
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Document search index management utilities and indexers"""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from functools import cache
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import Subquery
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import requests
|
||||
|
||||
from core import models, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@cache
|
||||
def get_document_indexer():
|
||||
"""Returns an instance of indexer service if enabled and properly configured."""
|
||||
classpath = settings.SEARCH_INDEXER_CLASS
|
||||
|
||||
# For this usecase an empty indexer class is not an issue but a feature.
|
||||
if not classpath:
|
||||
logger.info("Document indexer is not configured (see SEARCH_INDEXER_CLASS)")
|
||||
return None
|
||||
|
||||
try:
|
||||
indexer_class = import_string(settings.SEARCH_INDEXER_CLASS)
|
||||
return indexer_class()
|
||||
except ImportError as err:
|
||||
logger.error("SEARCH_INDEXER_CLASS setting is not valid : %s", err)
|
||||
except ImproperlyConfigured as err:
|
||||
logger.error("Document indexer is not properly configured : %s", err)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_batch_accesses_by_users_and_teams(paths):
|
||||
"""
|
||||
Get accesses related to a list of document paths,
|
||||
grouped by users and teams, including all ancestor paths.
|
||||
"""
|
||||
ancestor_map = utils.get_ancestor_to_descendants_map(
|
||||
paths, steplen=models.Document.steplen
|
||||
)
|
||||
ancestor_paths = list(ancestor_map.keys())
|
||||
|
||||
access_qs = models.DocumentAccess.objects.filter(
|
||||
document__path__in=ancestor_paths
|
||||
).values("document__path", "user__sub", "team")
|
||||
|
||||
access_by_document_path = defaultdict(lambda: {"users": set(), "teams": set()})
|
||||
|
||||
for access in access_qs:
|
||||
ancestor_path = access["document__path"]
|
||||
user_sub = access["user__sub"]
|
||||
team = access["team"]
|
||||
|
||||
for descendant_path in ancestor_map.get(ancestor_path, []):
|
||||
if user_sub:
|
||||
access_by_document_path[descendant_path]["users"].add(str(user_sub))
|
||||
if team:
|
||||
access_by_document_path[descendant_path]["teams"].add(team)
|
||||
|
||||
return dict(access_by_document_path)
|
||||
|
||||
|
||||
def get_visited_document_ids_of(queryset, user):
|
||||
"""
|
||||
Returns the ids of the documents that have a linktrace to the user and NOT owned.
|
||||
It will be use to limit the opensearch responses to the public documents already
|
||||
"visited" by the user.
|
||||
"""
|
||||
if isinstance(user, AnonymousUser):
|
||||
return []
|
||||
|
||||
qs = models.LinkTrace.objects.filter(user=user)
|
||||
|
||||
docs = (
|
||||
queryset.exclude(accesses__user=user)
|
||||
.filter(
|
||||
deleted_at__isnull=True,
|
||||
ancestors_deleted_at__isnull=True,
|
||||
)
|
||||
.filter(pk__in=Subquery(qs.values("document_id")))
|
||||
.order_by("pk")
|
||||
.distinct("pk")
|
||||
)
|
||||
|
||||
return [str(id) for id in docs.values_list("pk", flat=True)]
|
||||
|
||||
|
||||
class BaseDocumentIndexer(ABC):
|
||||
"""
|
||||
Base class for document indexers.
|
||||
|
||||
Handles batching and access resolution. Subclasses must implement both
|
||||
`serialize_document()` and `push()` to define backend-specific behavior.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the indexer.
|
||||
"""
|
||||
self.batch_size = settings.SEARCH_INDEXER_BATCH_SIZE
|
||||
self.indexer_url = settings.SEARCH_INDEXER_URL
|
||||
self.indexer_secret = settings.SEARCH_INDEXER_SECRET
|
||||
self.search_url = settings.SEARCH_INDEXER_QUERY_URL
|
||||
self.search_limit = settings.SEARCH_INDEXER_QUERY_LIMIT
|
||||
|
||||
if not self.indexer_url:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_URL must be set in Django settings."
|
||||
)
|
||||
|
||||
if not self.indexer_secret:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_SECRET must be set in Django settings."
|
||||
)
|
||||
|
||||
if not self.search_url:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_QUERY_URL must be set in Django settings."
|
||||
)
|
||||
|
||||
def index(self, queryset=None, batch_size=None):
|
||||
"""
|
||||
Fetch documents in batches, serialize them, and push to the search backend.
|
||||
|
||||
Args:
|
||||
queryset (optional): Document queryset
|
||||
Defaults to all documents without filter.
|
||||
batch_size (int, optional): Number of documents per batch.
|
||||
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
|
||||
"""
|
||||
last_id = 0
|
||||
count = 0
|
||||
queryset = queryset or models.Document.objects.all()
|
||||
batch_size = batch_size or self.batch_size
|
||||
|
||||
while True:
|
||||
documents_batch = list(
|
||||
queryset.filter(
|
||||
id__gt=last_id,
|
||||
).order_by("id")[:batch_size]
|
||||
)
|
||||
|
||||
if not documents_batch:
|
||||
break
|
||||
|
||||
doc_paths = [doc.path for doc in documents_batch]
|
||||
last_id = documents_batch[-1].id
|
||||
accesses_by_document_path = get_batch_accesses_by_users_and_teams(doc_paths)
|
||||
|
||||
serialized_batch = [
|
||||
self.serialize_document(document, accesses_by_document_path)
|
||||
for document in documents_batch
|
||||
if document.content or document.title
|
||||
]
|
||||
|
||||
if serialized_batch:
|
||||
self.push(serialized_batch)
|
||||
count += len(serialized_batch)
|
||||
|
||||
return count
|
||||
|
||||
@abstractmethod
|
||||
def serialize_document(self, document, accesses):
|
||||
"""
|
||||
Convert a Document instance to a JSON-serializable format for indexing.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def push(self, data):
|
||||
"""
|
||||
Push a batch of serialized documents to the backend.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
||||
def search(self, text, token, visited=(), nb_results=None):
|
||||
"""
|
||||
Search for documents in Find app.
|
||||
Ensure the same default ordering as "Docs" list : -updated_at
|
||||
|
||||
Returns ids of the documents
|
||||
|
||||
Args:
|
||||
text (str): Text search content.
|
||||
token (str): OIDC Authentication token.
|
||||
visited (list, optional):
|
||||
List of ids of active public documents with LinkTrace
|
||||
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
|
||||
nb_results (int, optional):
|
||||
The number of results to return.
|
||||
Defaults to 50 if not specified.
|
||||
"""
|
||||
nb_results = nb_results or self.search_limit
|
||||
response = self.search_query(
|
||||
data={
|
||||
"q": text,
|
||||
"visited": visited,
|
||||
"services": ["docs"],
|
||||
"nb_results": nb_results,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
|
||||
return [d["_id"] for d in response]
|
||||
|
||||
@abstractmethod
|
||||
def search_query(self, data, token) -> dict:
|
||||
"""
|
||||
Retrieve documents from the Find app API.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
|
||||
class SearchIndexer(BaseDocumentIndexer):
|
||||
"""
|
||||
Document indexer that pushes documents to La Suite Find app.
|
||||
"""
|
||||
|
||||
def serialize_document(self, document, accesses):
|
||||
"""
|
||||
Convert a Document to the JSON format expected by La Suite Find.
|
||||
|
||||
Args:
|
||||
document (Document): The document instance.
|
||||
accesses (dict): Mapping of document ID to user/team access.
|
||||
|
||||
Returns:
|
||||
dict: A JSON-serializable dictionary.
|
||||
"""
|
||||
doc_path = document.path
|
||||
doc_content = document.content
|
||||
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
|
||||
|
||||
return {
|
||||
"id": str(document.id),
|
||||
"title": document.title or "",
|
||||
"content": text_content,
|
||||
"depth": document.depth,
|
||||
"path": document.path,
|
||||
"numchild": document.numchild,
|
||||
"created_at": document.created_at.isoformat(),
|
||||
"updated_at": document.updated_at.isoformat(),
|
||||
"users": list(accesses.get(doc_path, {}).get("users", set())),
|
||||
"groups": list(accesses.get(doc_path, {}).get("teams", set())),
|
||||
"reach": document.computed_link_reach,
|
||||
"size": len(text_content.encode("utf-8")),
|
||||
"is_active": not bool(document.ancestors_deleted_at),
|
||||
}
|
||||
|
||||
def search_query(self, data, token) -> requests.Response:
|
||||
"""
|
||||
Retrieve documents from the Find app API.
|
||||
|
||||
Args:
|
||||
data (dict): search data
|
||||
token (str): OICD token
|
||||
|
||||
Returns:
|
||||
dict: A JSON-serializable dictionary.
|
||||
"""
|
||||
response = requests.post(
|
||||
self.search_url,
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def push(self, data):
|
||||
"""
|
||||
Push a batch of documents to the Find backend.
|
||||
|
||||
Args:
|
||||
data (list): List of document dictionaries.
|
||||
"""
|
||||
response = requests.post(
|
||||
self.indexer_url,
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {self.indexer_secret}"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Declare and configure the signals for the impress core application
|
||||
"""
|
||||
|
||||
from functools import partial
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import signals
|
||||
from django.dispatch import receiver
|
||||
|
||||
from . import models
|
||||
from .tasks.search import trigger_batch_document_indexer
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.Document)
|
||||
def document_post_save(sender, instance, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Asynchronous call to the document indexer at the end of the transaction.
|
||||
Note : Within the transaction we can have an empty content and a serialization
|
||||
error.
|
||||
"""
|
||||
transaction.on_commit(partial(trigger_batch_document_indexer, instance))
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.DocumentAccess)
|
||||
def document_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Asynchronous call to the document indexer at the end of the transaction.
|
||||
"""
|
||||
if not created:
|
||||
transaction.on_commit(
|
||||
partial(trigger_batch_document_indexer, instance.document)
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Trigger document indexation using celery task."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Q
|
||||
|
||||
from django_redis.cache import RedisCache
|
||||
|
||||
from core import models
|
||||
from core.services.search_indexers import (
|
||||
get_document_indexer,
|
||||
)
|
||||
|
||||
from impress.celery_app import app
|
||||
|
||||
logger = getLogger(__file__)
|
||||
|
||||
|
||||
@app.task
|
||||
def document_indexer_task(document_id):
|
||||
"""Celery Task : Sends indexation query for a document."""
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if indexer:
|
||||
logger.info("Start document %s indexation", document_id)
|
||||
indexer.index(models.Document.objects.filter(pk=document_id))
|
||||
|
||||
|
||||
def batch_indexer_throttle_acquire(timeout: int = 0, atomic: bool = True):
|
||||
"""
|
||||
Enable the task throttle flag for a delay.
|
||||
Uses redis locks if available to ensure atomic changes
|
||||
"""
|
||||
key = "document-batch-indexer-throttle"
|
||||
|
||||
# Redis is used as cache database (not in tests). Use the lock feature here
|
||||
# to ensure atomicity of changes to the throttle flag.
|
||||
if isinstance(cache, RedisCache) and atomic:
|
||||
with cache.locks(key):
|
||||
return batch_indexer_throttle_acquire(timeout, atomic=False)
|
||||
|
||||
# Use add() here :
|
||||
# - set the flag and returns true if not exist
|
||||
# - do nothing and return false if exist
|
||||
return cache.add(key, 1, timeout=timeout)
|
||||
|
||||
|
||||
@app.task
|
||||
def batch_document_indexer_task(timestamp):
|
||||
"""Celery Task : Sends indexation query for a batch of documents."""
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if indexer:
|
||||
queryset = models.Document.objects.filter(
|
||||
Q(updated_at__gte=timestamp)
|
||||
| Q(deleted_at__gte=timestamp)
|
||||
| Q(ancestors_deleted_at__gte=timestamp)
|
||||
)
|
||||
|
||||
count = indexer.index(queryset)
|
||||
logger.info("Indexed %d documents", count)
|
||||
|
||||
|
||||
def trigger_batch_document_indexer(item):
|
||||
"""
|
||||
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
|
||||
|
||||
Args:
|
||||
document (Document): The document instance.
|
||||
"""
|
||||
countdown = int(settings.SEARCH_INDEXER_COUNTDOWN)
|
||||
|
||||
# DO NOT create a task if indexation if disabled
|
||||
if not settings.SEARCH_INDEXER_CLASS:
|
||||
return
|
||||
|
||||
if countdown > 0:
|
||||
# Each time this method is called during a countdown, we increment the
|
||||
# counter and each task decrease it, so the index be run only once.
|
||||
if batch_indexer_throttle_acquire(timeout=countdown):
|
||||
logger.info(
|
||||
"Add task for batch document indexation from updated_at=%s in %d seconds",
|
||||
item.updated_at.isoformat(),
|
||||
countdown,
|
||||
)
|
||||
|
||||
batch_document_indexer_task.apply_async(
|
||||
args=[item.updated_at], countdown=countdown
|
||||
)
|
||||
else:
|
||||
logger.info("Skip task for batch document %s indexation", item.pk)
|
||||
else:
|
||||
document_indexer_task.apply(args=[item.pk])
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import random
|
||||
import re
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test.utils import override_settings
|
||||
@@ -12,7 +13,10 @@ from cryptography.fernet import Fernet
|
||||
from lasuite.oidc_login.backends import get_oidc_refresh_token
|
||||
|
||||
from core import models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.authentication.backends import (
|
||||
OIDCAuthenticationBackend,
|
||||
create_or_update_contact,
|
||||
)
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -509,3 +513,79 @@ def test_authentication_session_tokens(
|
||||
assert user is not None
|
||||
assert request.session["oidc_access_token"] == "test-access-token"
|
||||
assert get_oidc_refresh_token(request.session) == "test-refresh-token"
|
||||
|
||||
|
||||
def test_authentication_post_get_or_create_user_new_user_to_marketing_email(settings):
|
||||
"""
|
||||
New user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL enabled should create a contact
|
||||
in the marketing backend.
|
||||
"""
|
||||
|
||||
user = UserFactory()
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
with mock.patch.object(
|
||||
create_or_update_contact, "delay"
|
||||
) as mock_create_or_update_contact:
|
||||
klass.post_get_or_create_user(user, {}, True)
|
||||
mock_create_or_update_contact.assert_called_once_with(
|
||||
email=user.email, attributes={"DOCS_SOURCE": ["SIGNIN"]}
|
||||
)
|
||||
|
||||
|
||||
def test_authentication_post_get_or_create_user_new_user_to_marketing_email_disabled(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
New user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL disabled should not create a contact
|
||||
in the marketing backend.
|
||||
"""
|
||||
|
||||
user = UserFactory()
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
with mock.patch.object(
|
||||
create_or_update_contact, "delay"
|
||||
) as mock_create_or_update_contact:
|
||||
klass.post_get_or_create_user(user, {}, True)
|
||||
mock_create_or_update_contact.assert_not_called()
|
||||
|
||||
|
||||
def test_authentication_post_get_or_create_user_existing_user_to_marketing_email(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
Existing user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL enabled should not create a contact
|
||||
in the marketing backend.
|
||||
"""
|
||||
|
||||
user = UserFactory()
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
with mock.patch.object(
|
||||
create_or_update_contact, "delay"
|
||||
) as mock_create_or_update_contact:
|
||||
klass.post_get_or_create_user(user, {}, False)
|
||||
mock_create_or_update_contact.assert_not_called()
|
||||
|
||||
|
||||
def test_authentication_post_get_or_create_user_existing_user_to_marketing_email_disabled(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
Existing user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL disabled should not create a contact
|
||||
in the marketing backend.
|
||||
"""
|
||||
|
||||
user = UserFactory()
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
with mock.patch.object(
|
||||
create_or_update_contact, "delay"
|
||||
) as mock_create_or_update_contact:
|
||||
klass.post_get_or_create_user(user, {}, False)
|
||||
mock_create_or_update_contact.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Unit test for `index` command.
|
||||
"""
|
||||
|
||||
from operator import itemgetter
|
||||
from unittest import mock
|
||||
|
||||
from django.core.management import CommandError, call_command
|
||||
from django.db import transaction
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
from core.services.search_indexers import SearchIndexer
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_index():
|
||||
"""Test the command `index` that run the Find app indexer for all the available documents."""
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
empty_doc = factories.DocumentFactory(title=None, content="")
|
||||
no_title_doc = factories.DocumentFactory(title=None)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=empty_doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=no_title_doc, user=user)
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
str(empty_doc.path): {"users": [user.sub]},
|
||||
str(no_title_doc.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
call_command("index")
|
||||
|
||||
push_call_args = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
# called once but with a batch of docs
|
||||
mock_push.assert_called_once()
|
||||
|
||||
assert sorted(push_call_args[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc, accesses),
|
||||
indexer.serialize_document(no_title_doc, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_index_improperly_configured(indexer_settings):
|
||||
"""The command should raise an exception if the indexer is not configured"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
with pytest.raises(CommandError) as err:
|
||||
call_command("index")
|
||||
|
||||
assert str(err.value) == "The indexer is not enabled or properly configured."
|
||||
@@ -24,3 +24,30 @@ def mock_user_teams():
|
||||
"core.models.User.teams", new_callable=mock.PropertyMock
|
||||
) as mock_teams:
|
||||
yield mock_teams
|
||||
|
||||
|
||||
@pytest.fixture(name="indexer_settings")
|
||||
def indexer_settings_fixture(settings):
|
||||
"""
|
||||
Setup valid settings for the document indexer. Clear the indexer cache.
|
||||
"""
|
||||
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from core.services.search_indexers import ( # noqa: PLC0415
|
||||
get_document_indexer,
|
||||
)
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
|
||||
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.SearchIndexer"
|
||||
settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest"
|
||||
settings.SEARCH_INDEXER_URL = "http://localhost:8081/api/v1.0/documents/index/"
|
||||
settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://localhost:8081/api/v1.0/documents/search/"
|
||||
)
|
||||
settings.SEARCH_INDEXER_COUNTDOWN = 1
|
||||
|
||||
yield settings
|
||||
|
||||
# clear cache to prevent issues with other tests
|
||||
get_document_indexer.cache_clear()
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: all
|
||||
|
||||
The 'all' endpoint returns ALL documents (including descendants) that the user has access to.
|
||||
This is different from the 'list' endpoint which only returns top-level documents.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
def test_api_documents_all_anonymous(reach, role):
|
||||
"""
|
||||
Anonymous users should not be able to list any documents via the all endpoint
|
||||
whatever the link reach and link role.
|
||||
"""
|
||||
parent = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
factories.DocumentFactory(parent=parent, link_reach=reach, link_role=role)
|
||||
|
||||
response = APIClient().get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_with_children():
|
||||
"""
|
||||
Authenticated users should see all documents including children,
|
||||
even though children don't have DocumentAccess records.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Create a document tree: parent -> child -> grandchild
|
||||
parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=parent, user=user, role="owner")
|
||||
|
||||
child = factories.DocumentFactory(parent=parent)
|
||||
grandchild = factories.DocumentFactory(parent=child)
|
||||
|
||||
# Verify setup
|
||||
assert models.DocumentAccess.objects.filter(document=parent).count() == 1
|
||||
assert models.DocumentAccess.objects.filter(document=child).count() == 0
|
||||
assert models.DocumentAccess.objects.filter(document=grandchild).count() == 0
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# All three documents should be returned (parent + child + grandchild)
|
||||
assert len(results) == 3
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
|
||||
|
||||
depths = {result["depth"] for result in results}
|
||||
assert depths == {1, 2, 3}
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_multiple_trees():
|
||||
"""
|
||||
Users should see all accessible documents from multiple document trees.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Tree 1: User has access
|
||||
tree1_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=tree1_parent, user=user)
|
||||
tree1_child = factories.DocumentFactory(parent=tree1_parent)
|
||||
|
||||
# Tree 2: User has access
|
||||
tree2_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=tree2_parent, user=user)
|
||||
tree2_child1 = factories.DocumentFactory(parent=tree2_parent)
|
||||
tree2_child2 = factories.DocumentFactory(parent=tree2_parent)
|
||||
|
||||
# Tree 3: User does NOT have access
|
||||
tree3_parent = factories.DocumentFactory()
|
||||
factories.DocumentFactory(parent=tree3_parent)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Should return 5 documents (tree1: 2, tree2: 3, tree3: 0)
|
||||
assert len(results) == 5
|
||||
results_ids = {result["id"] for result in results}
|
||||
expected_ids = {
|
||||
str(tree1_parent.id),
|
||||
str(tree1_child.id),
|
||||
str(tree2_parent.id),
|
||||
str(tree2_child1.id),
|
||||
str(tree2_child2.id),
|
||||
}
|
||||
assert results_ids == expected_ids
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_explicit_access_to_parent_and_child():
|
||||
"""
|
||||
When a user has explicit DocumentAccess to both parent AND child,
|
||||
both should appear in the 'all' endpoint results (unlike 'list' which deduplicates).
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Parent with explicit access
|
||||
parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=parent, user=user)
|
||||
|
||||
# Child also has explicit access (e.g., shared separately)
|
||||
child = factories.DocumentFactory(parent=parent)
|
||||
factories.UserDocumentAccessFactory(document=child, user=user)
|
||||
|
||||
# Grandchild has no explicit access
|
||||
grandchild = factories.DocumentFactory(parent=child)
|
||||
|
||||
# Verify setup
|
||||
assert models.DocumentAccess.objects.filter(document=parent).count() == 1
|
||||
assert models.DocumentAccess.objects.filter(document=child).count() == 1
|
||||
assert models.DocumentAccess.objects.filter(document=grandchild).count() == 0
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# All three should appear
|
||||
assert len(results) == 3
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
|
||||
|
||||
# Each document should appear exactly once (no duplicates)
|
||||
results_ids_list = [result["id"] for result in results]
|
||||
assert len(results_ids_list) == len(set(results_ids_list)) # No duplicates
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_via_team(mock_user_teams):
|
||||
"""
|
||||
Users should see all documents (including descendants) for documents accessed via teams.
|
||||
"""
|
||||
mock_user_teams.return_value = ["team1", "team2"]
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Document tree via team1
|
||||
parent1 = factories.DocumentFactory()
|
||||
factories.TeamDocumentAccessFactory(document=parent1, team="team1")
|
||||
child1 = factories.DocumentFactory(parent=parent1)
|
||||
|
||||
# Document tree via team2
|
||||
parent2 = factories.DocumentFactory()
|
||||
factories.TeamDocumentAccessFactory(document=parent2, team="team2")
|
||||
child2 = factories.DocumentFactory(parent=parent2)
|
||||
|
||||
# Document tree via unknown team
|
||||
parent3 = factories.DocumentFactory()
|
||||
factories.TeamDocumentAccessFactory(document=parent3, team="team3")
|
||||
factories.DocumentFactory(parent=parent3)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Should return 4 documents (team1: 2, team2: 2, team3: 0)
|
||||
assert len(results) == 4
|
||||
results_ids = {result["id"] for result in results}
|
||||
expected_ids = {
|
||||
str(parent1.id),
|
||||
str(child1.id),
|
||||
str(parent2.id),
|
||||
str(child2.id),
|
||||
}
|
||||
assert results_ids == expected_ids
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_soft_deleted():
|
||||
"""
|
||||
Soft-deleted documents and their descendants should not be included.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Active tree
|
||||
active_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=active_parent, user=user)
|
||||
active_child = factories.DocumentFactory(parent=active_parent)
|
||||
|
||||
# Soft-deleted tree
|
||||
deleted_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=deleted_parent, user=user)
|
||||
_deleted_child = factories.DocumentFactory(parent=deleted_parent)
|
||||
deleted_parent.soft_delete()
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Should only return active documents
|
||||
assert len(results) == 2
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(active_parent.id), str(active_child.id)}
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_permanently_deleted():
|
||||
"""
|
||||
Permanently deleted documents should not be included.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Active tree
|
||||
active_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=active_parent, user=user)
|
||||
active_child = factories.DocumentFactory(parent=active_parent)
|
||||
|
||||
# Permanently deleted tree (deleted > 30 days ago)
|
||||
deleted_parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=deleted_parent, user=user)
|
||||
_deleted_child = factories.DocumentFactory(parent=deleted_parent)
|
||||
|
||||
fourty_days_ago = timezone.now() - timedelta(days=40)
|
||||
with mock.patch("django.utils.timezone.now", return_value=fourty_days_ago):
|
||||
deleted_parent.soft_delete()
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Should only return active documents
|
||||
assert len(results) == 2
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(active_parent.id), str(active_child.id)}
|
||||
|
||||
|
||||
def test_api_documents_all_authenticated_link_reach_restricted():
|
||||
"""
|
||||
Documents with link_reach=restricted accessed via LinkTrace should not appear
|
||||
in the all endpoint results.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Document with direct access (should appear)
|
||||
parent_with_access = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=parent_with_access, user=user)
|
||||
child_with_access = factories.DocumentFactory(parent=parent_with_access)
|
||||
|
||||
# Document with only LinkTrace and restricted reach (should NOT appear)
|
||||
parent_restricted = factories.DocumentFactory(
|
||||
link_reach="restricted", link_traces=[user]
|
||||
)
|
||||
factories.DocumentFactory(parent=parent_restricted)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Only documents with direct access should appear
|
||||
assert len(results) == 2
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(parent_with_access.id), str(child_with_access.id)}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_all_authenticated_link_reach_public_or_authenticated(reach):
|
||||
"""
|
||||
Documents with link_reach=public or authenticated accessed via LinkTrace
|
||||
should appear with all their descendants.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Document accessed via LinkTrace with non-restricted reach
|
||||
parent = factories.DocumentFactory(link_reach=reach, link_traces=[user])
|
||||
child = factories.DocumentFactory(parent=parent)
|
||||
grandchild = factories.DocumentFactory(parent=child)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# All descendants should be included
|
||||
assert len(results) == 3
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
|
||||
|
||||
|
||||
def test_api_documents_all_format():
|
||||
"""Validate the format of documents as returned by the all endpoint."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
|
||||
# Check pagination structure
|
||||
assert content == {
|
||||
"count": 2,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
|
||||
# Verify parent document format
|
||||
parent_result = [r for r in results if r["id"] == str(document.id)][0]
|
||||
assert parent_result == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"numchild": 1,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
# Verify child document format
|
||||
child_result = [r for r in results if r["id"] == str(child.id)][0]
|
||||
assert child_result["depth"] == 2
|
||||
assert child_result["user_role"] == access.role # Inherited from parent
|
||||
assert child_result["nb_accesses_direct"] == 0 # No direct access on child
|
||||
|
||||
|
||||
def test_api_documents_all_distinct():
|
||||
"""
|
||||
A document should only appear once even if the user has multiple access paths to it.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
other_user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Document with multiple accesses for the same user
|
||||
document = factories.DocumentFactory(users=[user, other_user])
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
|
||||
response = client.get("/api/v1.0/documents/all/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
|
||||
# Should return 2 documents (parent + child), each appearing once
|
||||
assert len(results) == 2
|
||||
results_ids = [result["id"] for result in results]
|
||||
assert results_ids.count(str(document.id)) == 1
|
||||
assert results_ids.count(str(child.id)) == 1
|
||||
|
||||
|
||||
def test_api_documents_all_comparison_with_list():
|
||||
"""
|
||||
The 'all' endpoint should return more documents than 'list' when there are children.
|
||||
'list' returns only top-level documents, 'all' returns all descendants.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Create a document tree
|
||||
parent = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=parent, user=user)
|
||||
child = factories.DocumentFactory(parent=parent)
|
||||
grandchild = factories.DocumentFactory(parent=child)
|
||||
|
||||
# Call list endpoint
|
||||
list_response = client.get("/api/v1.0/documents/")
|
||||
list_results = list_response.json()["results"]
|
||||
|
||||
# Call all endpoint
|
||||
all_response = client.get("/api/v1.0/documents/all/")
|
||||
all_results = all_response.json()["results"]
|
||||
|
||||
# list should return only parent
|
||||
assert len(list_results) == 1
|
||||
assert list_results[0]["id"] == str(parent.id)
|
||||
|
||||
# all should return parent + child + grandchild
|
||||
assert len(all_results) == 3
|
||||
all_ids = {result["id"] for result in all_results}
|
||||
assert all_ids == {str(parent.id), str(child.id), str(grandchild.id)}
|
||||
@@ -0,0 +1,425 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
import random
|
||||
from json import loads as json_loads
|
||||
|
||||
from django.test import RequestFactory
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from faker import Faker
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.services.search_indexers import get_document_indexer
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def build_search_url(**kwargs):
|
||||
"""Build absolute uri for search endpoint with ORDERED query arguments"""
|
||||
return (
|
||||
RequestFactory()
|
||||
.get("/api/v1.0/documents/search/", dict(sorted(kwargs.items())))
|
||||
.build_absolute_uri()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
@responses.activate
|
||||
def test_api_documents_search_anonymous(reach, role, indexer_settings):
|
||||
"""
|
||||
Anonymous users should not be allowed to search documents whatever the
|
||||
link reach and link role
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
# Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[],
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_endpoint_is_none(indexer_settings):
|
||||
"""
|
||||
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured.
|
||||
Should fallback on title filter
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(title="alpha")
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert content == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_invalid_params(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field is required."]}
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": " "})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field may not be blank."]}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "any", "page": "NaN"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"page": ["A valid integer is required."]}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_format(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
user_a, user_b, user_c = factories.UserFactory.create_batch(3)
|
||||
document = factories.DocumentFactory(
|
||||
title="alpha",
|
||||
users=(user_a, user_c),
|
||||
link_traces=(user, user_b),
|
||||
)
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
# Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[
|
||||
{"_id": str(document.pk)},
|
||||
],
|
||||
status=200,
|
||||
)
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert content == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 3,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"pagination, status, expected",
|
||||
(
|
||||
(
|
||||
{"page": 1, "page_size": 10},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"range": (0, None),
|
||||
},
|
||||
),
|
||||
(
|
||||
{},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"range": (0, None),
|
||||
"api_page_size": 21, # default page_size is 20
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 10},
|
||||
404,
|
||||
{},
|
||||
),
|
||||
(
|
||||
{"page": 1, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": {"page": 2, "page_size": 5},
|
||||
"range": (0, 5),
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": {"page_size": 5},
|
||||
"next": None,
|
||||
"range": (5, None),
|
||||
},
|
||||
),
|
||||
({"page": 3, "page_size": 5}, 404, {}),
|
||||
),
|
||||
)
|
||||
def test_api_documents_search_pagination(
|
||||
indexer_settings, pagination, status, expected
|
||||
):
|
||||
"""Documents should be ordered by descending "score" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
docs = factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
|
||||
|
||||
docs_by_uuid = {str(doc.pk): doc for doc in docs}
|
||||
api_results = [{"_id": id} for id in docs_by_uuid.keys()]
|
||||
|
||||
# reorder randomly to simulate score ordering
|
||||
random.shuffle(api_results)
|
||||
|
||||
# Find response
|
||||
# pylint: disable-next=assignment-from-none
|
||||
api_search = responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=api_results,
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/",
|
||||
data={
|
||||
"q": "alpha",
|
||||
**pagination,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
|
||||
if response.status_code < 300:
|
||||
previous_url = (
|
||||
build_search_url(q="alpha", **expected["previous"])
|
||||
if expected["previous"]
|
||||
else None
|
||||
)
|
||||
next_url = (
|
||||
build_search_url(q="alpha", **expected["next"])
|
||||
if expected["next"]
|
||||
else None
|
||||
)
|
||||
start, end = expected["range"]
|
||||
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == expected["count"]
|
||||
assert content["previous"] == previous_url
|
||||
assert content["next"] == next_url
|
||||
|
||||
results = content.pop("results")
|
||||
|
||||
# The find api results ordering by score is kept
|
||||
assert [r["id"] for r in results] == [r["_id"] for r in api_results[start:end]]
|
||||
|
||||
# Check the query parameters.
|
||||
assert api_search.call_count == 1
|
||||
assert api_search.calls[0].response.status_code == 200
|
||||
assert json_loads(api_search.calls[0].request.body) == {
|
||||
"q": "alpha",
|
||||
"visited": [],
|
||||
"services": ["docs"],
|
||||
"nb_results": 50,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"pagination, status, expected",
|
||||
(
|
||||
(
|
||||
{"page": 1, "page_size": 10},
|
||||
200,
|
||||
{"count": 10, "previous": None, "next": None, "range": (0, None)},
|
||||
),
|
||||
(
|
||||
{},
|
||||
200,
|
||||
{"count": 10, "previous": None, "next": None, "range": (0, None)},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 10},
|
||||
404,
|
||||
{},
|
||||
),
|
||||
(
|
||||
{"page": 1, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": {"page": 2, "page_size": 5},
|
||||
"range": (0, 5),
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": {"page_size": 5},
|
||||
"next": None,
|
||||
"range": (5, None),
|
||||
},
|
||||
),
|
||||
({"page": 3, "page_size": 5}, 404, {}),
|
||||
),
|
||||
)
|
||||
def test_api_documents_search_pagination_endpoint_is_none(
|
||||
indexer_settings, pagination, status, expected
|
||||
):
|
||||
"""Documents should be ordered by descending "-updated_at" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/",
|
||||
data={
|
||||
"q": "alpha",
|
||||
**pagination,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
|
||||
if response.status_code < 300:
|
||||
previous_url = (
|
||||
build_search_url(q="alpha", **expected["previous"])
|
||||
if expected["previous"]
|
||||
else None
|
||||
)
|
||||
next_url = (
|
||||
build_search_url(q="alpha", **expected["next"])
|
||||
if expected["next"]
|
||||
else None
|
||||
)
|
||||
queryset = models.Document.objects.order_by("-updated_at")
|
||||
start, end = expected["range"]
|
||||
expected_results = [str(d.pk) for d in queryset[start:end]]
|
||||
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == expected["count"]
|
||||
assert content["previous"] == previous_url
|
||||
assert content["next"] == next_url
|
||||
|
||||
results = content.pop("results")
|
||||
|
||||
assert [r["id"] for r in results] == expected_results
|
||||
@@ -24,6 +24,7 @@ pytestmark = pytest.mark.django_db
|
||||
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True,
|
||||
CRISP_WEBSITE_ID="123",
|
||||
FRONTEND_CSS_URL="http://testcss/",
|
||||
FRONTEND_JS_URL="http://testjs/",
|
||||
FRONTEND_THEME="test-theme",
|
||||
MEDIA_BASE_URL="http://testserver/",
|
||||
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
|
||||
@@ -49,6 +50,7 @@ def test_api_config(is_authenticated):
|
||||
"ENVIRONMENT": "test",
|
||||
"FRONTEND_CSS_URL": "http://testcss/",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
|
||||
"FRONTEND_JS_URL": "http://testjs/",
|
||||
"FRONTEND_THEME": "test-theme",
|
||||
"LANGUAGES": [
|
||||
["en-us", "English"],
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Test DocumentThrottle for regular throttling and y-provider bypass.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_throttling_document_throttle_regular_requests(settings):
|
||||
"""Test that regular requests are throttled normally."""
|
||||
|
||||
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "3/minute"
|
||||
settings.Y_PROVIDER_API_KEY = "test-y-provider-key"
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
# Make 3 requests without the y-provider key
|
||||
for _i in range(3):
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# 4th request should be throttled
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert response.status_code == 429
|
||||
|
||||
# A request with the y-provider key should NOT be throttled
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
HTTP_X_Y_PROVIDER_KEY="test-y-provider-key",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Restore original rate
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate
|
||||
|
||||
|
||||
def test_api_throttling_document_throttle_y_provider_exempted(settings):
|
||||
"""Test that y-provider requests are exempted from throttling."""
|
||||
|
||||
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "3/minute"
|
||||
settings.Y_PROVIDER_API_KEY = "test-y-provider-key"
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
# Make many requests with the y-provider API key
|
||||
for _i in range(10):
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
HTTP_X_Y_PROVIDER_KEY="test-y-provider-key",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Restore original rate
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate
|
||||
|
||||
|
||||
def test_api_throttling_document_throttle_invalid_token(settings):
|
||||
"""Test that requests with invalid tokens are throttled."""
|
||||
|
||||
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "3/minute"
|
||||
settings.Y_PROVIDER_API_KEY = "test-y-provider-key"
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
# Make 3 requests with an invalid token
|
||||
for _i in range(3):
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
HTTP_X_Y_PROVIDER_KEY="invalid-token",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# 4th request should be throttled
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
HTTP_X_Y_PROVIDER_KEY="invalid-token",
|
||||
)
|
||||
assert response.status_code == 429
|
||||
|
||||
# Restore original rate
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate
|
||||
@@ -76,6 +76,131 @@ def test_api_users_list_query_email():
|
||||
assert user_ids == []
|
||||
|
||||
|
||||
def test_api_users_list_query_email_with_internationalized_domain_names():
|
||||
"""
|
||||
Authenticated users should be able to list users and filter by email.
|
||||
It should work even if the email address contains an internationalized domain name.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
jean = factories.UserFactory(email="jean.martin@éducation.fr")
|
||||
marie = factories.UserFactory(email="marie.durand@education.fr")
|
||||
kurokawa = factories.UserFactory(email="contact@黒川.日本")
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=jean.martin@education.fr")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(jean.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=jean.martin@éducation.fr")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(jean.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=marie.durand@education.fr")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(marie.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=marie.durand@éducation.fr")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(marie.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=contact@黒川.日本")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(kurokawa.id)]
|
||||
|
||||
|
||||
def test_api_users_list_query_full_name():
|
||||
"""
|
||||
Authenticated users should be able to list users and filter by full name.
|
||||
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
dave = factories.UserFactory(email="contact@work.com", full_name="David Bowman")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=David",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=Bowman")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=bowman")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=BOWMAN")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=BoWmAn")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=Bovin")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == []
|
||||
|
||||
|
||||
def test_api_users_list_query_accented_full_name():
|
||||
"""
|
||||
Authenticated users should be able to list users and filter by full name with accents.
|
||||
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
fred = factories.UserFactory(
|
||||
email="contact@work.com", full_name="Frédérique Lefèvre"
|
||||
)
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=Frédérique")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(fred.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=Frederique")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(fred.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=Lefèvre")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(fred.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=Lefevre")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(fred.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=François Lorfebvre")
|
||||
assert response.status_code == 200
|
||||
users = [user["full_name"] for user in response.json()]
|
||||
assert users == []
|
||||
|
||||
|
||||
def test_api_users_list_limit(settings):
|
||||
"""
|
||||
Authenticated users should be able to list users and the number of results
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
"""
|
||||
Unit tests for the Document model
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
from operator import itemgetter
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.services.search_indexers import SearchIndexer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def reset_batch_indexer_throttle():
|
||||
"""Reset throttle flag"""
|
||||
cache.delete("document-batch-indexer-throttle")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_throttle():
|
||||
"""Reset throttle flag before each test"""
|
||||
reset_batch_indexer_throttle()
|
||||
yield
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer(mock_push):
|
||||
"""Test indexation task on document creation"""
|
||||
with transaction.atomic():
|
||||
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
accesses = {}
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
|
||||
assert len(data) == 1
|
||||
|
||||
# One call
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc1, accesses),
|
||||
indexer.serialize_document(doc2, accesses),
|
||||
indexer.serialize_document(doc3, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
# The throttle counters should be reset
|
||||
assert cache.get("document-batch-indexer-throttle") == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_no_batches(indexer_settings):
|
||||
"""Test indexation task on doculment creation, no throttle"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
accesses = {}
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
|
||||
# 3 calls
|
||||
assert len(data) == 3
|
||||
# one document per call
|
||||
assert [len(d) for d in data] == [1] * 3
|
||||
# all documents are indexed
|
||||
assert sorted([d[0] for d in data], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc1, accesses),
|
||||
indexer.serialize_document(doc2, accesses),
|
||||
indexer.serialize_document(doc3, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
# The throttle counters should be reset
|
||||
assert cache.get("file-batch-indexer-throttle") is None
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_settings):
|
||||
"""Task should not start an indexation when disabled"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_wrongly_configured(
|
||||
mock_push, indexer_settings
|
||||
):
|
||||
"""Task should not start an indexation when disabled"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_with_accesses(mock_push):
|
||||
"""Test indexation task on document creation"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc1, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc2, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc3, user=user)
|
||||
|
||||
accesses = {
|
||||
str(doc1.path): {"users": [user.sub]},
|
||||
str(doc2.path): {"users": [user.sub]},
|
||||
str(doc3.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
|
||||
assert len(data) == 1
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc1, accesses),
|
||||
indexer.serialize_document(doc2, accesses),
|
||||
indexer.serialize_document(doc3, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_deleted(mock_push):
|
||||
"""Indexation task on deleted or ancestor_deleted documents"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
main_doc = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
child_doc = factories.DocumentFactory(
|
||||
parent=main_doc,
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=main_doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=child_doc, user=user)
|
||||
|
||||
# Manually reset the throttle flag here or the next indexation will be ignored for 1 second
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with transaction.atomic():
|
||||
main_doc_deleted = models.Document.objects.get(pk=main_doc.pk)
|
||||
main_doc_deleted.soft_delete()
|
||||
|
||||
child_doc_deleted = models.Document.objects.get(pk=child_doc.pk)
|
||||
|
||||
main_doc_deleted.refresh_from_db()
|
||||
child_doc_deleted.refresh_from_db()
|
||||
|
||||
assert main_doc_deleted.deleted_at is not None
|
||||
assert child_doc_deleted.ancestors_deleted_at is not None
|
||||
|
||||
assert child_doc_deleted.deleted_at is None
|
||||
assert child_doc_deleted.ancestors_deleted_at is not None
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
str(main_doc_deleted.path): {"users": [user.sub]},
|
||||
str(child_doc_deleted.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
|
||||
assert len(data) == 2
|
||||
|
||||
# First indexation on document creation
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc, accesses),
|
||||
indexer.serialize_document(main_doc, accesses),
|
||||
indexer.serialize_document(child_doc, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
# Even deleted items are re-indexed : only update their status in the future
|
||||
assert sorted(data[1], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(main_doc_deleted, accesses), # soft_delete()
|
||||
indexer.serialize_document(child_doc_deleted, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_models_documents_indexer_hard_deleted():
|
||||
"""Indexation task on hard deleted document"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
# Call task on deleted document.
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
doc.delete()
|
||||
|
||||
# Hard delete document are not re-indexed.
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_restored(mock_push):
|
||||
"""Restart indexation task on restored documents"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
doc_deleted = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
doc_ancestor_deleted = factories.DocumentFactory(
|
||||
parent=doc_deleted,
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc_deleted, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc_ancestor_deleted, user=user)
|
||||
|
||||
doc_deleted.soft_delete()
|
||||
|
||||
doc_deleted.refresh_from_db()
|
||||
doc_ancestor_deleted.refresh_from_db()
|
||||
|
||||
assert doc_deleted.deleted_at is not None
|
||||
assert doc_deleted.ancestors_deleted_at is not None
|
||||
|
||||
assert doc_ancestor_deleted.deleted_at is None
|
||||
assert doc_ancestor_deleted.ancestors_deleted_at is not None
|
||||
|
||||
# Manually reset the throttle flag here or the next indexation will be ignored for 1 second
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with transaction.atomic():
|
||||
doc_restored = models.Document.objects.get(pk=doc_deleted.pk)
|
||||
doc_restored.restore()
|
||||
|
||||
doc_ancestor_restored = models.Document.objects.get(pk=doc_ancestor_deleted.pk)
|
||||
|
||||
assert doc_restored.deleted_at is None
|
||||
assert doc_restored.ancestors_deleted_at is None
|
||||
|
||||
assert doc_ancestor_restored.deleted_at is None
|
||||
assert doc_ancestor_restored.ancestors_deleted_at is None
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
str(doc_deleted.path): {"users": [user.sub]},
|
||||
str(doc_ancestor_deleted.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
|
||||
# All docs are re-indexed
|
||||
assert len(data) == 2
|
||||
|
||||
# First indexation on items creation & soft delete (in the same transaction)
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc, accesses),
|
||||
indexer.serialize_document(doc_deleted, accesses),
|
||||
indexer.serialize_document(doc_ancestor_deleted, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
# Restored items are re-indexed : only update their status in the future
|
||||
assert sorted(data[1], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc_restored, accesses), # restore()
|
||||
indexer.serialize_document(doc_ancestor_restored, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_models_documents_post_save_indexer_throttle():
|
||||
"""Test indexation task skipping on document update"""
|
||||
indexer = SearchIndexer()
|
||||
user = factories.UserFactory()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
docs = factories.DocumentFactory.create_batch(5, users=(user,))
|
||||
|
||||
accesses = {str(item.path): {"users": [user.sub]} for item in docs}
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
# Simulate 1 running task
|
||||
cache.set("document-batch-indexer-throttle", 1)
|
||||
|
||||
# save doc to trigger the indexer, but nothing should be done since
|
||||
# the flag is up
|
||||
with transaction.atomic():
|
||||
docs[0].save()
|
||||
docs[2].save()
|
||||
docs[3].save()
|
||||
|
||||
assert [call.args[0] for call in mock_push.call_args_list] == []
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
# No waiting task
|
||||
cache.delete("document-batch-indexer-throttle")
|
||||
|
||||
with transaction.atomic():
|
||||
docs[0].save()
|
||||
docs[2].save()
|
||||
docs[3].save()
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
# One call
|
||||
assert len(data) == 1
|
||||
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(docs[0], accesses),
|
||||
indexer.serialize_document(docs[2], accesses),
|
||||
indexer.serialize_document(docs[3], accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_models_documents_access_post_save_indexer():
|
||||
"""Test indexation task on DocumentAccess update"""
|
||||
users = factories.UserFactory.create_batch(3)
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(users=users)
|
||||
doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by(
|
||||
"user__sub"
|
||||
)
|
||||
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
for doc_access in doc_accesses:
|
||||
doc_access.save()
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
# One call
|
||||
assert len(data) == 1
|
||||
|
||||
assert [d["id"] for d in data[0]] == [str(doc.pk)]
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
|
||||
"""Test indexation task on ItemAccess update, no throttle"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
users = factories.UserFactory.create_batch(3)
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(users=users)
|
||||
doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by(
|
||||
"user__sub"
|
||||
)
|
||||
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
for doc_access in doc_accesses:
|
||||
doc_access.save()
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
# 3 calls
|
||||
assert len(data) == 3
|
||||
# one document per call
|
||||
assert [len(d) for d in data] == [1] * 3
|
||||
# the same document is indexed 3 times
|
||||
assert [d[0]["id"] for d in data] == [str(doc.pk)] * 3
|
||||
@@ -0,0 +1,635 @@
|
||||
"""Tests for Documents search indexers"""
|
||||
|
||||
from functools import partial
|
||||
from json import dumps as json_dumps
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from requests import HTTPError
|
||||
|
||||
from core import factories, models, utils
|
||||
from core.services.search_indexers import (
|
||||
BaseDocumentIndexer,
|
||||
SearchIndexer,
|
||||
get_document_indexer,
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
class FakeDocumentIndexer(BaseDocumentIndexer):
|
||||
"""Fake indexer for test purpose"""
|
||||
|
||||
def serialize_document(self, document, accesses):
|
||||
return {}
|
||||
|
||||
def push(self, data):
|
||||
pass
|
||||
|
||||
def search_query(self, data, token):
|
||||
return {}
|
||||
|
||||
|
||||
def test_services_search_indexer_class_invalid(indexer_settings):
|
||||
"""
|
||||
Should raise RuntimeError if SEARCH_INDEXER_CLASS cannot be imported.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = "unknown.Unknown"
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
|
||||
def test_services_search_indexer_class(indexer_settings):
|
||||
"""
|
||||
Import indexer class defined in setting SEARCH_INDEXER_CLASS.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = (
|
||||
"core.tests.test_services_search_indexers.FakeDocumentIndexer"
|
||||
)
|
||||
|
||||
assert isinstance(
|
||||
get_document_indexer(),
|
||||
import_string("core.tests.test_services_search_indexers.FakeDocumentIndexer"),
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_indexer_is_configured(indexer_settings):
|
||||
"""
|
||||
Should return true only when the indexer class and other configuration settings
|
||||
are valid.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
# None
|
||||
get_document_indexer.cache_clear()
|
||||
assert not get_document_indexer()
|
||||
|
||||
# Empty
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = ""
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
assert not get_document_indexer()
|
||||
|
||||
# Valid class
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = (
|
||||
"core.services.search_indexers.SearchIndexer"
|
||||
)
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_URL = ""
|
||||
|
||||
# Invalid url
|
||||
get_document_indexer.cache_clear()
|
||||
assert not get_document_indexer()
|
||||
|
||||
|
||||
def test_services_search_indexer_url_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is None or empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_indexer_url_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is empty string.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_indexer_secret_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_SECRET is None.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_SECRET = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_indexer_secret_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_SECRET is empty string.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_SECRET = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_endpoint_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is None.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_endpoint_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_serialize_document_returns_expected_json():
|
||||
"""
|
||||
It should serialize documents with correct metadata and access control.
|
||||
"""
|
||||
user_a, user_b = factories.UserFactory.create_batch(2)
|
||||
document = factories.DocumentFactory()
|
||||
factories.DocumentFactory(parent=document)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=document, user=user_a)
|
||||
factories.UserDocumentAccessFactory(document=document, user=user_b)
|
||||
factories.TeamDocumentAccessFactory(document=document, team="team1")
|
||||
factories.TeamDocumentAccessFactory(document=document, team="team2")
|
||||
|
||||
accesses = {
|
||||
document.path: {
|
||||
"users": {str(user_a.sub), str(user_b.sub)},
|
||||
"teams": {"team1", "team2"},
|
||||
}
|
||||
}
|
||||
|
||||
indexer = SearchIndexer()
|
||||
result = indexer.serialize_document(document, accesses)
|
||||
|
||||
assert set(result.pop("users")) == {str(user_a.sub), str(user_b.sub)}
|
||||
assert set(result.pop("groups")) == {"team1", "team2"}
|
||||
assert result == {
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"depth": 1,
|
||||
"path": document.path,
|
||||
"numchild": 1,
|
||||
"content": utils.base64_yjs_to_text(document.content),
|
||||
"created_at": document.created_at.isoformat(),
|
||||
"updated_at": document.updated_at.isoformat(),
|
||||
"reach": document.link_reach,
|
||||
"size": 13,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_serialize_document_deleted():
|
||||
"""Deleted documents are marked as just in the serialized json."""
|
||||
parent = factories.DocumentFactory()
|
||||
document = factories.DocumentFactory(parent=parent)
|
||||
|
||||
parent.soft_delete()
|
||||
document.refresh_from_db()
|
||||
|
||||
indexer = SearchIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["is_active"] is False
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_serialize_document_empty():
|
||||
"""Empty documents returns empty content in the serialized json."""
|
||||
document = factories.DocumentFactory(content="", title=None)
|
||||
|
||||
indexer = SearchIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["content"] == ""
|
||||
assert result["title"] == ""
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_services_search_indexers_index_errors(indexer_settings):
|
||||
"""
|
||||
Documents indexing response handling on Find API HTTP errors.
|
||||
"""
|
||||
factories.DocumentFactory()
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_URL = "http://app-find/api/v1.0/documents/index/"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://app-find/api/v1.0/documents/index/",
|
||||
status=401,
|
||||
body=json_dumps({"message": "Authentication failed."}),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
SearchIndexer().index()
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
mock_push, indexer_settings
|
||||
):
|
||||
"""
|
||||
Documents indexing should be processed in batches,
|
||||
and only the access data relevant to each batch should be used.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_BATCH_SIZE = 2
|
||||
documents = factories.DocumentFactory.create_batch(5)
|
||||
|
||||
# Attach a single user access to each document
|
||||
expected_user_subs = {}
|
||||
for document in documents:
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
expected_user_subs[str(document.id)] = str(access.user.sub)
|
||||
|
||||
assert SearchIndexer().index() == 5
|
||||
|
||||
# Should be 3 batches: 2 + 2 + 1
|
||||
assert mock_push.call_count == 3
|
||||
|
||||
seen_doc_ids = set()
|
||||
|
||||
for call in mock_push.call_args_list:
|
||||
batch = call.args[0]
|
||||
assert isinstance(batch, list)
|
||||
|
||||
for doc_json in batch:
|
||||
doc_id = doc_json["id"]
|
||||
seen_doc_ids.add(doc_id)
|
||||
|
||||
# Only one user expected per document
|
||||
assert doc_json["users"] == [expected_user_subs[doc_id]]
|
||||
assert doc_json["groups"] == []
|
||||
|
||||
# Make sure all 5 documents were indexed
|
||||
assert seen_doc_ids == {str(d.id) for d in documents}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_batch_size_argument(mock_push):
|
||||
"""
|
||||
Documents indexing should be processed in batches,
|
||||
batch_size overrides SEARCH_INDEXER_BATCH_SIZE
|
||||
"""
|
||||
documents = factories.DocumentFactory.create_batch(5)
|
||||
|
||||
# Attach a single user access to each document
|
||||
expected_user_subs = {}
|
||||
for document in documents:
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
expected_user_subs[str(document.id)] = str(access.user.sub)
|
||||
|
||||
assert SearchIndexer().index(batch_size=2) == 5
|
||||
|
||||
# Should be 3 batches: 2 + 2 + 1
|
||||
assert mock_push.call_count == 3
|
||||
|
||||
seen_doc_ids = set()
|
||||
|
||||
for call in mock_push.call_args_list:
|
||||
batch = call.args[0]
|
||||
assert isinstance(batch, list)
|
||||
|
||||
for doc_json in batch:
|
||||
doc_id = doc_json["id"]
|
||||
seen_doc_ids.add(doc_id)
|
||||
|
||||
# Only one user expected per document
|
||||
assert doc_json["users"] == [expected_user_subs[doc_id]]
|
||||
assert doc_json["groups"] == []
|
||||
|
||||
# Make sure all 5 documents were indexed
|
||||
assert seen_doc_ids == {str(d.id) for d in documents}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ignore_empty_documents(mock_push):
|
||||
"""
|
||||
Documents indexing should be processed in batches,
|
||||
and only the access data relevant to each batch should be used.
|
||||
"""
|
||||
document = factories.DocumentFactory()
|
||||
factories.DocumentFactory(content="", title="")
|
||||
empty_title = factories.DocumentFactory(title="")
|
||||
empty_content = factories.DocumentFactory(content="")
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
|
||||
assert mock_push.call_count == 1
|
||||
|
||||
# Make sure only not eempty documents are indexed
|
||||
results = {doc["id"] for doc in mock_push.call_args[0][0]}
|
||||
assert results == {
|
||||
str(d.id)
|
||||
for d in (
|
||||
document,
|
||||
empty_content,
|
||||
empty_title,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings):
|
||||
"""
|
||||
Documents indexing batch can be empty if all the docs are empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_BATCH_SIZE = 2
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
# Only empty docs
|
||||
factories.DocumentFactory.create_batch(5, content="", title="")
|
||||
|
||||
assert SearchIndexer().index() == 1
|
||||
assert mock_push.call_count == 1
|
||||
|
||||
results = [doc["id"] for doc in mock_push.call_args[0][0]]
|
||||
assert results == [str(document.id)]
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_link_reach(mock_push):
|
||||
"""Document accesses and reach should take into account ancestors link reaches."""
|
||||
great_grand_parent = factories.DocumentFactory(link_reach="restricted")
|
||||
grand_parent = factories.DocumentFactory(
|
||||
parent=great_grand_parent, link_reach="authenticated"
|
||||
)
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="public")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
|
||||
assert SearchIndexer().index() == 4
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 4
|
||||
assert results[str(great_grand_parent.id)]["reach"] == "restricted"
|
||||
assert results[str(grand_parent.id)]["reach"] == "authenticated"
|
||||
assert results[str(parent.id)]["reach"] == "public"
|
||||
assert results[str(document.id)]["reach"] == "public"
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_users(mock_push):
|
||||
"""Document accesses and reach should include users from ancestors."""
|
||||
user_gp, user_p, user_d = factories.UserFactory.create_batch(3)
|
||||
|
||||
grand_parent = factories.DocumentFactory(users=[user_gp])
|
||||
parent = factories.DocumentFactory(parent=grand_parent, users=[user_p])
|
||||
document = factories.DocumentFactory(parent=parent, users=[user_d])
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
assert results[str(grand_parent.id)]["users"] == [str(user_gp.sub)]
|
||||
assert set(results[str(parent.id)]["users"]) == {str(user_gp.sub), str(user_p.sub)}
|
||||
assert set(results[str(document.id)]["users"]) == {
|
||||
str(user_gp.sub),
|
||||
str(user_p.sub),
|
||||
str(user_d.sub),
|
||||
}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_teams(mock_push):
|
||||
"""Document accesses and reach should include teams from ancestors."""
|
||||
grand_parent = factories.DocumentFactory(teams=["team_gp"])
|
||||
parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"])
|
||||
document = factories.DocumentFactory(parent=parent, teams=["team_d"])
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
assert results[str(grand_parent.id)]["groups"] == ["team_gp"]
|
||||
assert set(results[str(parent.id)]["groups"]) == {"team_gp", "team_p"}
|
||||
assert set(results[str(document.id)]["groups"]) == {"team_gp", "team_p", "team_d"}
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
|
||||
"""
|
||||
push() should call requests.post with the correct URL from settings
|
||||
the timeout set to 10 seconds and the data as JSON.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = "http://example.com/index"
|
||||
|
||||
indexer = SearchIndexer()
|
||||
sample_data = [{"id": "123", "title": "Test"}]
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
|
||||
indexer.push(sample_data)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_URL
|
||||
assert kwargs.get("json") == sample_data
|
||||
assert kwargs.get("timeout") == 10
|
||||
|
||||
|
||||
def test_get_visited_document_ids_of():
|
||||
"""
|
||||
get_visited_document_ids_of() returns the ids of the documents viewed
|
||||
by the user BUT without specific access configuration (like public ones)
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
other = factories.UserFactory()
|
||||
anonymous = AnonymousUser()
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
assert not get_visited_document_ids_of(queryset, anonymous)
|
||||
assert not get_visited_document_ids_of(queryset, user)
|
||||
|
||||
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
|
||||
|
||||
create_link(document=doc1)
|
||||
create_link(document=doc2)
|
||||
|
||||
# The third document is not visited
|
||||
assert sorted(get_visited_document_ids_of(queryset, user)) == sorted(
|
||||
[str(doc1.pk), str(doc2.pk)]
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(user=other, document=doc1)
|
||||
factories.UserDocumentAccessFactory(user=user, document=doc2)
|
||||
|
||||
# The second document have an access for the user
|
||||
assert get_visited_document_ids_of(queryset, user) == [str(doc1.pk)]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_get_visited_document_ids_of_deleted():
|
||||
"""
|
||||
get_visited_document_ids_of() returns the ids of the documents viewed
|
||||
by the user if they are not deleted.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
anonymous = AnonymousUser()
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
assert not get_visited_document_ids_of(queryset, anonymous)
|
||||
assert not get_visited_document_ids_of(queryset, user)
|
||||
|
||||
doc = factories.DocumentFactory()
|
||||
doc_deleted = factories.DocumentFactory()
|
||||
doc_ancestor_deleted = factories.DocumentFactory(parent=doc_deleted)
|
||||
|
||||
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
|
||||
|
||||
create_link(document=doc)
|
||||
create_link(document=doc_deleted)
|
||||
create_link(document=doc_ancestor_deleted)
|
||||
|
||||
# The all documents are visited
|
||||
assert sorted(get_visited_document_ids_of(queryset, user)) == sorted(
|
||||
[str(doc.pk), str(doc_deleted.pk), str(doc_ancestor_deleted.pk)]
|
||||
)
|
||||
|
||||
doc_deleted.soft_delete()
|
||||
|
||||
# Only the first document is not deleted
|
||||
assert get_visited_document_ids_of(queryset, user) == [str(doc.pk)]
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_services_search_indexers_search_errors(indexer_settings):
|
||||
"""
|
||||
Documents indexing response handling on Find API HTTP errors.
|
||||
"""
|
||||
factories.DocumentFactory()
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://app-find/api/v1.0/documents/search/"
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://app-find/api/v1.0/documents/search/",
|
||||
status=401,
|
||||
body=json_dumps({"message": "Authentication failed."}),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
SearchIndexer().search("alpha", token="mytoken")
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_services_search_indexers_search(mock_post, indexer_settings):
|
||||
"""
|
||||
search() should call requests.post to SEARCH_INDEXER_QUERY_URL with the
|
||||
document ids from linktraces.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
|
||||
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
|
||||
|
||||
create_link(document=doc1)
|
||||
create_link(document=doc2)
|
||||
|
||||
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
|
||||
|
||||
indexer.search("alpha", visited=visited, token="mytoken")
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
|
||||
query_data = kwargs.get("json")
|
||||
assert query_data["q"] == "alpha"
|
||||
assert sorted(query_data["visited"]) == sorted([str(doc1.pk), str(doc2.pk)])
|
||||
assert query_data["services"] == ["docs"]
|
||||
assert query_data["nb_results"] == 50
|
||||
assert query_data["order_by"] == "updated_at"
|
||||
assert query_data["order_direction"] == "desc"
|
||||
|
||||
assert kwargs.get("headers") == {"Authorization": "Bearer mytoken"}
|
||||
assert kwargs.get("timeout") == 10
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_services_search_indexers_search_nb_results(mock_post, indexer_settings):
|
||||
"""
|
||||
Find API call should have nb_results == SEARCH_INDEXER_QUERY_LIMIT
|
||||
or the given nb_results argument.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_LIMIT = 25
|
||||
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
|
||||
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
|
||||
|
||||
create_link(document=doc1)
|
||||
create_link(document=doc2)
|
||||
|
||||
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
|
||||
|
||||
indexer.search("alpha", visited=visited, token="mytoken")
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
assert kwargs.get("json")["nb_results"] == 25
|
||||
|
||||
# The argument overrides the setting value
|
||||
indexer.search("alpha", visited=visited, token="mytoken", nb_results=109)
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
assert kwargs.get("json")["nb_results"] == 109
|
||||
@@ -75,3 +75,28 @@ def test_utils_extract_attachments():
|
||||
base64_string = base64.b64encode(update).decode("utf-8")
|
||||
# image_key2 is missing the "/media/" part and shouldn't get extracted
|
||||
assert utils.extract_attachments(base64_string) == [image_key1, image_key3]
|
||||
|
||||
|
||||
def test_utils_get_ancestor_to_descendants_map_single_path():
|
||||
"""Test ancestor mapping of a single path."""
|
||||
paths = ["000100020005"]
|
||||
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
|
||||
|
||||
assert result == {
|
||||
"0001": {"000100020005"},
|
||||
"00010002": {"000100020005"},
|
||||
"000100020005": {"000100020005"},
|
||||
}
|
||||
|
||||
|
||||
def test_utils_get_ancestor_to_descendants_map_multiple_paths():
|
||||
"""Test ancestor mapping of multiple paths with shared prefixes."""
|
||||
paths = ["000100020005", "00010003"]
|
||||
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
|
||||
|
||||
assert result == {
|
||||
"0001": {"000100020005", "00010003"},
|
||||
"00010002": {"000100020005"},
|
||||
"000100020005": {"000100020005"},
|
||||
"00010003": {"00010003"},
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import base64
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
import pycrdt
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -9,6 +10,27 @@ from bs4 import BeautifulSoup
|
||||
from core import enums
|
||||
|
||||
|
||||
def get_ancestor_to_descendants_map(paths, steplen):
|
||||
"""
|
||||
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.
|
||||
|
||||
Each path is assumed to use materialized path format with fixed-length segments.
|
||||
|
||||
Args:
|
||||
paths (list of str): List of full document paths.
|
||||
steplen (int): Length of each path segment.
|
||||
|
||||
Returns:
|
||||
dict[str, set[str]]: Mapping from ancestor path to its descendant paths (including itself).
|
||||
"""
|
||||
ancestor_map = defaultdict(set)
|
||||
for path in paths:
|
||||
for i in range(steplen, len(path) + 1, steplen):
|
||||
ancestor = path[:i]
|
||||
ancestor_map[ancestor].add(path)
|
||||
return ancestor_map
|
||||
|
||||
|
||||
def filter_descendants(paths, root_paths, skip_sorting=False):
|
||||
"""
|
||||
Filters paths to keep only those that are descendants of any path in root_paths.
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
# ruff: noqa: S311, S106
|
||||
"""create_demo management command"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from uuid import uuid4
|
||||
|
||||
from django import db
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
import pycrdt
|
||||
from faker import Faker
|
||||
|
||||
from core import models
|
||||
|
||||
from demo import defaults
|
||||
|
||||
fake = Faker()
|
||||
languages = [x for (x, y) in settings.LANGUAGES]
|
||||
fake = Faker(languages)
|
||||
|
||||
logger = logging.getLogger("impress.commands.demo.create_demo")
|
||||
|
||||
@@ -27,6 +31,16 @@ def random_true_with_probability(probability):
|
||||
return random.random() < probability
|
||||
|
||||
|
||||
def get_ydoc_for_text(text):
|
||||
"""Return a ydoc from plain text for demo purposes."""
|
||||
ydoc = pycrdt.Doc()
|
||||
paragraph = pycrdt.XmlElement("p", {}, [pycrdt.XmlText(text)])
|
||||
fragment = pycrdt.XmlFragment([paragraph])
|
||||
ydoc["document-store"] = fragment
|
||||
update = ydoc.get_update()
|
||||
return base64.b64encode(update).decode("utf-8")
|
||||
|
||||
|
||||
class BulkQueue:
|
||||
"""A utility class to create Django model instances in bulk by just pushing to a queue."""
|
||||
|
||||
@@ -48,7 +62,7 @@ class BulkQueue:
|
||||
self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001
|
||||
|
||||
def push(self, obj):
|
||||
"""Add a model instance to queue to that it gets created in bulk."""
|
||||
"""Add a model instance to queue so that it gets created in bulk."""
|
||||
objects = self.queue[obj._meta.model.__name__] # noqa: SLF001
|
||||
objects.append(obj)
|
||||
if len(objects) > self.BATCH_SIZE:
|
||||
@@ -127,7 +141,7 @@ def create_demo(stdout):
|
||||
is_staff=False,
|
||||
short_name=first_name,
|
||||
full_name=f"{first_name:s} {random.choice(last_names):s}",
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
language=random.choice(languages),
|
||||
)
|
||||
)
|
||||
queue.flush()
|
||||
@@ -139,17 +153,19 @@ def create_demo(stdout):
|
||||
# pylint: disable=protected-access
|
||||
key = models.Document._int2str(i) # noqa: SLF001
|
||||
padding = models.Document.alphabet[0] * (models.Document.steplen - len(key))
|
||||
queue.push(
|
||||
models.Document(
|
||||
depth=1,
|
||||
path=f"{padding}{key}",
|
||||
creator_id=random.choice(users_ids),
|
||||
title=fake.sentence(nb_words=4),
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
if random_true_with_probability(0.5)
|
||||
else random.choice(models.LinkReachChoices.values),
|
||||
)
|
||||
title = fake.sentence(nb_words=4)
|
||||
document = models.Document(
|
||||
id=uuid4(),
|
||||
depth=1,
|
||||
path=f"{padding}{key}",
|
||||
creator_id=random.choice(users_ids),
|
||||
title=title,
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
if random_true_with_probability(0.5)
|
||||
else random.choice(models.LinkReachChoices.values),
|
||||
)
|
||||
document.save_content(get_ydoc_for_text(f"Content for {title:s}"))
|
||||
queue.push(document)
|
||||
|
||||
queue.flush()
|
||||
|
||||
@@ -179,8 +195,7 @@ def create_demo(stdout):
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
language=dev_user["language"]
|
||||
or random.choice(settings.LANGUAGES)[0],
|
||||
language=dev_user["language"] or random.choice(languages),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -99,6 +99,31 @@ class Base(Configuration):
|
||||
}
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
# Search
|
||||
SEARCH_INDEXER_CLASS = values.Value(
|
||||
default=None,
|
||||
environ_name="SEARCH_INDEXER_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
SEARCH_INDEXER_BATCH_SIZE = values.IntegerValue(
|
||||
default=100_000, environ_name="SEARCH_INDEXER_BATCH_SIZE", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_URL", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_COUNTDOWN = values.IntegerValue(
|
||||
default=1, environ_name="SEARCH_INDEXER_COUNTDOWN", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_SECRET = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_SECRET", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_QUERY_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_QUERY_URL", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_QUERY_LIMIT = values.PositiveIntegerValue(
|
||||
default=50, environ_name="SEARCH_INDEXER_QUERY_LIMIT", environ_prefix=None
|
||||
)
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(DATA_DIR, "static")
|
||||
@@ -328,6 +353,7 @@ class Base(Configuration):
|
||||
# OIDC third party
|
||||
"mozilla_django_oidc",
|
||||
"lasuite.malware_detection",
|
||||
"lasuite.marketing",
|
||||
"csp",
|
||||
]
|
||||
|
||||
@@ -483,6 +509,9 @@ class Base(Configuration):
|
||||
FRONTEND_CSS_URL = values.Value(
|
||||
None, environ_name="FRONTEND_CSS_URL", environ_prefix=None
|
||||
)
|
||||
FRONTEND_JS_URL = values.Value(
|
||||
None, environ_name="FRONTEND_JS_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
THEME_CUSTOMIZATION_FILE_PATH = values.Value(
|
||||
os.path.join(BASE_DIR, "impress/configuration/theme/default.json"),
|
||||
@@ -811,6 +840,30 @@ class Base(Configuration):
|
||||
),
|
||||
}
|
||||
|
||||
# Marketing and communication settings
|
||||
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
|
||||
False,
|
||||
environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL",
|
||||
environ_prefix=None,
|
||||
help_text=(
|
||||
"When enabled, new users are automatically added to mailing list "
|
||||
"for product updates, marketing communications, and customized emails. "
|
||||
),
|
||||
)
|
||||
|
||||
LASUITE_MARKETING = {
|
||||
"BACKEND": values.Value(
|
||||
"lasuite.marketing.backends.dummy.DummyBackend",
|
||||
environ_name="LASUITE_MARKETING_BACKEND",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"PARAMETERS": values.DictValue(
|
||||
default={},
|
||||
environ_name="LASUITE_MARKETING_PARAMETERS",
|
||||
environ_prefix=None,
|
||||
),
|
||||
}
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Breton\n"
|
||||
"Language: br_FR\n"
|
||||
@@ -79,11 +79,15 @@ msgstr "Doare korf"
|
||||
msgid "Format"
|
||||
msgstr "Stumm"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "eilenn {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,8 +243,8 @@ msgstr "implijer"
|
||||
msgid "users"
|
||||
msgstr "implijerien"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
|
||||
#: core/models.py:361 core/models.py:1434
|
||||
msgid "title"
|
||||
msgstr "titl"
|
||||
|
||||
@@ -256,188 +260,188 @@ msgstr "Restr"
|
||||
msgid "Documents"
|
||||
msgstr "Restroù"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
|
||||
#: core/models.py:827
|
||||
msgid "Untitled Document"
|
||||
msgstr "Restr hep titl"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} en deus rannet ur restr ganeoc'h!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:866 core/models.py:866
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} en deus pedet ac'hanoc'h gant ar rol \"{role}\" war ar restr da-heul:"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:872 core/models.py:872
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} en deus rannet ur restr ganeoc'h: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:973 core/models.py:973
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Roud liamm ar restr/an implijer"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Roudoù liamm ar restr/an implijer"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:980 core/models.py:980
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Ur roud liamm a zo dija evit an restr/an implijer."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1003 core/models.py:1003
|
||||
msgid "Document favorite"
|
||||
msgstr "Restr muiañ-karet"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
msgid "Document favorites"
|
||||
msgstr "Restroù muiañ-karet"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Ar restr-mañ a zo ur restr muiañ karet gant an implijer-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1032 core/models.py:1032
|
||||
msgid "Document/user relation"
|
||||
msgstr "Liamm restr/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
msgid "Document/user relations"
|
||||
msgstr "Liammoù restr/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
msgid "This user is already in this document."
|
||||
msgstr "An implijer-mañ a zo dija er restr-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1045 core/models.py:1045
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
|
||||
#: core/models.py:1051 core/models.py:1520
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "An implijer pe ar skipailh a rank bezañ termenet, ket an daou avat."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1202 core/models.py:1202
|
||||
msgid "Document ask for access"
|
||||
msgstr "Goulenn tizhout ar restr"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1203 core/models.py:1203
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Goulennoù tizhout ar restr"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1209 core/models.py:1209
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "An implijer en deus goulennet tizhout ar restr-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} en defe c'hoant da dizhout ar restr-mañ!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1270 core/models.py:1270
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} en defe c'hoant da dizhout ar restr da-heul:"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1276 core/models.py:1276
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} en defe c'hoant da dizhout ar restr: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1318 core/models.py:1318
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1319 core/models.py:1319
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
|
||||
#: core/models.py:1322 core/models.py:1374
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1369 core/models.py:1369
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1370 core/models.py:1370
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1423 core/models.py:1423
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1424 core/models.py:1424
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "description"
|
||||
msgstr "deskrivadur"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "code"
|
||||
msgstr "kod"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "public"
|
||||
msgstr "publik"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
#: build/lib/core/models.py:1441 core/models.py:1441
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "M'eo foran ar patrom-mañ hag implijus gant n'eus forzh piv."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
#: build/lib/core/models.py:1447 core/models.py:1447
|
||||
msgid "Template"
|
||||
msgstr "Patrom"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
#: build/lib/core/models.py:1448 core/models.py:1448
|
||||
msgid "Templates"
|
||||
msgstr "Patromoù"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "Template/user relation"
|
||||
msgstr "Liamm patrom/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
#: build/lib/core/models.py:1502 core/models.py:1502
|
||||
msgid "Template/user relations"
|
||||
msgstr "Liammoù patrom/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
#: build/lib/core/models.py:1508 core/models.py:1508
|
||||
msgid "This user is already in this template."
|
||||
msgstr "An implijer-mañ a zo dija er patrom-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
#: build/lib/core/models.py:1514 core/models.py:1514
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1591 core/models.py:1591
|
||||
msgid "email address"
|
||||
msgstr "postel"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1610 core/models.py:1610
|
||||
msgid "Document invitation"
|
||||
msgstr "Pedadenn d'ur restr"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1611 core/models.py:1611
|
||||
msgid "Document invitations"
|
||||
msgstr "Pedadennoù d'ur restr"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1631 core/models.py:1631
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -79,11 +79,15 @@ msgstr "Typ"
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "Kopie von {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,8 +243,8 @@ msgstr "Benutzer"
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
|
||||
#: core/models.py:361 core/models.py:1434
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
@@ -256,188 +260,188 @@ msgstr "Dokument"
|
||||
msgid "Documents"
|
||||
msgstr "Dokumente"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
|
||||
#: core/models.py:827
|
||||
msgid "Untitled Document"
|
||||
msgstr "Unbenanntes Dokument"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:866 core/models.py:866
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:872 core/models.py:872
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:973 core/models.py:973
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:980 core/models.py:980
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Für dieses Dokument/ diesen Benutzer ist bereits eine Linkverfolgung vorhanden."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1003 core/models.py:1003
|
||||
msgid "Document favorite"
|
||||
msgstr "Dokumentenfavorit"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
msgid "Document favorites"
|
||||
msgstr "Dokumentfavoriten"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1032 core/models.py:1032
|
||||
msgid "Document/user relation"
|
||||
msgstr "Dokument/Benutzerbeziehung"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
msgid "Document/user relations"
|
||||
msgstr "Dokument/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1045 core/models.py:1045
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
|
||||
#: core/models.py:1051 core/models.py:1520
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1202 core/models.py:1202
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1203 core/models.py:1203
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1209 core/models.py:1209
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1270 core/models.py:1270
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1276 core/models.py:1276
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1318 core/models.py:1318
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1319 core/models.py:1319
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
|
||||
#: core/models.py:1322 core/models.py:1374
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1369 core/models.py:1369
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1370 core/models.py:1370
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1423 core/models.py:1423
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1424 core/models.py:1424
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "description"
|
||||
msgstr "Beschreibung"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "code"
|
||||
msgstr "Code"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "public"
|
||||
msgstr "öffentlich"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
#: build/lib/core/models.py:1441 core/models.py:1441
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
#: build/lib/core/models.py:1447 core/models.py:1447
|
||||
msgid "Template"
|
||||
msgstr "Vorlage"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
#: build/lib/core/models.py:1448 core/models.py:1448
|
||||
msgid "Templates"
|
||||
msgstr "Vorlagen"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "Template/user relation"
|
||||
msgstr "Vorlage/Benutzer-Beziehung"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
#: build/lib/core/models.py:1502 core/models.py:1502
|
||||
msgid "Template/user relations"
|
||||
msgstr "Vorlage/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
#: build/lib/core/models.py:1508 core/models.py:1508
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
#: build/lib/core/models.py:1514 core/models.py:1514
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Dieses Team ist bereits in diesem Template."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1591 core/models.py:1591
|
||||
msgid "email address"
|
||||
msgstr "E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1610 core/models.py:1610
|
||||
msgid "Document invitation"
|
||||
msgstr "Einladung zum Dokument"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1611 core/models.py:1611
|
||||
msgid "Document invitations"
|
||||
msgstr "Dokumenteinladungen"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1631 core/models.py:1631
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -79,11 +79,15 @@ msgstr ""
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,8 +243,8 @@ msgstr ""
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
|
||||
#: core/models.py:361 core/models.py:1434
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
@@ -256,188 +260,188 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
|
||||
#: core/models.py:827
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:866 core/models.py:866
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:872 core/models.py:872
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:973 core/models.py:973
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:980 core/models.py:980
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1003 core/models.py:1003
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1032 core/models.py:1032
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1045 core/models.py:1045
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
|
||||
#: core/models.py:1051 core/models.py:1520
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1202 core/models.py:1202
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1203 core/models.py:1203
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1209 core/models.py:1209
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1270 core/models.py:1270
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1276 core/models.py:1276
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1318 core/models.py:1318
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1319 core/models.py:1319
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
|
||||
#: core/models.py:1322 core/models.py:1374
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1369 core/models.py:1369
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1370 core/models.py:1370
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1423 core/models.py:1423
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1424 core/models.py:1424
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
#: build/lib/core/models.py:1441 core/models.py:1441
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
#: build/lib/core/models.py:1447 core/models.py:1447
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
#: build/lib/core/models.py:1448 core/models.py:1448
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
#: build/lib/core/models.py:1502 core/models.py:1502
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
#: build/lib/core/models.py:1508 core/models.py:1508
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
#: build/lib/core/models.py:1514 core/models.py:1514
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1591 core/models.py:1591
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1610 core/models.py:1610
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1611 core/models.py:1611
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1631 core/models.py:1631
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Spanish\n"
|
||||
"Language: es_ES\n"
|
||||
@@ -79,11 +79,15 @@ msgstr "Tipo de Cuerpo"
|
||||
msgid "Format"
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copia de {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,8 +243,8 @@ msgstr "usuario"
|
||||
msgid "users"
|
||||
msgstr "usuarios"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
|
||||
#: core/models.py:361 core/models.py:1434
|
||||
msgid "title"
|
||||
msgstr "título"
|
||||
|
||||
@@ -256,188 +260,188 @@ msgstr "Documento"
|
||||
msgid "Documents"
|
||||
msgstr "Documentos"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
|
||||
#: core/models.py:827
|
||||
msgid "Untitled Document"
|
||||
msgstr "Documento sin título"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "¡{name} ha compartido un documento contigo!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:866 core/models.py:866
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "Te ha invitado {name} al siguiente documento con el rol \"{role}\" :"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:872 core/models.py:872
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} ha compartido un documento contigo: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:973 core/models.py:973
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Traza del enlace de documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Trazas del enlace de documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:980 core/models.py:980
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Ya existe una traza de enlace para este documento/usuario."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1003 core/models.py:1003
|
||||
msgid "Document favorite"
|
||||
msgstr "Documento favorito"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
msgid "Document favorites"
|
||||
msgstr "Documentos favoritos"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Este documento ya ha sido marcado como favorito por el usuario."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1032 core/models.py:1032
|
||||
msgid "Document/user relation"
|
||||
msgstr "Relación documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
msgid "Document/user relations"
|
||||
msgstr "Relaciones documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Este usuario ya forma parte del documento."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1045 core/models.py:1045
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Este equipo ya forma parte del documento."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
|
||||
#: core/models.py:1051 core/models.py:1520
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Debe establecerse un usuario o un equipo, no ambos."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1202 core/models.py:1202
|
||||
msgid "Document ask for access"
|
||||
msgstr "Solicitud de acceso"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1203 core/models.py:1203
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Solicitud de accesos"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1209 core/models.py:1209
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Este usuario ya ha solicitado acceso a este documento."
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "¡{name} desea acceder a un documento!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1270 core/models.py:1270
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} desea acceso al siguiente documento:"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1276 core/models.py:1276
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} está pidiendo acceso al documento: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1318 core/models.py:1318
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1319 core/models.py:1319
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
|
||||
#: core/models.py:1322 core/models.py:1374
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1369 core/models.py:1369
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1370 core/models.py:1370
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1423 core/models.py:1423
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1424 core/models.py:1424
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "description"
|
||||
msgstr "descripción"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "code"
|
||||
msgstr "código"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "public"
|
||||
msgstr "público"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
#: build/lib/core/models.py:1441 core/models.py:1441
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Si esta plantilla es pública para que cualquiera la utilice."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
#: build/lib/core/models.py:1447 core/models.py:1447
|
||||
msgid "Template"
|
||||
msgstr "Plantilla"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
#: build/lib/core/models.py:1448 core/models.py:1448
|
||||
msgid "Templates"
|
||||
msgstr "Plantillas"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "Template/user relation"
|
||||
msgstr "Relación plantilla/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
#: build/lib/core/models.py:1502 core/models.py:1502
|
||||
msgid "Template/user relations"
|
||||
msgstr "Relaciones plantilla/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
#: build/lib/core/models.py:1508 core/models.py:1508
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Este usuario ya forma parte de la plantilla."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
#: build/lib/core/models.py:1514 core/models.py:1514
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Este equipo ya se encuentra en esta plantilla."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1591 core/models.py:1591
|
||||
msgid "email address"
|
||||
msgstr "dirección de correo electrónico"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1610 core/models.py:1610
|
||||
msgid "Document invitation"
|
||||
msgstr "Invitación al documento"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1611 core/models.py:1611
|
||||
msgid "Document invitations"
|
||||
msgstr "Invitaciones a documentos"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1631 core/models.py:1631
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Este correo electrónico está asociado a un usuario registrado."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -79,11 +79,15 @@ msgstr "Type de corps"
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copie de {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr "Noyau d'application Impress"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,8 +243,8 @@ msgstr "utilisateur"
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
|
||||
#: core/models.py:361 core/models.py:1434
|
||||
msgid "title"
|
||||
msgstr "titre"
|
||||
|
||||
@@ -256,188 +260,188 @@ msgstr "Document"
|
||||
msgid "Documents"
|
||||
msgstr "Documents"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
|
||||
#: core/models.py:827
|
||||
msgid "Untitled Document"
|
||||
msgstr "Document sans titre"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} a partagé un document avec vous!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:866 core/models.py:866
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant :"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:872 core/models.py:872
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} a partagé un document avec vous : {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:973 core/models.py:973
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Trace du lien document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Traces du lien document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:980 core/models.py:980
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Une trace de lien existe déjà pour ce document/utilisateur."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1003 core/models.py:1003
|
||||
msgid "Document favorite"
|
||||
msgstr "Document favori"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
msgid "Document favorites"
|
||||
msgstr "Documents favoris"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Ce document est déjà un favori de cet utilisateur."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1032 core/models.py:1032
|
||||
msgid "Document/user relation"
|
||||
msgstr "Relation document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
msgid "Document/user relations"
|
||||
msgstr "Relations document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Cet utilisateur est déjà dans ce document."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1045 core/models.py:1045
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Cette équipe est déjà dans ce document."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
|
||||
#: core/models.py:1051 core/models.py:1520
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "L'utilisateur ou l'équipe doivent être définis, pas les deux."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1202 core/models.py:1202
|
||||
msgid "Document ask for access"
|
||||
msgstr "Demande d'accès au document"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1203 core/models.py:1203
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Demande d'accès au document"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1209 core/models.py:1209
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Cet utilisateur a déjà demandé l'accès à ce document."
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} souhaiterait accéder au document suivant !"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1270 core/models.py:1270
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} souhaiterait accéder au document suivant :"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1276 core/models.py:1276
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} demande l'accès au document : {title}"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1318 core/models.py:1318
|
||||
msgid "Thread"
|
||||
msgstr "Conversation"
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1319 core/models.py:1319
|
||||
msgid "Threads"
|
||||
msgstr "Conversations"
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
|
||||
#: core/models.py:1322 core/models.py:1374
|
||||
msgid "Anonymous"
|
||||
msgstr "Anonyme"
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1369 core/models.py:1369
|
||||
msgid "Comment"
|
||||
msgstr "Commentaire"
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1370 core/models.py:1370
|
||||
msgid "Comments"
|
||||
msgstr "Commentaires"
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "Cet émoji a déjà été réagi à ce commentaire."
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1423 core/models.py:1423
|
||||
msgid "Reaction"
|
||||
msgstr "Réaction"
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1424 core/models.py:1424
|
||||
msgid "Reactions"
|
||||
msgstr "Réactions"
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "description"
|
||||
msgstr "description"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "public"
|
||||
msgstr "public"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
#: build/lib/core/models.py:1441 core/models.py:1441
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Si ce modèle est public, utilisable par n'importe qui."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
#: build/lib/core/models.py:1447 core/models.py:1447
|
||||
msgid "Template"
|
||||
msgstr "Modèle"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
#: build/lib/core/models.py:1448 core/models.py:1448
|
||||
msgid "Templates"
|
||||
msgstr "Modèles"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "Template/user relation"
|
||||
msgstr "Relation modèle/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
#: build/lib/core/models.py:1502 core/models.py:1502
|
||||
msgid "Template/user relations"
|
||||
msgstr "Relations modèle/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
#: build/lib/core/models.py:1508 core/models.py:1508
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Cet utilisateur est déjà dans ce modèle."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
#: build/lib/core/models.py:1514 core/models.py:1514
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Cette équipe est déjà modèle."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1591 core/models.py:1591
|
||||
msgid "email address"
|
||||
msgstr "adresse e-mail"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1610 core/models.py:1610
|
||||
msgid "Document invitation"
|
||||
msgstr "Invitation à un document"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1611 core/models.py:1611
|
||||
msgid "Document invitations"
|
||||
msgstr "Invitations à un document"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1631 core/models.py:1631
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Italian\n"
|
||||
"Language: it_IT\n"
|
||||
@@ -79,11 +79,15 @@ msgstr ""
|
||||
msgid "Format"
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copia di {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,8 +243,8 @@ msgstr "utente"
|
||||
msgid "users"
|
||||
msgstr "utenti"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
|
||||
#: core/models.py:361 core/models.py:1434
|
||||
msgid "title"
|
||||
msgstr "titolo"
|
||||
|
||||
@@ -256,188 +260,188 @@ msgstr "Documento"
|
||||
msgid "Documents"
|
||||
msgstr "Documenti"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
|
||||
#: core/models.py:827
|
||||
msgid "Untitled Document"
|
||||
msgstr "Documento senza titolo"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} ha condiviso un documento con te!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:866 core/models.py:866
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} ti ha invitato con il ruolo \"{role}\" nel seguente documento:"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:872 core/models.py:872
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} ha condiviso un documento con te: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:973 core/models.py:973
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:980 core/models.py:980
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1003 core/models.py:1003
|
||||
msgid "Document favorite"
|
||||
msgstr "Documento preferito"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
msgid "Document favorites"
|
||||
msgstr "Documenti preferiti"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1032 core/models.py:1032
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Questo utente è già presente in questo documento."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1045 core/models.py:1045
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Questo team è già presente in questo documento."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
|
||||
#: core/models.py:1051 core/models.py:1520
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1202 core/models.py:1202
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1203 core/models.py:1203
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1209 core/models.py:1209
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1270 core/models.py:1270
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1276 core/models.py:1276
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1318 core/models.py:1318
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1319 core/models.py:1319
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
|
||||
#: core/models.py:1322 core/models.py:1374
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1369 core/models.py:1369
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1370 core/models.py:1370
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1423 core/models.py:1423
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1424 core/models.py:1424
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "description"
|
||||
msgstr "descrizione"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "public"
|
||||
msgstr "pubblico"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
#: build/lib/core/models.py:1441 core/models.py:1441
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Indica se questo modello è pubblico per chiunque."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
#: build/lib/core/models.py:1447 core/models.py:1447
|
||||
msgid "Template"
|
||||
msgstr "Modello"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
#: build/lib/core/models.py:1448 core/models.py:1448
|
||||
msgid "Templates"
|
||||
msgstr "Modelli"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
#: build/lib/core/models.py:1502 core/models.py:1502
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
#: build/lib/core/models.py:1508 core/models.py:1508
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Questo utente è già in questo modello."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
#: build/lib/core/models.py:1514 core/models.py:1514
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Questo team è già in questo modello."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1591 core/models.py:1591
|
||||
msgid "email address"
|
||||
msgstr "indirizzo e-mail"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1610 core/models.py:1610
|
||||
msgid "Document invitation"
|
||||
msgstr "Invito al documento"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1611 core/models.py:1611
|
||||
msgid "Document invitations"
|
||||
msgstr "Inviti al documento"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1631 core/models.py:1631
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Questa email è già associata a un utente registrato."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
@@ -79,11 +79,15 @@ msgstr "Text type"
|
||||
msgid "Format"
|
||||
msgstr "Formaat"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "kopie van {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr "Docs kern applicatie"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,8 +243,8 @@ msgstr "gebruiker"
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
|
||||
#: core/models.py:361 core/models.py:1434
|
||||
msgid "title"
|
||||
msgstr "titel"
|
||||
|
||||
@@ -256,188 +260,188 @@ msgstr "Document"
|
||||
msgid "Documents"
|
||||
msgstr "Documenten"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
|
||||
#: core/models.py:827
|
||||
msgid "Untitled Document"
|
||||
msgstr "Naamloos Document"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} heeft een document met u gedeeld!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:866 core/models.py:866
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} heeft u uitgenodigd met de rol \"{role}\" op het volgende document:"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:872 core/models.py:872
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} heeft een document met u gedeeld: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:973 core/models.py:973
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Document/gebruiker link"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Document/gebruiker link"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:980 core/models.py:980
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Een link bestaat al voor dit document/deze gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1003 core/models.py:1003
|
||||
msgid "Document favorite"
|
||||
msgstr "Document favoriet"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
msgid "Document favorites"
|
||||
msgstr "Document favorieten"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Dit document is al in gebruik als favoriet door dezelfde gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1032 core/models.py:1032
|
||||
msgid "Document/user relation"
|
||||
msgstr "Document/gebruiker relatie"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
msgid "Document/user relations"
|
||||
msgstr "Document/gebruiker relaties"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
msgid "This user is already in this document."
|
||||
msgstr "De gebruiker bestaat al in dit document."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1045 core/models.py:1045
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dit team bestaat al in dit document."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
|
||||
#: core/models.py:1051 core/models.py:1520
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Een gebruiker of team moet gekozen worden, maar niet beide."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1202 core/models.py:1202
|
||||
msgid "Document ask for access"
|
||||
msgstr "Document verzoekt om toegang"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1203 core/models.py:1203
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Document verzoekt om toegangen"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1209 core/models.py:1209
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Deze gebruiker heeft al om toegang tot dit document gevraagd."
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} verzoekt toegang tot een document!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1270 core/models.py:1270
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} verzoekt toegang tot het volgende document:"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1276 core/models.py:1276
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} verzoekt toegang tot het document: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1318 core/models.py:1318
|
||||
msgid "Thread"
|
||||
msgstr "Kanaal"
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1319 core/models.py:1319
|
||||
msgid "Threads"
|
||||
msgstr "Kanalen"
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
|
||||
#: core/models.py:1322 core/models.py:1374
|
||||
msgid "Anonymous"
|
||||
msgstr "Anoniem"
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1369 core/models.py:1369
|
||||
msgid "Comment"
|
||||
msgstr "Reactie"
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1370 core/models.py:1370
|
||||
msgid "Comments"
|
||||
msgstr "Reacties"
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "Deze emoji is al op deze opmerking gereageerd."
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1423 core/models.py:1423
|
||||
msgid "Reaction"
|
||||
msgstr "Reactie"
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1424 core/models.py:1424
|
||||
msgid "Reactions"
|
||||
msgstr "Reacties"
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "description"
|
||||
msgstr "omschrijving"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "public"
|
||||
msgstr "publiek"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
#: build/lib/core/models.py:1441 core/models.py:1441
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Of dit sjabloon door iedereen publiekelijk te gebruiken is."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
#: build/lib/core/models.py:1447 core/models.py:1447
|
||||
msgid "Template"
|
||||
msgstr "Sjabloon"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
#: build/lib/core/models.py:1448 core/models.py:1448
|
||||
msgid "Templates"
|
||||
msgstr "Sjabloon"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "Template/user relation"
|
||||
msgstr "Sjabloon/gebruiker relatie"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
#: build/lib/core/models.py:1502 core/models.py:1502
|
||||
msgid "Template/user relations"
|
||||
msgstr "Sjabloon/gebruiker relaties"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
#: build/lib/core/models.py:1508 core/models.py:1508
|
||||
msgid "This user is already in this template."
|
||||
msgstr "De gebruiker bestaat al in dit sjabloon."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
#: build/lib/core/models.py:1514 core/models.py:1514
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Het team bestaat al in dit sjabloon."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1591 core/models.py:1591
|
||||
msgid "email address"
|
||||
msgstr "e-mailadres"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1610 core/models.py:1610
|
||||
msgid "Document invitation"
|
||||
msgstr "Document uitnodiging"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1611 core/models.py:1611
|
||||
msgid "Document invitations"
|
||||
msgstr "Document uitnodigingen"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1631 core/models.py:1631
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Portuguese\n"
|
||||
"Language: pt_PT\n"
|
||||
@@ -79,11 +79,15 @@ msgstr "Tipo de corpo"
|
||||
msgid "Format"
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "cópia de {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,8 +243,8 @@ msgstr ""
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
|
||||
#: core/models.py:361 core/models.py:1434
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
@@ -256,188 +260,188 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
|
||||
#: core/models.py:827
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:866 core/models.py:866
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:872 core/models.py:872
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:973 core/models.py:973
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:980 core/models.py:980
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1003 core/models.py:1003
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1032 core/models.py:1032
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1045 core/models.py:1045
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
|
||||
#: core/models.py:1051 core/models.py:1520
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1202 core/models.py:1202
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1203 core/models.py:1203
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1209 core/models.py:1209
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1270 core/models.py:1270
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1276 core/models.py:1276
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1318 core/models.py:1318
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1319 core/models.py:1319
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
|
||||
#: core/models.py:1322 core/models.py:1374
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1369 core/models.py:1369
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1370 core/models.py:1370
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1423 core/models.py:1423
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1424 core/models.py:1424
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
#: build/lib/core/models.py:1441 core/models.py:1441
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
#: build/lib/core/models.py:1447 core/models.py:1447
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
#: build/lib/core/models.py:1448 core/models.py:1448
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
#: build/lib/core/models.py:1502 core/models.py:1502
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
#: build/lib/core/models.py:1508 core/models.py:1508
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
#: build/lib/core/models.py:1514 core/models.py:1514
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1591 core/models.py:1591
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1610 core/models.py:1610
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1611 core/models.py:1611
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1631 core/models.py:1631
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Russian\n"
|
||||
"Language: ru_RU\n"
|
||||
@@ -79,11 +79,15 @@ msgstr "Тип сообщения"
|
||||
msgid "Format"
|
||||
msgstr "Формат"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "копия {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr "Ядро приложения Impress"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,8 +243,8 @@ msgstr "пользователь"
|
||||
msgid "users"
|
||||
msgstr "пользователи"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
|
||||
#: core/models.py:361 core/models.py:1434
|
||||
msgid "title"
|
||||
msgstr "заголовок"
|
||||
|
||||
@@ -256,188 +260,188 @@ msgstr "Документ"
|
||||
msgid "Documents"
|
||||
msgstr "Документы"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
|
||||
#: core/models.py:827
|
||||
msgid "Untitled Document"
|
||||
msgstr "Безымянный документ"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} делится с вами документом!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:866 core/models.py:866
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:872 core/models.py:872
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} делится с вами документом: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:973 core/models.py:973
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Трассировка связи документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Трассировка связей документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:980 core/models.py:980
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1003 core/models.py:1003
|
||||
msgid "Document favorite"
|
||||
msgstr "Избранный документ"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
msgid "Document favorites"
|
||||
msgstr "Избранные документы"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Этот документ уже помечен как избранный для этого пользователя."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1032 core/models.py:1032
|
||||
msgid "Document/user relation"
|
||||
msgstr "Отношение документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
msgid "Document/user relations"
|
||||
msgstr "Отношения документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Этот пользователь уже имеет доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1045 core/models.py:1045
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Эта команда уже имеет доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
|
||||
#: core/models.py:1051 core/models.py:1520
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1202 core/models.py:1202
|
||||
msgid "Document ask for access"
|
||||
msgstr "Документ запрашивает доступ"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1203 core/models.py:1203
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Документ запрашивает доступы"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1209 core/models.py:1209
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Этот пользователь уже запросил доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} хочет получить доступ к документу!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1270 core/models.py:1270
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} хочет получить доступ к следующему документу:"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1276 core/models.py:1276
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} запрашивает доступ к документу: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1318 core/models.py:1318
|
||||
msgid "Thread"
|
||||
msgstr "Обсуждение"
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1319 core/models.py:1319
|
||||
msgid "Threads"
|
||||
msgstr "Обсуждения"
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
|
||||
#: core/models.py:1322 core/models.py:1374
|
||||
msgid "Anonymous"
|
||||
msgstr "Аноним"
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1369 core/models.py:1369
|
||||
msgid "Comment"
|
||||
msgstr "Комментарий"
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1370 core/models.py:1370
|
||||
msgid "Comments"
|
||||
msgstr "Комментарии"
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "Этот эмодзи уже использован в этом комментарии."
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1423 core/models.py:1423
|
||||
msgid "Reaction"
|
||||
msgstr "Реакция"
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1424 core/models.py:1424
|
||||
msgid "Reactions"
|
||||
msgstr "Реакции"
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "description"
|
||||
msgstr "описание"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "code"
|
||||
msgstr "код"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "public"
|
||||
msgstr "доступно всем"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
#: build/lib/core/models.py:1441 core/models.py:1441
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Этот шаблон доступен всем пользователям."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
#: build/lib/core/models.py:1447 core/models.py:1447
|
||||
msgid "Template"
|
||||
msgstr "Шаблон"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
#: build/lib/core/models.py:1448 core/models.py:1448
|
||||
msgid "Templates"
|
||||
msgstr "Шаблоны"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "Template/user relation"
|
||||
msgstr "Отношение шаблон/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
#: build/lib/core/models.py:1502 core/models.py:1502
|
||||
msgid "Template/user relations"
|
||||
msgstr "Отношения шаблон/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
#: build/lib/core/models.py:1508 core/models.py:1508
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Этот пользователь уже указан в этом шаблоне."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
#: build/lib/core/models.py:1514 core/models.py:1514
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Эта команда уже указана в этом шаблоне."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1591 core/models.py:1591
|
||||
msgid "email address"
|
||||
msgstr "адрес электронной почты"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1610 core/models.py:1610
|
||||
msgid "Document invitation"
|
||||
msgstr "Приглашение для документа"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1611 core/models.py:1611
|
||||
msgid "Document invitations"
|
||||
msgstr "Приглашения для документов"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1631 core/models.py:1631
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Этот адрес уже связан с зарегистрированным пользователем."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Slovenian\n"
|
||||
"Language: sl_SI\n"
|
||||
@@ -79,11 +79,15 @@ msgstr "Vrsta telesa"
|
||||
msgid "Format"
|
||||
msgstr "Oblika"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,8 +243,8 @@ msgstr "uporabnik"
|
||||
msgid "users"
|
||||
msgstr "uporabniki"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
|
||||
#: core/models.py:361 core/models.py:1434
|
||||
msgid "title"
|
||||
msgstr "naslov"
|
||||
|
||||
@@ -256,188 +260,188 @@ msgstr "Dokument"
|
||||
msgid "Documents"
|
||||
msgstr "Dokumenti"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
|
||||
#: core/models.py:827
|
||||
msgid "Untitled Document"
|
||||
msgstr "Dokument brez naslova"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} je delil dokument z vami!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:866 core/models.py:866
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} vas je povabil z vlogo \"{role}\" na naslednjem dokumentu:"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:872 core/models.py:872
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} je delil dokument z vami: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:973 core/models.py:973
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Dokument/sled povezave uporabnika"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Sledi povezav dokumenta/uporabnika"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:980 core/models.py:980
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1003 core/models.py:1003
|
||||
msgid "Document favorite"
|
||||
msgstr "Priljubljeni dokument"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
msgid "Document favorites"
|
||||
msgstr "Priljubljeni dokumenti"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Ta dokument je že ciljno usmerjen s priljubljenim primerkom relacije za istega uporabnika."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1032 core/models.py:1032
|
||||
msgid "Document/user relation"
|
||||
msgstr "Odnos dokument/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
msgid "Document/user relations"
|
||||
msgstr "Odnosi dokument/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Ta uporabnik je že v tem dokumentu."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1045 core/models.py:1045
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Ta ekipa je že v tem dokumentu."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
|
||||
#: core/models.py:1051 core/models.py:1520
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Nastaviti je treba bodisi uporabnika ali ekipo, a ne obojega."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1202 core/models.py:1202
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1203 core/models.py:1203
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1209 core/models.py:1209
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1270 core/models.py:1270
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1276 core/models.py:1276
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1318 core/models.py:1318
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1319 core/models.py:1319
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
|
||||
#: core/models.py:1322 core/models.py:1374
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1369 core/models.py:1369
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1370 core/models.py:1370
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1423 core/models.py:1423
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1424 core/models.py:1424
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "description"
|
||||
msgstr "opis"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "code"
|
||||
msgstr "koda"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "public"
|
||||
msgstr "javno"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
#: build/lib/core/models.py:1441 core/models.py:1441
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ali je ta predloga javna za uporabo."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
#: build/lib/core/models.py:1447 core/models.py:1447
|
||||
msgid "Template"
|
||||
msgstr "Predloga"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
#: build/lib/core/models.py:1448 core/models.py:1448
|
||||
msgid "Templates"
|
||||
msgstr "Predloge"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "Template/user relation"
|
||||
msgstr "Odnos predloga/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
#: build/lib/core/models.py:1502 core/models.py:1502
|
||||
msgid "Template/user relations"
|
||||
msgstr "Odnosi med predlogo in uporabnikom"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
#: build/lib/core/models.py:1508 core/models.py:1508
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Ta uporabnik je že v tej predlogi."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
#: build/lib/core/models.py:1514 core/models.py:1514
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Ta ekipa je že v tej predlogi."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1591 core/models.py:1591
|
||||
msgid "email address"
|
||||
msgstr "elektronski naslov"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1610 core/models.py:1610
|
||||
msgid "Document invitation"
|
||||
msgstr "Vabilo na dokument"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1611 core/models.py:1611
|
||||
msgid "Document invitations"
|
||||
msgstr "Vabila na dokument"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1631 core/models.py:1631
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Swedish\n"
|
||||
"Language: sv_SE\n"
|
||||
@@ -79,11 +79,15 @@ msgstr ""
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,8 +243,8 @@ msgstr ""
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
|
||||
#: core/models.py:361 core/models.py:1434
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
@@ -256,188 +260,188 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
|
||||
#: core/models.py:827
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:866 core/models.py:866
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:872 core/models.py:872
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:973 core/models.py:973
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:980 core/models.py:980
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1003 core/models.py:1003
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1032 core/models.py:1032
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1045 core/models.py:1045
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
|
||||
#: core/models.py:1051 core/models.py:1520
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1202 core/models.py:1202
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1203 core/models.py:1203
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1209 core/models.py:1209
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1270 core/models.py:1270
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1276 core/models.py:1276
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1318 core/models.py:1318
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1319 core/models.py:1319
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
|
||||
#: core/models.py:1322 core/models.py:1374
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1369 core/models.py:1369
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1370 core/models.py:1370
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1423 core/models.py:1423
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1424 core/models.py:1424
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
#: build/lib/core/models.py:1441 core/models.py:1441
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
#: build/lib/core/models.py:1447 core/models.py:1447
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
#: build/lib/core/models.py:1448 core/models.py:1448
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
#: build/lib/core/models.py:1502 core/models.py:1502
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
#: build/lib/core/models.py:1508 core/models.py:1508
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
#: build/lib/core/models.py:1514 core/models.py:1514
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1591 core/models.py:1591
|
||||
msgid "email address"
|
||||
msgstr "e-postadress"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1610 core/models.py:1610
|
||||
msgid "Document invitation"
|
||||
msgstr "Bjud in dokument"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1611 core/models.py:1611
|
||||
msgid "Document invitations"
|
||||
msgstr "Inbjudningar dokument"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1631 core/models.py:1631
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Denna e-postadress är redan associerad med en registrerad användare."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Turkish\n"
|
||||
"Language: tr_TR\n"
|
||||
@@ -79,11 +79,15 @@ msgstr ""
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,8 +243,8 @@ msgstr ""
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
|
||||
#: core/models.py:361 core/models.py:1434
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
@@ -256,188 +260,188 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
|
||||
#: core/models.py:827
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:866 core/models.py:866
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:872 core/models.py:872
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:973 core/models.py:973
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:980 core/models.py:980
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1003 core/models.py:1003
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1032 core/models.py:1032
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1045 core/models.py:1045
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
|
||||
#: core/models.py:1051 core/models.py:1520
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1202 core/models.py:1202
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1203 core/models.py:1203
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1209 core/models.py:1209
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1270 core/models.py:1270
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1276 core/models.py:1276
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1318 core/models.py:1318
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1319 core/models.py:1319
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
|
||||
#: core/models.py:1322 core/models.py:1374
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1369 core/models.py:1369
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1370 core/models.py:1370
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1423 core/models.py:1423
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1424 core/models.py:1424
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
#: build/lib/core/models.py:1441 core/models.py:1441
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
#: build/lib/core/models.py:1447 core/models.py:1447
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
#: build/lib/core/models.py:1448 core/models.py:1448
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
#: build/lib/core/models.py:1502 core/models.py:1502
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
#: build/lib/core/models.py:1508 core/models.py:1508
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
#: build/lib/core/models.py:1514 core/models.py:1514
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1591 core/models.py:1591
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1610 core/models.py:1610
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1611 core/models.py:1611
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1631 core/models.py:1631
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Ukrainian\n"
|
||||
"Language: uk_UA\n"
|
||||
@@ -79,11 +79,15 @@ msgstr "Тип вмісту"
|
||||
msgid "Format"
|
||||
msgstr "Формат"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "копія {title}"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr "Ядро додатку Impress"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,8 +243,8 @@ msgstr "користувач"
|
||||
msgid "users"
|
||||
msgstr "користувачі"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
|
||||
#: core/models.py:361 core/models.py:1434
|
||||
msgid "title"
|
||||
msgstr "заголовок"
|
||||
|
||||
@@ -256,188 +260,188 @@ msgstr "Документ"
|
||||
msgid "Documents"
|
||||
msgstr "Документи"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
|
||||
#: core/models.py:827
|
||||
msgid "Untitled Document"
|
||||
msgstr "Документ без назви"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} ділиться з вами документом!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:866 core/models.py:866
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:872 core/models.py:872
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} ділиться з вами документом: {title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:973 core/models.py:973
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Трасування посилання Документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Трасування посилань Документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:980 core/models.py:980
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1003 core/models.py:1003
|
||||
msgid "Document favorite"
|
||||
msgstr "Обраний документ"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
msgid "Document favorites"
|
||||
msgstr "Обрані документи"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Цей документ вже вказаний як обраний для одного користувача."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1032 core/models.py:1032
|
||||
msgid "Document/user relation"
|
||||
msgstr "Відносини документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
msgid "Document/user relations"
|
||||
msgstr "Відносини документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Цей користувач вже має доступ до цього документу."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1045 core/models.py:1045
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Ця команда вже має доступ до цього документа."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
|
||||
#: core/models.py:1051 core/models.py:1520
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Вкажіть користувача або команду, а не обох."
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1202 core/models.py:1202
|
||||
msgid "Document ask for access"
|
||||
msgstr "Запит доступу до документа"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1203 core/models.py:1203
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Запит доступу для документа"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1209 core/models.py:1209
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Цей користувач вже попросив доступ до цього документа."
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} хоче отримати доступ до документа!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1270 core/models.py:1270
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} бажає отримати доступ до наступного документа:"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1276 core/models.py:1276
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} запитує доступ до документа: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1318 core/models.py:1318
|
||||
msgid "Thread"
|
||||
msgstr "Обговорення"
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1319 core/models.py:1319
|
||||
msgid "Threads"
|
||||
msgstr "Обговорення"
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
|
||||
#: core/models.py:1322 core/models.py:1374
|
||||
msgid "Anonymous"
|
||||
msgstr "Анонім"
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1369 core/models.py:1369
|
||||
msgid "Comment"
|
||||
msgstr "Коментар"
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1370 core/models.py:1370
|
||||
msgid "Comments"
|
||||
msgstr "Коментарі"
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "Цим емодзі вже відреагували на цей коментар."
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1423 core/models.py:1423
|
||||
msgid "Reaction"
|
||||
msgstr "Реакція"
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1424 core/models.py:1424
|
||||
msgid "Reactions"
|
||||
msgstr "Реакції"
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "description"
|
||||
msgstr "опис"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "code"
|
||||
msgstr "код"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "public"
|
||||
msgstr "публічне"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
#: build/lib/core/models.py:1441 core/models.py:1441
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Чи є цей шаблон публічним для будь-кого користувача."
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
#: build/lib/core/models.py:1447 core/models.py:1447
|
||||
msgid "Template"
|
||||
msgstr "Шаблон"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
#: build/lib/core/models.py:1448 core/models.py:1448
|
||||
msgid "Templates"
|
||||
msgstr "Шаблони"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "Template/user relation"
|
||||
msgstr "Відношення шаблон/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
#: build/lib/core/models.py:1502 core/models.py:1502
|
||||
msgid "Template/user relations"
|
||||
msgstr "Відношення шаблон/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
#: build/lib/core/models.py:1508 core/models.py:1508
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Цей користувач вже має доступ до цього шаблону."
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
#: build/lib/core/models.py:1514 core/models.py:1514
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Ця команда вже має доступ до цього шаблону."
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1591 core/models.py:1591
|
||||
msgid "email address"
|
||||
msgstr "електронна адреса"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1610 core/models.py:1610
|
||||
msgid "Document invitation"
|
||||
msgstr "Запрошення до редагування документа"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1611 core/models.py:1611
|
||||
msgid "Document invitations"
|
||||
msgstr "Запрошення до редагування документів"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1631 core/models.py:1631
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-11-26 13:33\n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Chinese Simplified\n"
|
||||
"Language: zh_CN\n"
|
||||
@@ -79,11 +79,15 @@ msgstr "正文类型"
|
||||
msgid "Format"
|
||||
msgstr "格式"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "{title} 的副本"
|
||||
|
||||
#: build/lib/core/apps.py:12 core/apps.py:12
|
||||
msgid "Impress core application"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
@@ -239,8 +243,8 @@ msgstr "用户"
|
||||
msgid "users"
|
||||
msgstr "个用户"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
|
||||
#: core/models.py:361 core/models.py:1434
|
||||
msgid "title"
|
||||
msgstr "标题"
|
||||
|
||||
@@ -256,188 +260,188 @@ msgstr "文档"
|
||||
msgid "Documents"
|
||||
msgstr "个文档"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
|
||||
#: core/models.py:827
|
||||
msgid "Untitled Document"
|
||||
msgstr "未命名文档"
|
||||
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#: build/lib/core/models.py:862 core/models.py:862
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} 与您共享了一个文档!"
|
||||
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#: build/lib/core/models.py:866 core/models.py:866
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} 邀请您以“{role}”角色访问以下文档:"
|
||||
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#: build/lib/core/models.py:872 core/models.py:872
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} 与您共享了一个文档:{title}"
|
||||
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
#: build/lib/core/models.py:973 core/models.py:973
|
||||
msgid "Document/user link trace"
|
||||
msgstr "文档/用户链接跟踪"
|
||||
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
msgid "Document/user link traces"
|
||||
msgstr "个文档/用户链接跟踪"
|
||||
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
#: build/lib/core/models.py:980 core/models.py:980
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "此文档/用户的链接跟踪已存在。"
|
||||
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
#: build/lib/core/models.py:1003 core/models.py:1003
|
||||
msgid "Document favorite"
|
||||
msgstr "文档收藏"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
msgid "Document favorites"
|
||||
msgstr "文档收藏夹"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1010 core/models.py:1010
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "该文档已被同一用户的收藏关系实例关联。"
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
#: build/lib/core/models.py:1032 core/models.py:1032
|
||||
msgid "Document/user relation"
|
||||
msgstr "文档/用户关系"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
msgid "Document/user relations"
|
||||
msgstr "文档/用户关系集"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
msgid "This user is already in this document."
|
||||
msgstr "该用户已在此文档中。"
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
#: build/lib/core/models.py:1045 core/models.py:1045
|
||||
msgid "This team is already in this document."
|
||||
msgstr "该团队已在此文档中。"
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
|
||||
#: core/models.py:1051 core/models.py:1520
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "必须设置用户或团队之一,不能同时设置两者。"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1202 core/models.py:1202
|
||||
msgid "Document ask for access"
|
||||
msgstr "文档需要访问权限"
|
||||
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
#: build/lib/core/models.py:1203 core/models.py:1203
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "文档需要访问权限"
|
||||
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
#: build/lib/core/models.py:1209 core/models.py:1209
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "用户已申请该文档的访问权限。"
|
||||
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} 申请访问文档!"
|
||||
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#: build/lib/core/models.py:1270 core/models.py:1270
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} 申请访问以下文档:"
|
||||
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#: build/lib/core/models.py:1276 core/models.py:1276
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name}申请文档:{title}的访问权限"
|
||||
|
||||
#: build/lib/core/models.py:1314 core/models.py:1314
|
||||
#: build/lib/core/models.py:1318 core/models.py:1318
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1315 core/models.py:1315
|
||||
#: build/lib/core/models.py:1319 core/models.py:1319
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
|
||||
#: core/models.py:1318 core/models.py:1370
|
||||
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
|
||||
#: core/models.py:1322 core/models.py:1374
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1365 core/models.py:1365
|
||||
#: build/lib/core/models.py:1369 core/models.py:1369
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1366 core/models.py:1366
|
||||
#: build/lib/core/models.py:1370 core/models.py:1370
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1415 core/models.py:1415
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1419 core/models.py:1419
|
||||
#: build/lib/core/models.py:1423 core/models.py:1423
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1420 core/models.py:1420
|
||||
#: build/lib/core/models.py:1424 core/models.py:1424
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1431 core/models.py:1431
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "description"
|
||||
msgstr "说明"
|
||||
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
#: build/lib/core/models.py:1436 core/models.py:1436
|
||||
msgid "code"
|
||||
msgstr "代码"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
#: build/lib/core/models.py:1439 core/models.py:1439
|
||||
msgid "public"
|
||||
msgstr "公开"
|
||||
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
#: build/lib/core/models.py:1441 core/models.py:1441
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "该模板是否公开供任何人使用。"
|
||||
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
#: build/lib/core/models.py:1447 core/models.py:1447
|
||||
msgid "Template"
|
||||
msgstr "模板"
|
||||
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
#: build/lib/core/models.py:1448 core/models.py:1448
|
||||
msgid "Templates"
|
||||
msgstr "模板"
|
||||
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "Template/user relation"
|
||||
msgstr "模板/用户关系"
|
||||
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
#: build/lib/core/models.py:1502 core/models.py:1502
|
||||
msgid "Template/user relations"
|
||||
msgstr "模板/用户关系集"
|
||||
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
#: build/lib/core/models.py:1508 core/models.py:1508
|
||||
msgid "This user is already in this template."
|
||||
msgstr "该用户已在此模板中。"
|
||||
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
#: build/lib/core/models.py:1514 core/models.py:1514
|
||||
msgid "This team is already in this template."
|
||||
msgstr "该团队已在此模板中。"
|
||||
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
#: build/lib/core/models.py:1591 core/models.py:1591
|
||||
msgid "email address"
|
||||
msgstr "电子邮件地址"
|
||||
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
#: build/lib/core/models.py:1610 core/models.py:1610
|
||||
msgid "Document invitation"
|
||||
msgstr "文档邀请"
|
||||
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
#: build/lib/core/models.py:1611 core/models.py:1611
|
||||
msgid "Document invitations"
|
||||
msgstr "文档邀请"
|
||||
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
#: build/lib/core/models.py:1631 core/models.py:1631
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "此电子邮件已经与现有注册用户关联。"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "4.0.0"
|
||||
version = "4.3.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -34,12 +34,12 @@ dependencies = [
|
||||
"django-countries==8.1.0",
|
||||
"django-csp==4.0",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.18",
|
||||
"django-lasuite[all]==0.0.22",
|
||||
"django-parler==2.3",
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.2.8",
|
||||
"django==5.2.9",
|
||||
"django-treebeard==4.7.1",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
|
||||
@@ -126,6 +126,20 @@ test.describe('Config', () => {
|
||||
).toBeAttached();
|
||||
});
|
||||
|
||||
test('it checks FRONTEND_JS_URL config', async ({ page }) => {
|
||||
await overrideConfig(page, {
|
||||
FRONTEND_JS_URL: 'http://localhost:123465/js/script.js',
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('script[src="http://localhost:123465/js/script.js"]')
|
||||
.first(),
|
||||
).toBeAttached();
|
||||
});
|
||||
|
||||
test('it checks theme_customization.translations config', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -145,10 +159,6 @@ test.describe('Config', () => {
|
||||
|
||||
await expect(page.getByText('MyCustomDocs')).toBeAttached();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Config: Not logged', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('it checks the config api is called', async ({ page }) => {
|
||||
const responsePromise = page.waitForResponse(
|
||||
@@ -168,6 +178,10 @@ test.describe('Config: Not logged', () => {
|
||||
|
||||
expect(configApi).toStrictEqual(CONFIG_LEFT);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Config: Not logged', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('it checks that theme is configured from config endpoint', async ({
|
||||
page,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, getOtherBrowserName, verifyDocName } from './utils-common';
|
||||
import { writeInEditor } from './utils-editor';
|
||||
import {
|
||||
closeHeaderMenu,
|
||||
createDoc,
|
||||
getOtherBrowserName,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { getEditor, writeInEditor } from './utils-editor';
|
||||
import {
|
||||
addNewMember,
|
||||
connectOtherUserToDoc,
|
||||
@@ -43,6 +48,7 @@ test.describe('Doc Comments', () => {
|
||||
await thread.locator('[data-test="save"]').click();
|
||||
await expect(thread.getByText('This is a comment').first()).toBeHidden();
|
||||
|
||||
await editor.first().click();
|
||||
await editor.getByText('Hello').click();
|
||||
|
||||
await thread.getByText('This is a comment').first().hover();
|
||||
@@ -116,8 +122,7 @@ test.describe('Doc Comments', () => {
|
||||
await createDoc(page, 'comment-interaction', browserName, 1);
|
||||
|
||||
// Checks add react reaction
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.locator('.bn-block-outer').last().fill('Hello World');
|
||||
const editor = await writeInEditor({ page, text: 'Hello' });
|
||||
await editor.getByText('Hello').selectText();
|
||||
await page.getByRole('button', { name: 'Comment' }).click();
|
||||
|
||||
@@ -131,6 +136,7 @@ test.describe('Doc Comments', () => {
|
||||
'background-color',
|
||||
'rgba(237, 180, 0, 0.4)',
|
||||
);
|
||||
await editor.first().click();
|
||||
await editor.getByText('Hello').click();
|
||||
|
||||
await thread.getByText('This is a comment').first().hover();
|
||||
@@ -147,7 +153,7 @@ test.describe('Doc Comments', () => {
|
||||
await thread.getByRole('menuitem', { name: 'Edit comment' }).click();
|
||||
const commentEditor = thread.getByText('This is a comment').first();
|
||||
await commentEditor.fill('This is an edited comment');
|
||||
const saveBtn = thread.getByRole('button', { name: 'Save' });
|
||||
const saveBtn = thread.locator('button[data-test="save"]').first();
|
||||
await saveBtn.click();
|
||||
await expect(saveBtn).toBeHidden();
|
||||
await expect(
|
||||
@@ -157,7 +163,8 @@ test.describe('Doc Comments', () => {
|
||||
|
||||
// Add second comment
|
||||
await thread.getByRole('paragraph').last().fill('This is a second comment');
|
||||
await thread.getByRole('button', { name: 'Save' }).click();
|
||||
await saveBtn.click();
|
||||
await expect(saveBtn).toBeHidden();
|
||||
await expect(
|
||||
thread.getByText('This is an edited comment').first(),
|
||||
).toBeVisible();
|
||||
@@ -181,6 +188,29 @@ test.describe('Doc Comments', () => {
|
||||
'background-color',
|
||||
'rgba(0, 0, 0, 0)',
|
||||
);
|
||||
|
||||
/* Delete the last comment remove the thread */
|
||||
await editor.getByText('Hello').selectText();
|
||||
await page.getByRole('button', { name: 'Comment' }).click();
|
||||
|
||||
await thread.getByRole('paragraph').first().fill('This is a new comment');
|
||||
await thread.locator('[data-test="save"]').click();
|
||||
|
||||
await expect(editor.getByText('Hello')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(237, 180, 0, 0.4)',
|
||||
);
|
||||
await editor.first().click();
|
||||
await editor.getByText('Hello').click();
|
||||
|
||||
await thread.getByText('This is a new comment').first().hover();
|
||||
await thread.locator('[data-test="moreactions"]').first().click();
|
||||
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
|
||||
|
||||
await expect(editor.getByText('Hello')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(0, 0, 0, 0)',
|
||||
);
|
||||
});
|
||||
|
||||
test('it checks the comments abilities', async ({ page, browserName }) => {
|
||||
@@ -191,6 +221,8 @@ test.describe('Doc Comments', () => {
|
||||
// We share the doc with another user
|
||||
const otherBrowserName = getOtherBrowserName(browserName);
|
||||
|
||||
const editor = await getEditor({ page });
|
||||
|
||||
// Add a new member with editor role
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await addNewMember(page, 0, 'Editor', otherBrowserName);
|
||||
@@ -214,7 +246,7 @@ test.describe('Doc Comments', () => {
|
||||
text: 'Hello, I can edit the document',
|
||||
});
|
||||
await expect(
|
||||
otherEditor.getByText('Hello, I can edit the document'),
|
||||
editor.getByText('Hello, I can edit the document'),
|
||||
).toBeVisible();
|
||||
await otherEditor.getByText('Hello').selectText();
|
||||
await otherPage.getByRole('button', { name: 'Comment' }).click();
|
||||
@@ -292,4 +324,82 @@ test.describe('Doc Comments', () => {
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test('it checks comments pasting from another document', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'comment-doc-1', browserName, 1);
|
||||
|
||||
// We add a comment in the first document
|
||||
const editor1 = await writeInEditor({ page, text: 'Document One' });
|
||||
await editor1.getByText('Document One').selectText();
|
||||
await page.getByRole('button', { name: 'Comment' }).click();
|
||||
|
||||
const thread1 = page.locator('.bn-thread');
|
||||
await thread1.getByRole('paragraph').first().fill('Comment in Doc One');
|
||||
await thread1.locator('[data-test="save"]').click();
|
||||
await expect(thread1.getByText('Comment in Doc One').first()).toBeHidden();
|
||||
|
||||
await expect(editor1.getByText('Document One')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(237, 180, 0, 0.4)',
|
||||
);
|
||||
|
||||
await editor1.getByText('Document One').click();
|
||||
// We copy the content including the comment from the first document
|
||||
await editor1.getByText('Document One').selectText();
|
||||
await page.keyboard.press('Control+C');
|
||||
|
||||
// We create a second document
|
||||
await createDoc(page, 'comment-doc-2', browserName, 1);
|
||||
|
||||
// We paste the content into the second document
|
||||
const editor2 = await writeInEditor({ page, text: '' });
|
||||
await editor2.click();
|
||||
await page.keyboard.press('Control+V');
|
||||
|
||||
await expect(editor2.getByText('Document One')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(0, 0, 0, 0)',
|
||||
);
|
||||
|
||||
await editor2.getByText('Document One').click();
|
||||
await expect(page.locator('.bn-thread')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Doc Comments mobile', () => {
|
||||
test.use({ viewport: { width: 500, height: 1200 } });
|
||||
|
||||
test('Can comments on mobile', async ({ page, browserName }) => {
|
||||
const [title] = await createDoc(
|
||||
page,
|
||||
'comment-mobile',
|
||||
browserName,
|
||||
1,
|
||||
true,
|
||||
);
|
||||
|
||||
await closeHeaderMenu(page);
|
||||
|
||||
await verifyDocName(page, title);
|
||||
|
||||
// Checks add react reaction
|
||||
const editor = await writeInEditor({ page, text: 'Hello' });
|
||||
await editor.getByText('Hello').selectText();
|
||||
await page.getByRole('button', { name: 'Comment' }).click();
|
||||
|
||||
const thread = page.locator('.bn-thread');
|
||||
await thread.getByRole('paragraph').first().fill('This is a comment');
|
||||
await thread.locator('[data-test="save"]').click();
|
||||
await expect(thread.getByText('This is a comment').first()).toBeHidden();
|
||||
// Check toolbar is closed after adding a comment
|
||||
await expect(page.getByRole('button', { name: 'Paragraph' })).toBeHidden();
|
||||
|
||||
await editor.first().click();
|
||||
await editor.getByText('Hello').click();
|
||||
|
||||
await expect(thread.getByText('This is a comment').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -802,6 +802,8 @@ test.describe('Doc Editor', () => {
|
||||
await page.getByText('Symbols').scrollIntoViewIfNeeded();
|
||||
await expect(page.getByRole('button', { name: '🛃' })).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.locator('.bn-side-menu > button').last().click();
|
||||
await page.locator('.mantine-Menu-dropdown > button').last().click();
|
||||
await page.locator('.bn-color-picker-dropdown > button').last().click();
|
||||
@@ -878,14 +880,15 @@ test.describe('Doc Editor', () => {
|
||||
// Wait for the interlink to be created and rendered
|
||||
const editor = await getEditor({ page });
|
||||
|
||||
const interlinkChild2 = editor.getByRole('button', {
|
||||
name: docChild2,
|
||||
});
|
||||
const interlinkChild = editor
|
||||
.locator('.--docs--interlinking-link-inline-content')
|
||||
.first();
|
||||
|
||||
await expect(interlinkChild2).toBeVisible({ timeout: 10000 });
|
||||
await expect(interlinkChild2).toContainText('😀');
|
||||
await expect(interlinkChild2.locator('svg').first()).toBeHidden();
|
||||
await interlinkChild2.click();
|
||||
await expect(interlinkChild).toBeVisible({ timeout: 10000 });
|
||||
await expect(interlinkChild).toContainText('😀');
|
||||
await expect(interlinkChild).toContainText(docChild2);
|
||||
await expect(interlinkChild.locator('svg').first()).toBeHidden();
|
||||
await interlinkChild.click();
|
||||
|
||||
await verifyDocName(page, docChild2);
|
||||
|
||||
@@ -895,11 +898,9 @@ test.describe('Doc Editor', () => {
|
||||
await input.fill(docChild1);
|
||||
await searchContainer.getByText(docChild1).click();
|
||||
|
||||
const interlinkChild1 = editor.getByRole('button', {
|
||||
name: docChild1,
|
||||
});
|
||||
await expect(interlinkChild1).toBeVisible({ timeout: 10000 });
|
||||
await expect(interlinkChild1.locator('svg').first()).toBeVisible();
|
||||
await expect(interlinkChild).toContainText(docChild1);
|
||||
await expect(interlinkChild).toBeVisible({ timeout: 10000 });
|
||||
await expect(interlinkChild.locator('svg').first()).toBeVisible();
|
||||
|
||||
await page.keyboard.press('@');
|
||||
|
||||
@@ -959,13 +960,35 @@ test.describe('Doc Editor', () => {
|
||||
test('it embeds PDF', async ({ page, browserName }) => {
|
||||
await createDoc(page, 'doc-toolbar', browserName, 1);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await updateShareLink(page, 'Public', 'Reading');
|
||||
|
||||
await page.getByRole('button', { name: 'Close the share modal' }).click();
|
||||
|
||||
await openSuggestionMenu({ page });
|
||||
await page.getByText('Embed a PDF file').click();
|
||||
|
||||
const pdfBlock = page.locator('div[data-content-type="pdf"]').first();
|
||||
const pdfBlock = page.locator('div[data-content-type="pdf"]').last();
|
||||
|
||||
await expect(pdfBlock).toBeVisible();
|
||||
|
||||
// Try with invalid PDF first
|
||||
await page.getByText(/Add (PDF|file)/).click();
|
||||
|
||||
await page.locator('[data-test="embed-tab"]').click();
|
||||
|
||||
await page
|
||||
.locator('[data-test="embed-input"]')
|
||||
.fill('https://example.test/test.test');
|
||||
|
||||
await page.locator('[data-test="embed-input-button"]').click();
|
||||
|
||||
await expect(page.getByText('Invalid or missing PDF file')).toBeVisible();
|
||||
|
||||
await openSuggestionMenu({ page });
|
||||
await page.getByText('Embed a PDF file').click();
|
||||
|
||||
// Now with a valid PDF
|
||||
await page.getByText(/Add (PDF|file)/).click();
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
@@ -990,10 +1013,50 @@ test.describe('Doc Editor', () => {
|
||||
await expect(pdfEmbed).toHaveAttribute('role', 'presentation');
|
||||
|
||||
// Check download with original filename
|
||||
await page.locator('.bn-block-content[data-content-type="pdf"]').click();
|
||||
await pdfBlock.click();
|
||||
await page.locator('[data-test="downloadfile"]').click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe('test-pdf.pdf');
|
||||
});
|
||||
|
||||
test('it preserves text when switching between mobile and desktop views', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [docTitle] = await createDoc(
|
||||
page,
|
||||
'doc-viewport-test',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
const editor = await writeInEditor({
|
||||
page,
|
||||
text: 'Hello World - Desktop Text',
|
||||
});
|
||||
await expect(editor.getByText('Hello World - Desktop Text')).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Switch to mobile viewport
|
||||
await page.setViewportSize({ width: 500, height: 1200 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(editor.getByText('Hello World - Desktop Text')).toBeVisible();
|
||||
|
||||
await writeInEditor({
|
||||
page,
|
||||
text: 'Mobile Text',
|
||||
});
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Switch back to desktop viewport
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await expect(editor.getByText('Mobile Text')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
import JSZip from 'jszip';
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
|
||||
import {
|
||||
@@ -31,7 +32,7 @@ test.describe('Doc Export', () => {
|
||||
|
||||
await expect(page.getByTestId('modal-export-title')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Download your document in a .docx, .odt or .pdf format.'),
|
||||
page.getByText(/Download your document in a \.docx, \.odt.*format\./i),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('combobox', { name: 'Template' }),
|
||||
@@ -187,6 +188,89 @@ test.describe('Doc Export', () => {
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.odt`);
|
||||
});
|
||||
|
||||
test('it exports the doc to html zip', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-editor-html-zip',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
// Add some content and at least one image so that the ZIP contains media files.
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello HTML ZIP');
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Resizable image with caption').click();
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.getByText('Upload image').click();
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
|
||||
|
||||
const image = page
|
||||
.locator('.--docs--editor-container img.bn-visual-media')
|
||||
.first();
|
||||
|
||||
// Wait for the image to be attached and have a valid src (aria-hidden prevents toBeVisible on Chromium)
|
||||
await expect(image).toBeAttached({ timeout: 10000 });
|
||||
await expect(image).toHaveAttribute('src', /.*\.svg/);
|
||||
|
||||
// Give some time for the image to be fully processed
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Export the document',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page.getByRole('combobox', { name: 'Format' }).click();
|
||||
await page.getByRole('option', { name: 'HTML' }).click();
|
||||
|
||||
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.zip`);
|
||||
});
|
||||
|
||||
void page.getByTestId('doc-export-download-button').click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.zip`);
|
||||
|
||||
const zipBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
// Unzip and inspect contents
|
||||
const zip = await JSZip.loadAsync(zipBuffer);
|
||||
|
||||
// Check that index.html exists
|
||||
const indexHtml = zip.file('index.html');
|
||||
expect(indexHtml).not.toBeNull();
|
||||
|
||||
// Read and verify HTML content
|
||||
const htmlContent = await indexHtml!.async('string');
|
||||
expect(htmlContent).toContain('Hello HTML ZIP');
|
||||
expect(htmlContent).toContain('href="styles.css"');
|
||||
|
||||
// Check for media files (they are at the root of the ZIP, not in a media/ folder)
|
||||
// Media files are named like "1-test.svg" or "media-1.png" by deriveMediaFilename
|
||||
const allFiles = Object.keys(zip.files);
|
||||
const mediaFiles = allFiles.filter(
|
||||
(name) => name !== 'index.html' && !name.endsWith('/'),
|
||||
);
|
||||
expect(mediaFiles.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify the SVG image is included
|
||||
const svgFile = mediaFiles.find((name) => name.endsWith('.svg'));
|
||||
expect(svgFile).toBeDefined();
|
||||
const styleFile = mediaFiles.find((name) => name === 'styles.css');
|
||||
expect(styleFile).toBeDefined();
|
||||
});
|
||||
|
||||
/**
|
||||
* This test tell us that the export to pdf is working with images
|
||||
* but it does not tell us if the images are being displayed correctly
|
||||
@@ -443,7 +527,7 @@ test.describe('Doc Export', () => {
|
||||
|
||||
await verifyDocName(page, docChild);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
const editor = await openSuggestionMenu({ page });
|
||||
await page.getByText('Link a doc').first().click();
|
||||
|
||||
const input = page.locator(
|
||||
@@ -460,12 +544,11 @@ test.describe('Doc Export', () => {
|
||||
await searchContainer.getByText(randomDoc).click();
|
||||
|
||||
// Search the interlinking link in the editor (not in the document tree)
|
||||
const editor = page.locator('.ProseMirror.bn-editor');
|
||||
const interlink = editor.getByRole('button', {
|
||||
name: randomDoc,
|
||||
});
|
||||
const interlink = editor
|
||||
.locator('.--docs--interlinking-link-inline-content')
|
||||
.first();
|
||||
|
||||
await expect(interlink).toBeVisible();
|
||||
await expect(interlink).toContainText(randomDoc);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${docChild}.pdf`);
|
||||
@@ -509,7 +592,7 @@ test.describe('Doc Export', () => {
|
||||
|
||||
await verifyDocName(page, docChild);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
const editor = await openSuggestionMenu({ page });
|
||||
await page.getByText('Link a doc').first().click();
|
||||
|
||||
const input = page.locator(
|
||||
@@ -526,12 +609,11 @@ test.describe('Doc Export', () => {
|
||||
await searchContainer.getByText(randomDoc).click();
|
||||
|
||||
// Search the interlinking link in the editor (not in the document tree)
|
||||
const editor = page.locator('.ProseMirror.bn-editor');
|
||||
const interlink = editor.getByRole('button', {
|
||||
name: randomDoc,
|
||||
});
|
||||
const interlink = editor
|
||||
.locator('.--docs--interlinking-link-inline-content')
|
||||
.first();
|
||||
|
||||
await expect(interlink).toBeVisible();
|
||||
await expect(interlink).toContainText(randomDoc);
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, mockedListDocs } from './utils-common';
|
||||
import { createDoc, mockedListDocs, toggleHeaderMenu } from './utils-common';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.describe('Doc grid dnd', () => {
|
||||
@@ -185,10 +185,7 @@ test.describe('Doc grid dnd mobile', () => {
|
||||
true,
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the header menu' })
|
||||
.getByText('menu')
|
||||
.click();
|
||||
await toggleHeaderMenu(page);
|
||||
|
||||
await expect(page.locator('.--docs-sub-page-item').first()).toHaveAttribute(
|
||||
'draggable',
|
||||
|
||||
@@ -408,40 +408,6 @@ test.describe('Doc Header', () => {
|
||||
expect(clipboardContent.trim()).toBe('# Hello World');
|
||||
});
|
||||
|
||||
test('It checks the copy as HTML button', async ({ page, browserName }) => {
|
||||
test.skip(
|
||||
browserName === 'webkit',
|
||||
'navigator.clipboard is not working with webkit and playwright',
|
||||
);
|
||||
|
||||
// create page and navigate to it
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'New doc',
|
||||
})
|
||||
.click();
|
||||
|
||||
// Add dummy content to the doc
|
||||
const editor = page.locator('.ProseMirror');
|
||||
const docFirstBlock = editor.locator('.bn-block-content').first();
|
||||
await docFirstBlock.click();
|
||||
await page.keyboard.type('# Hello World', { delay: 100 });
|
||||
const docFirstBlockContent = docFirstBlock.locator('h1');
|
||||
await expect(docFirstBlockContent).toHaveText('Hello World');
|
||||
|
||||
// Copy content to clipboard
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
|
||||
await expect(page.getByText('Copied to clipboard')).toBeVisible();
|
||||
|
||||
// Test that clipboard is in HTML format
|
||||
const handle = await page.evaluateHandle(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
const clipboardContent = await handle.jsonValue();
|
||||
expect(clipboardContent.trim()).toBe(`<h1>Hello World</h1><p></p>`);
|
||||
});
|
||||
|
||||
test('it checks the copy link button', async ({ page, browserName }) => {
|
||||
test.skip(
|
||||
browserName === 'webkit',
|
||||
|
||||
@@ -303,6 +303,40 @@ test.describe('Doc Tree', () => {
|
||||
await expect(docTree.getByText(docChild)).toBeVisible();
|
||||
});
|
||||
|
||||
test('keyboard navigation with F2 focuses root actions button', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
// Create a parent document to initialize the tree
|
||||
const [docParent] = await createDoc(
|
||||
page,
|
||||
'doc-tree-keyboard-f2-root',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await verifyDocName(page, docParent);
|
||||
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
await expect(docTree).toBeVisible();
|
||||
|
||||
const rootItem = page.getByTestId('doc-tree-root-item');
|
||||
await expect(rootItem).toBeVisible();
|
||||
|
||||
// Focus the root item
|
||||
await rootItem.focus();
|
||||
await expect(rootItem).toBeFocused();
|
||||
|
||||
// Press F2 → focus should move to the root actions \"More options\" button
|
||||
await page.keyboard.press('F2');
|
||||
|
||||
const rootActions = rootItem.locator('.doc-tree-root-item-actions');
|
||||
const rootMoreOptionsButton = rootActions.getByRole('button', {
|
||||
name: /more options/i,
|
||||
});
|
||||
|
||||
await expect(rootMoreOptionsButton).toBeFocused();
|
||||
});
|
||||
|
||||
test('it updates the child icon from the tree', async ({
|
||||
page,
|
||||
browserName,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
mockedDocument,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { openSuggestionMenu, writeInEditor } from './utils-editor';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -17,6 +18,7 @@ test.describe('Doc Version', () => {
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
// Initially, there is no version
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('menuitem', { name: 'Version history' }).click();
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
@@ -26,31 +28,49 @@ test.describe('Doc Version', () => {
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(modal.getByText('No versions')).toBeVisible();
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await modal.getByRole('button', { name: 'close' }).click();
|
||||
await editor.click();
|
||||
await page.keyboard.type('# Hello World');
|
||||
|
||||
await writeInEditor({ page, text: 'Hello World' });
|
||||
|
||||
// It will trigger a save, no version created yet (initial version is not counted)
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Hello World' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Hello World')).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.ProseMirror .bn-block')
|
||||
.getByRole('heading', { name: 'Hello World' })
|
||||
.fill('It will create a version');
|
||||
// Write more
|
||||
await writeInEditor({ page, text: 'It will create a version' });
|
||||
|
||||
await openSuggestionMenu({ page });
|
||||
await page.getByText('Add a callout block').click();
|
||||
|
||||
const calloutBlock = page
|
||||
.locator('div[data-content-type="callout"]')
|
||||
.first();
|
||||
|
||||
await expect(calloutBlock).toBeVisible();
|
||||
|
||||
// It will trigger a save and create a version this time
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Hello World')).toBeHidden();
|
||||
await expect(page.getByText('It will create a version')).toBeVisible();
|
||||
|
||||
await expect(calloutBlock).toBeVisible();
|
||||
|
||||
// Write more
|
||||
await writeInEditor({ page, text: 'It will create a second version' });
|
||||
|
||||
// It will trigger a save and create a second version
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'It will create a version' }),
|
||||
page.getByText('It will create a second version'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
@@ -60,11 +80,33 @@ test.describe('Doc Version', () => {
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('status')).toBeHidden();
|
||||
const items = await panel.locator('.version-item').all();
|
||||
expect(items.length).toBe(1);
|
||||
await items[0].click();
|
||||
expect(items.length).toBe(2);
|
||||
await items[1].click();
|
||||
|
||||
await expect(modal.getByText('Hello World')).toBeVisible();
|
||||
await expect(modal.getByText('It will create a version')).toBeHidden();
|
||||
await expect(
|
||||
modal.locator('div[data-content-type="callout"]').first(),
|
||||
).toBeHidden();
|
||||
|
||||
await items[0].click();
|
||||
|
||||
await expect(modal.getByText('Hello World')).toBeVisible();
|
||||
await expect(modal.getByText('It will create a version')).toBeVisible();
|
||||
await expect(
|
||||
modal.locator('div[data-content-type="callout"]').first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
modal.getByText('It will create a second version'),
|
||||
).toBeHidden();
|
||||
|
||||
await items[1].click();
|
||||
|
||||
await expect(modal.getByText('Hello World')).toBeVisible();
|
||||
await expect(modal.getByText('It will create a version')).toBeHidden();
|
||||
await expect(
|
||||
modal.locator('div[data-content-type="callout"]').first(),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('it does not display the doc versions if not allowed', async ({
|
||||
|
||||
@@ -10,6 +10,7 @@ export const CONFIG = {
|
||||
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
|
||||
ENVIRONMENT: 'development',
|
||||
FRONTEND_CSS_URL: null,
|
||||
FRONTEND_JS_URL: null,
|
||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED: true,
|
||||
FRONTEND_THEME: null,
|
||||
MEDIA_BASE_URL: 'http://localhost:8083',
|
||||
@@ -83,6 +84,34 @@ export const randomName = (name: string, browserName: string, length: number) =>
|
||||
return `${browserName}-${Math.floor(Math.random() * 10000)}-${index}-${name}`;
|
||||
});
|
||||
|
||||
export const openHeaderMenu = async (page: Page) => {
|
||||
const toggleButton = page.getByTestId('header-menu-toggle');
|
||||
await expect(toggleButton).toBeVisible();
|
||||
|
||||
const isExpanded =
|
||||
(await toggleButton.getAttribute('aria-expanded')) === 'true';
|
||||
if (!isExpanded) {
|
||||
await toggleButton.click();
|
||||
}
|
||||
};
|
||||
|
||||
export const closeHeaderMenu = async (page: Page) => {
|
||||
const toggleButton = page.getByTestId('header-menu-toggle');
|
||||
await expect(toggleButton).toBeVisible();
|
||||
|
||||
const isExpanded =
|
||||
(await toggleButton.getAttribute('aria-expanded')) === 'true';
|
||||
if (isExpanded) {
|
||||
await toggleButton.click();
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleHeaderMenu = async (page: Page) => {
|
||||
const toggleButton = page.getByTestId('header-menu-toggle');
|
||||
await expect(toggleButton).toBeVisible();
|
||||
await toggleButton.click();
|
||||
};
|
||||
|
||||
export const createDoc = async (
|
||||
page: Page,
|
||||
docName: string,
|
||||
@@ -94,10 +123,7 @@ export const createDoc = async (
|
||||
|
||||
for (let i = 0; i < randomDocs.length; i++) {
|
||||
if (isMobile) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the header menu' })
|
||||
.getByText('menu')
|
||||
.click();
|
||||
await openHeaderMenu(page);
|
||||
}
|
||||
|
||||
await page
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Page, expect } from '@playwright/test';
|
||||
|
||||
import {
|
||||
BrowserName,
|
||||
closeHeaderMenu,
|
||||
openHeaderMenu,
|
||||
randomName,
|
||||
updateDocTitle,
|
||||
verifyDocName,
|
||||
@@ -15,10 +17,7 @@ export const createRootSubPage = async (
|
||||
isMobile = false,
|
||||
) => {
|
||||
if (isMobile) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the header menu' })
|
||||
.getByText('menu')
|
||||
.click();
|
||||
await openHeaderMenu(page);
|
||||
}
|
||||
|
||||
// Get response
|
||||
@@ -29,10 +28,7 @@ export const createRootSubPage = async (
|
||||
const subPageJson = (await response.json()) as { id: string };
|
||||
|
||||
if (isMobile) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the header menu' })
|
||||
.getByText('menu')
|
||||
.click();
|
||||
await openHeaderMenu(page);
|
||||
}
|
||||
|
||||
// Get doc tree
|
||||
@@ -44,13 +40,9 @@ export const createRootSubPage = async (
|
||||
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
|
||||
.first();
|
||||
await expect(subPageItem).toBeVisible();
|
||||
await subPageItem.click();
|
||||
|
||||
if (isMobile) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the header menu' })
|
||||
.getByText('close')
|
||||
.click();
|
||||
await closeHeaderMenu(page);
|
||||
}
|
||||
|
||||
// Update sub page name
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "4.0.0",
|
||||
"version": "4.3.0",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
@@ -15,7 +15,7 @@
|
||||
"test:ui::chromium": "yarn test:ui --project=chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.56.1",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.5",
|
||||
"eslint-plugin-docs": "*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "4.0.0",
|
||||
"version": "4.3.0",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
@@ -19,14 +19,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "2.0.3",
|
||||
"@blocknote/code-block": "0.42.3",
|
||||
"@blocknote/core": "0.42.3",
|
||||
"@blocknote/mantine": "0.42.3",
|
||||
"@blocknote/react": "0.42.3",
|
||||
"@blocknote/xl-docx-exporter": "0.42.3",
|
||||
"@blocknote/xl-multi-column": "0.42.3",
|
||||
"@blocknote/xl-odt-exporter": "0.42.3",
|
||||
"@blocknote/xl-pdf-exporter": "0.42.3",
|
||||
"@blocknote/code-block": "0.45.0",
|
||||
"@blocknote/core": "0.45.0",
|
||||
"@blocknote/mantine": "0.45.0",
|
||||
"@blocknote/react": "0.45.0",
|
||||
"@blocknote/xl-docx-exporter": "0.45.0",
|
||||
"@blocknote/xl-multi-column": "0.45.0",
|
||||
"@blocknote/xl-odt-exporter": "0.45.0",
|
||||
"@blocknote/xl-pdf-exporter": "0.45.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
@@ -35,14 +35,14 @@
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.30",
|
||||
"@fontsource/material-icons": "5.2.7",
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "0.18.0",
|
||||
"@hocuspocus/provider": "3.4.0",
|
||||
"@mantine/core": "8.3.9",
|
||||
"@mantine/hooks": "8.3.9",
|
||||
"@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.27.0",
|
||||
"@tanstack/react-query": "5.90.10",
|
||||
"@sentry/nextjs": "10.30.0",
|
||||
"@tanstack/react-query": "5.90.12",
|
||||
"@tiptap/extensions": "*",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
@@ -52,18 +52,18 @@
|
||||
"emoji-datasource-apple": "16.0.0",
|
||||
"emoji-mart": "5.6.0",
|
||||
"emoji-regex": "10.6.0",
|
||||
"i18next": "25.6.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.4",
|
||||
"posthog-js": "1.298.0",
|
||||
"next": "15.5.9",
|
||||
"posthog-js": "1.306.1",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.13.0",
|
||||
"react-dom": "*",
|
||||
"react-dropzone": "14.3.8",
|
||||
"react-i18next": "16.3.5",
|
||||
"react-i18next": "16.5.0",
|
||||
"react-intersection-observer": "10.0.0",
|
||||
"react-resizable-panels": "3.0.6",
|
||||
"react-select": "5.10.2",
|
||||
@@ -71,11 +71,11 @@
|
||||
"use-debounce": "10.0.6",
|
||||
"y-protocols": "1.0.6",
|
||||
"yjs": "*",
|
||||
"zustand": "5.0.8"
|
||||
"zustand": "5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.91.0",
|
||||
"@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.0",
|
||||
@@ -85,21 +85,21 @@
|
||||
"@types/node": "*",
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"@vitejs/plugin-react": "5.1.1",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"cross-env": "10.1.0",
|
||||
"dotenv": "17.2.3",
|
||||
"eslint-plugin-docs": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jsdom": "27.2.0",
|
||||
"jsdom": "27.3.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.6.2",
|
||||
"stylelint": "16.26.0",
|
||||
"prettier": "3.7.4",
|
||||
"stylelint": "16.26.1",
|
||||
"stylelint-config-standard": "39.0.1",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "4.0.13",
|
||||
"vite-tsconfig-paths": "6.0.1",
|
||||
"vitest": "4.0.15",
|
||||
"webpack": "5.103.0",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -1,5 +1,6 @@
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import Head from 'next/head';
|
||||
import Script from 'next/script';
|
||||
import { PropsWithChildren, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -87,6 +88,9 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
||||
<link rel="stylesheet" href={conf?.FRONTEND_CSS_URL} />
|
||||
</Head>
|
||||
)}
|
||||
{conf?.FRONTEND_JS_URL && (
|
||||
<Script src={conf?.FRONTEND_JS_URL} strategy="afterInteractive" />
|
||||
)}
|
||||
<AnalyticsProvider>
|
||||
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
|
||||
{children}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface ConfigResponse {
|
||||
ENVIRONMENT: string;
|
||||
FRONTEND_CSS_URL?: string;
|
||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED?: boolean;
|
||||
FRONTEND_JS_URL?: string;
|
||||
FRONTEND_THEME?: Theme;
|
||||
LANGUAGES: [string, string][];
|
||||
LANGUAGE_CODE: string;
|
||||
|
||||
@@ -44,7 +44,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
|
||||
if (config?.FRONTEND_HOMEPAGE_FEATURE_ENABLED) {
|
||||
if (pathname !== HOME_URL) {
|
||||
setIsRedirecting(true);
|
||||
void replace(HOME_URL).then(() => setIsRedirecting(false));
|
||||
window.location.replace(HOME_URL);
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { baseApiUrl } from '@/api';
|
||||
|
||||
export const HOME_URL = '/home';
|
||||
export const HOME_URL = '/home/';
|
||||
export const LOGIN_URL = `${baseApiUrl()}authenticate/`;
|
||||
export const LOGOUT_URL = `${baseApiUrl()}logout/`;
|
||||
export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth';
|
||||
|
||||
+47
-31
@@ -6,6 +6,7 @@ import {
|
||||
defaultInlineContentSpecs,
|
||||
withPageBreak,
|
||||
} from '@blocknote/core';
|
||||
import { CommentsExtension } from '@blocknote/core/comments';
|
||||
import '@blocknote/core/fonts/inter.css';
|
||||
import * as locales from '@blocknote/core/locales';
|
||||
import { BlockNoteView } from '@blocknote/mantine';
|
||||
@@ -15,13 +16,13 @@ import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
import type { Awareness } from 'y-protocols/awareness';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, TextErrors } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Doc, useProviderStore } from '@/docs/doc-management';
|
||||
import { avatarUrlFromName, useAuth } from '@/features/auth';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import {
|
||||
useHeadings,
|
||||
@@ -84,10 +85,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
const { setEditor } = useEditorStore();
|
||||
const { t } = useTranslation();
|
||||
const { themeTokens } = useCunninghamTheme();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { isSynced: isConnectedToCollabServer } = useProviderStore();
|
||||
const refEditorContainer = useRef<HTMLDivElement>(null);
|
||||
const canSeeComment = doc.abilities.comment && isDesktop;
|
||||
const canSeeComment = doc.abilities.comment;
|
||||
// Determine if comments should be visible in the UI
|
||||
const showComments = canSeeComment;
|
||||
|
||||
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
|
||||
const { i18n } = useTranslation();
|
||||
@@ -99,7 +101,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
const cursorName = collabName || t('Anonymous');
|
||||
const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity';
|
||||
|
||||
const threadStore = useComments(doc.id, canSeeComment, user);
|
||||
const { resolveUsers, threadStore } = useComments(
|
||||
doc.id,
|
||||
canSeeComment,
|
||||
user,
|
||||
);
|
||||
|
||||
const currentUserAvatarUrl = useMemo(() => {
|
||||
if (canSeeComment) {
|
||||
@@ -110,7 +116,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
const editor: DocsBlockNoteEditor = useCreateBlockNote(
|
||||
{
|
||||
collaboration: {
|
||||
provider: provider,
|
||||
provider: provider as { awareness?: Awareness | undefined },
|
||||
fragment: provider.document.getXmlFragment('document-store'),
|
||||
user: {
|
||||
name: cursorName,
|
||||
@@ -154,28 +160,34 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
},
|
||||
showCursorLabels: showCursorLabels as 'always' | 'activity',
|
||||
},
|
||||
comments: { threadStore },
|
||||
dictionary: {
|
||||
...locales[lang as keyof typeof locales],
|
||||
multi_column:
|
||||
multiColumnLocales?.[lang as keyof typeof multiColumnLocales],
|
||||
...(multiColumnLocales && {
|
||||
multi_column:
|
||||
multiColumnLocales[lang as keyof typeof multiColumnLocales],
|
||||
}),
|
||||
},
|
||||
resolveUsers: async (userIds) => {
|
||||
return Promise.resolve(
|
||||
userIds.map((encodedURIUserId) => {
|
||||
const fullName = decodeURIComponent(encodedURIUserId);
|
||||
pasteHandler: ({ event, defaultPasteHandler }) => {
|
||||
// Get clipboard data
|
||||
const blocknoteData = event.clipboardData?.getData('blocknote/html');
|
||||
|
||||
return {
|
||||
id: encodedURIUserId,
|
||||
username: fullName || t('Anonymous'),
|
||||
avatarUrl: avatarUrlFromName(
|
||||
fullName,
|
||||
themeTokens?.font?.families?.base,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
/**
|
||||
* When pasting comments, the data-bn-thread-id
|
||||
* attribute is present in the clipboard data.
|
||||
* This indicates that the pasted content contains comments.
|
||||
* But if the content with comments comes from another document,
|
||||
* it will create orphaned comments that are not linked to this document
|
||||
* and create errors.
|
||||
* To avoid this, we refresh the threads to ensure that only comments
|
||||
* relevant to the current document are displayed.
|
||||
*/
|
||||
if (blocknoteData && blocknoteData.includes('data-bn-thread-id')) {
|
||||
void threadStore.refreshThreads();
|
||||
}
|
||||
|
||||
return defaultPasteHandler();
|
||||
},
|
||||
extensions: [CommentsExtension({ threadStore, resolveUsers })],
|
||||
tables: {
|
||||
splitCells: true,
|
||||
cellBackgroundColor: true,
|
||||
@@ -185,7 +197,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
uploadFile,
|
||||
schema: blockNoteSchema,
|
||||
},
|
||||
[cursorName, lang, provider, uploadFile, threadStore],
|
||||
[cursorName, lang, provider, uploadFile, threadStore, resolveUsers],
|
||||
);
|
||||
|
||||
useHeadings(editor);
|
||||
@@ -207,7 +219,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
ref={refEditorContainer}
|
||||
$css={css`
|
||||
${cssEditor};
|
||||
${cssComments(canSeeComment, currentUserAvatarUrl)}
|
||||
${cssComments(showComments, currentUserAvatarUrl)}
|
||||
`}
|
||||
>
|
||||
{errorAttachment && (
|
||||
@@ -225,7 +237,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
formattingToolbar={false}
|
||||
slashMenu={false}
|
||||
theme="light"
|
||||
comments={canSeeComment}
|
||||
comments={showComments}
|
||||
aria-label={t('Document editor')}
|
||||
>
|
||||
<BlockNoteSuggestionMenu />
|
||||
@@ -246,7 +258,7 @@ export const BlockNoteReader = ({
|
||||
}: BlockNoteReaderProps) => {
|
||||
const { user } = useAuth();
|
||||
const { setEditor } = useEditorStore();
|
||||
const threadStore = useComments(docId, false, user);
|
||||
const { threadStore } = useComments(docId, false, user);
|
||||
const { t } = useTranslation();
|
||||
const editor = useCreateBlockNote(
|
||||
{
|
||||
@@ -259,12 +271,16 @@ export const BlockNoteReader = ({
|
||||
provider: undefined,
|
||||
},
|
||||
schema: blockNoteSchema,
|
||||
comments: { threadStore },
|
||||
resolveUsers: async () => {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
extensions: [
|
||||
CommentsExtension({
|
||||
threadStore,
|
||||
resolveUsers: async () => {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
[initialContent],
|
||||
[initialContent, threadStore],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import { combineByGroup, filterSuggestionItems } from '@blocknote/core';
|
||||
import { combineByGroup } from '@blocknote/core';
|
||||
import { filterSuggestionItems } from '@blocknote/core/extensions';
|
||||
import {
|
||||
DefaultReactSuggestionItem,
|
||||
SuggestionMenuController,
|
||||
|
||||
+41
-34
@@ -6,24 +6,25 @@
|
||||
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx
|
||||
*/
|
||||
|
||||
import {
|
||||
BlockSchema,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
blockHasType,
|
||||
} from '@blocknote/core';
|
||||
import { blockHasType } from '@blocknote/core';
|
||||
import {
|
||||
useBlockNoteEditor,
|
||||
useComponentsContext,
|
||||
useDictionary,
|
||||
useSelectedBlocks,
|
||||
useEditorState,
|
||||
} from '@blocknote/react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { RiDownload2Fill } from 'react-icons/ri';
|
||||
|
||||
import { downloadFile, exportResolveFileUrl } from '@/docs/doc-export';
|
||||
import { isSafeUrl } from '@/utils/url';
|
||||
|
||||
import {
|
||||
DocsBlockSchema,
|
||||
DocsInlineContentSchema,
|
||||
DocsStyleSchema,
|
||||
} from '../../types';
|
||||
|
||||
export const FileDownloadButton = ({
|
||||
open,
|
||||
}: {
|
||||
@@ -33,36 +34,43 @@ export const FileDownloadButton = ({
|
||||
const Components = useComponentsContext();
|
||||
|
||||
const editor = useBlockNoteEditor<
|
||||
BlockSchema,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
DocsBlockSchema,
|
||||
DocsInlineContentSchema,
|
||||
DocsStyleSchema
|
||||
>();
|
||||
|
||||
const selectedBlocks = useSelectedBlocks(editor);
|
||||
const fileBlock = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) => {
|
||||
const selectedBlocks = editor.getSelection()?.blocks || [
|
||||
editor.getTextCursorPosition().block,
|
||||
];
|
||||
|
||||
const fileBlock = useMemo(() => {
|
||||
// Checks if only one block is selected.
|
||||
if (selectedBlocks.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
if (selectedBlocks.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const block = selectedBlocks[0];
|
||||
const block = selectedBlocks[0];
|
||||
|
||||
if (
|
||||
!blockHasType(block, editor, block.type, {
|
||||
url: 'string',
|
||||
name: 'string',
|
||||
})
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
blockHasType(block, editor, block.type, { url: 'string', name: 'string' })
|
||||
) {
|
||||
return block;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [editor, selectedBlocks]);
|
||||
},
|
||||
});
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
if (fileBlock && fileBlock.props.url) {
|
||||
editor.focus();
|
||||
|
||||
const url = fileBlock.props.url as string;
|
||||
const name = fileBlock.props.name as string | undefined;
|
||||
const name = fileBlock.props.name as string;
|
||||
|
||||
/**
|
||||
* If not hosted on our domain, means not a file uploaded by the user,
|
||||
@@ -110,19 +118,18 @@ export const FileDownloadButton = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const blockType = fileBlock.type as string;
|
||||
const tooltipText =
|
||||
dict.formatting_toolbar.file_download.tooltip[blockType] ||
|
||||
dict.formatting_toolbar.file_download.tooltip['file'];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Components.FormattingToolbar.Button
|
||||
className="bn-button --docs--editor-file-download-button"
|
||||
data-test="downloadfile"
|
||||
label={
|
||||
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
|
||||
dict.formatting_toolbar.file_download.tooltip['file']
|
||||
}
|
||||
mainTooltip={
|
||||
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
|
||||
dict.formatting_toolbar.file_download.tooltip['file']
|
||||
}
|
||||
label={tooltipText}
|
||||
mainTooltip={tooltipText}
|
||||
icon={<RiDownload2Fill />}
|
||||
onClick={() => void onClick()}
|
||||
/>
|
||||
|
||||
@@ -90,7 +90,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
}
|
||||
}, [isProviderReady, setIsSkeletonVisible]);
|
||||
|
||||
if (!isProviderReady) {
|
||||
if (!isProviderReady || provider?.configuration.name !== doc.id) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
|
||||
+26
-2
@@ -1,6 +1,14 @@
|
||||
/**
|
||||
* This file is adapted from BlockNote's AddCommentButton component
|
||||
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx
|
||||
*/
|
||||
|
||||
import { CommentsExtension } from '@blocknote/core/comments';
|
||||
import { FormattingToolbarExtension } from '@blocknote/core/extensions';
|
||||
import {
|
||||
useBlockNoteEditor,
|
||||
useComponentsContext,
|
||||
useExtension,
|
||||
useSelectedBlocks,
|
||||
} from '@blocknote/react';
|
||||
import { useMemo } from 'react';
|
||||
@@ -22,6 +30,10 @@ export const CommentToolbarButton = () => {
|
||||
const { currentDoc } = useDocStore();
|
||||
const { t } = useTranslation();
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const comments = useExtension('comments') as unknown as ReturnType<
|
||||
ReturnType<typeof CommentsExtension>
|
||||
>;
|
||||
const { store } = useExtension(FormattingToolbarExtension);
|
||||
|
||||
const editor = useBlockNoteEditor<
|
||||
DocsBlockSchema,
|
||||
@@ -35,7 +47,18 @@ export const CommentToolbarButton = () => {
|
||||
return !!selectedBlocks.find((block) => block.content !== undefined);
|
||||
}, [selectedBlocks]);
|
||||
|
||||
const focusOnInputThread = () => {
|
||||
// Use setTimeout to ensure the DOM has been updated with the new comment
|
||||
setTimeout(() => {
|
||||
const threadElement = document.querySelector<HTMLElement>(
|
||||
'.bn-thread .bn-editor',
|
||||
);
|
||||
threadElement?.focus();
|
||||
}, 400);
|
||||
};
|
||||
|
||||
if (
|
||||
!comments ||
|
||||
!show ||
|
||||
!editor.isEditable ||
|
||||
!Components ||
|
||||
@@ -49,8 +72,9 @@ export const CommentToolbarButton = () => {
|
||||
<Components.Generic.Toolbar.Button
|
||||
className="bn-button"
|
||||
onClick={() => {
|
||||
editor.comments?.startPendingComment();
|
||||
editor.formattingToolbar.closeMenu();
|
||||
comments.startPendingComment();
|
||||
store.setState(false);
|
||||
focusOnInputThread();
|
||||
}}
|
||||
aria-haspopup="dialog"
|
||||
data-test="comment-toolbar-button"
|
||||
|
||||
+60
-12
@@ -37,7 +37,8 @@ export class DocsThreadStore extends ThreadStore {
|
||||
if (docAuth.canSee) {
|
||||
this.awareness = awareness;
|
||||
this.awareness?.on('update', this.onAwarenessUpdate);
|
||||
void this.refreshThreads();
|
||||
|
||||
this.refreshThreads();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,9 +99,9 @@ export class DocsThreadStore extends ThreadStore {
|
||||
|
||||
// If we know the threadId, schedule a targeted refresh. Otherwise, fall back to full refresh.
|
||||
if (ping.threadId) {
|
||||
await this.refreshThread(ping.threadId);
|
||||
void this.refreshThread(ping.threadId);
|
||||
} else {
|
||||
await this.refreshThreads();
|
||||
this.refreshThreads();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -117,6 +118,21 @@ export class DocsThreadStore extends ThreadStore {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to the bottom of a thread modal
|
||||
* @param threadId
|
||||
*/
|
||||
private scrollToBottomOfThread() {
|
||||
// Use setTimeout to ensure the DOM has been updated with the new comment
|
||||
setTimeout(() => {
|
||||
const threadElement = document.querySelector('.bn-thread');
|
||||
threadElement?.scrollBy({
|
||||
top: threadElement.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies all subscribers about the current thread state
|
||||
*/
|
||||
@@ -185,7 +201,7 @@ export class DocsThreadStore extends ThreadStore {
|
||||
const { editor } = useEditorStore.getState();
|
||||
|
||||
// Should not happen
|
||||
if (!editor) {
|
||||
if (!editor || !editor._tiptapEditor?.view?.dom) {
|
||||
console.warn('Editor to add thread not ready');
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -196,6 +212,17 @@ export class DocsThreadStore extends ThreadStore {
|
||||
.setMark?.('comment', { orphan: false, threadId })
|
||||
.run?.();
|
||||
|
||||
/**
|
||||
* We have some issues with mobiles and the formatting toolbar reopening
|
||||
* after adding a comment, so we restore the cursor position here.
|
||||
* By restoring the cursor position at the head of the selection,
|
||||
* it will automatically close the formatting toolbar.
|
||||
*/
|
||||
const cursorPos = editor._tiptapEditor?.state.selection.head;
|
||||
if (cursorPos !== undefined) {
|
||||
editor._tiptapEditor?.commands.setTextSelection(cursorPos);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
@@ -225,6 +252,7 @@ export class DocsThreadStore extends ThreadStore {
|
||||
this.upsertClientThreadData(threadData);
|
||||
this.notifySubscribers();
|
||||
this.ping(threadData.id);
|
||||
|
||||
return threadData;
|
||||
};
|
||||
|
||||
@@ -265,7 +293,7 @@ export class DocsThreadStore extends ThreadStore {
|
||||
}),
|
||||
);
|
||||
|
||||
await this.refreshThreads();
|
||||
this.refreshThreads();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -283,7 +311,17 @@ export class DocsThreadStore extends ThreadStore {
|
||||
this.notifySubscribers();
|
||||
}
|
||||
|
||||
public async refreshThreads(): Promise<void> {
|
||||
public refreshThreads() {
|
||||
this.initThreads().catch((e) => {
|
||||
if (!(e instanceof APIError) || e.status !== 401) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
public async initThreads(): Promise<void> {
|
||||
const response = await fetchAPI(`documents/${this.docId}/threads/`, {
|
||||
method: 'GET',
|
||||
});
|
||||
@@ -345,6 +383,10 @@ export class DocsThreadStore extends ThreadStore {
|
||||
await this.refreshThread(threadId);
|
||||
}
|
||||
this.ping(threadId);
|
||||
|
||||
// Auto-scroll to bottom of thread after adding comment
|
||||
this.scrollToBottomOfThread();
|
||||
|
||||
return serverCommentToClientComment(comment);
|
||||
};
|
||||
|
||||
@@ -405,10 +447,20 @@ export class DocsThreadStore extends ThreadStore {
|
||||
// Optimistically remove the comment locally if we have the thread
|
||||
const existing = this.threads.get(threadId);
|
||||
if (existing) {
|
||||
const updatedComments = existing.comments.filter(
|
||||
(c) => c.id !== commentId,
|
||||
);
|
||||
|
||||
// If this was the last comment, delete the thread
|
||||
if (updatedComments.length === 0) {
|
||||
await this.deleteThread({ threadId });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated: ClientThreadData = {
|
||||
...existing,
|
||||
updatedAt: new Date(),
|
||||
comments: existing.comments.filter((c) => c.id !== commentId),
|
||||
comments: updatedComments,
|
||||
};
|
||||
this.upsertClientThreadData(updated);
|
||||
this.notifySubscribers();
|
||||
@@ -419,10 +471,6 @@ export class DocsThreadStore extends ThreadStore {
|
||||
this.ping(threadId);
|
||||
};
|
||||
|
||||
/**
|
||||
* UI not implemented
|
||||
* @param _options
|
||||
*/
|
||||
public deleteThread = async (_options: { threadId: string }) => {
|
||||
const response = await fetchAPI(
|
||||
`documents/${this.docId}/threads/${_options.threadId}/`,
|
||||
@@ -459,7 +507,7 @@ export class DocsThreadStore extends ThreadStore {
|
||||
);
|
||||
}
|
||||
|
||||
await this.refreshThreads();
|
||||
this.refreshThreads();
|
||||
this.ping(threadId);
|
||||
};
|
||||
|
||||
|
||||
+27
-10
@@ -13,6 +13,10 @@ export const cssComments = (
|
||||
background: ${canSeeComment ? '#EDB40066' : 'transparent'};
|
||||
color: var(--c--globals--colors--gray-700);
|
||||
}
|
||||
|
||||
[data-show-selection] {
|
||||
color: HighlightText;
|
||||
}
|
||||
}
|
||||
|
||||
em-emoji-picker {
|
||||
@@ -22,13 +26,16 @@ export const cssComments = (
|
||||
|
||||
// Thread modal
|
||||
.bn-thread {
|
||||
width: 400px;
|
||||
width: 100%;
|
||||
min-width: calc(min(400px, 90vw));
|
||||
max-width: calc(min(400px, 90vw));
|
||||
max-height: calc(min(500px, 60vh));
|
||||
padding: 8px;
|
||||
box-shadow: 0px 6px 18px 0px #00001229;
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
gap: 0;
|
||||
overflow: auto;
|
||||
max-height: 500px;
|
||||
|
||||
.bn-default-styles {
|
||||
font-family: var(--c--globals--font--families--base);
|
||||
@@ -113,7 +120,7 @@ export const cssComments = (
|
||||
}
|
||||
|
||||
& svg {
|
||||
color: var(--c--globals--colors--info-600);
|
||||
color: var(--c--contextuals--background--semantic--brand--primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,15 +137,23 @@ export const cssComments = (
|
||||
padding-inline: var(--c--globals--spacings--st);
|
||||
|
||||
&[data-test='save'] {
|
||||
border: 1px solid var(--c--globals--colors--info-600);
|
||||
background: var(--c--globals--colors--info-600);
|
||||
color: white;
|
||||
border: 1px solid
|
||||
var(--c--contextuals--background--semantic--brand--primary);
|
||||
background: var(
|
||||
--c--contextuals--background--semantic--brand--primary
|
||||
);
|
||||
color: var(
|
||||
--c--contextuals--content--semantic--brand--on-brand
|
||||
);
|
||||
}
|
||||
|
||||
&[data-test='cancel'] {
|
||||
background: white;
|
||||
border: 1px solid var(--c--globals--colors--gray-300);
|
||||
color: var(--c--globals--colors--info-600);
|
||||
border: 1px solid
|
||||
var(--c--contextuals--border--surface--primary);
|
||||
color: var(
|
||||
--c--contextuals--background--semantic--brand--primary
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,7 +195,9 @@ export const cssComments = (
|
||||
|
||||
button {
|
||||
font-size: 0;
|
||||
background: var(--c--globals--colors--info-600);
|
||||
background: var(
|
||||
--c--contextuals--background--semantic--brand--primary
|
||||
);
|
||||
width: var(--c--globals--spacings--md);
|
||||
height: var(--c--globals--spacings--md);
|
||||
padding: var(--c--globals--spacings--0);
|
||||
@@ -193,7 +210,7 @@ export const cssComments = (
|
||||
content: 'arrow_upward_alt';
|
||||
font-family: 'Material Symbols Outlined Variable', sans-serif;
|
||||
font-size: 18px;
|
||||
color: var(--c--globals--colors--gray-100);
|
||||
color: var(--c--contextuals--content--semantic--brand--on-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-3
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { User } from '@/features/auth';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { User, avatarUrlFromName } from '@/features/auth';
|
||||
import { Doc, useProviderStore } from '@/features/docs/doc-management';
|
||||
|
||||
import { DocsThreadStore } from './DocsThreadStore';
|
||||
@@ -12,6 +14,9 @@ export function useComments(
|
||||
user: User | null | undefined,
|
||||
) {
|
||||
const { provider } = useProviderStore();
|
||||
const { t } = useTranslation();
|
||||
const { themeTokens } = useCunninghamTheme();
|
||||
|
||||
const threadStore = useMemo(() => {
|
||||
return new DocsThreadStore(
|
||||
docId,
|
||||
@@ -29,5 +34,25 @@ export function useComments(
|
||||
};
|
||||
}, [threadStore]);
|
||||
|
||||
return threadStore;
|
||||
const resolveUsers = useCallback(
|
||||
async (userIds: string[]) => {
|
||||
return Promise.resolve(
|
||||
userIds.map((encodedURIUserId) => {
|
||||
const fullName = decodeURIComponent(encodedURIUserId);
|
||||
|
||||
return {
|
||||
id: encodedURIUserId,
|
||||
username: fullName || t('Anonymous'),
|
||||
avatarUrl: avatarUrlFromName(
|
||||
fullName,
|
||||
themeTokens?.font?.families?.base,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[t, themeTokens?.font?.families?.base],
|
||||
);
|
||||
|
||||
return { threadStore, resolveUsers };
|
||||
}
|
||||
|
||||
+32
-25
@@ -5,8 +5,8 @@ import {
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
defaultProps,
|
||||
insertOrUpdateBlock,
|
||||
} from '@blocknote/core';
|
||||
import { insertOrUpdateBlockForSlashMenu } from '@blocknote/core/extensions';
|
||||
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
@@ -97,34 +97,41 @@ const CalloutComponent = ({
|
||||
`}
|
||||
>
|
||||
<CalloutBlockStyle />
|
||||
<BoxButton
|
||||
contentEditable={false}
|
||||
onClick={toggleEmojiPicker}
|
||||
$css={css`
|
||||
font-size: 1.125rem;
|
||||
cursor: ${isEditable ? 'pointer' : 'default'};
|
||||
${isEditable &&
|
||||
`
|
||||
<Box $position="relative">
|
||||
<BoxButton
|
||||
contentEditable={false}
|
||||
onClick={toggleEmojiPicker}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && openEmojiPicker) {
|
||||
setOpenEmojiPicker(false);
|
||||
}
|
||||
}}
|
||||
$css={css`
|
||||
font-size: 1.125rem;
|
||||
cursor: ${isEditable ? 'pointer' : 'default'};
|
||||
${isEditable &&
|
||||
`
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
`}
|
||||
$align="center"
|
||||
$width="28px"
|
||||
$radius="4px"
|
||||
>
|
||||
{block.props.emoji}
|
||||
</BoxButton>
|
||||
`}
|
||||
$align="center"
|
||||
$width="28px"
|
||||
$radius="4px"
|
||||
>
|
||||
{block.props.emoji}
|
||||
</BoxButton>
|
||||
|
||||
{openEmojiPicker && (
|
||||
<EmojiPicker
|
||||
emojiData={emojidata}
|
||||
onClickOutside={onClickOutside}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
withOverlay={true}
|
||||
/>
|
||||
)}
|
||||
{openEmojiPicker && (
|
||||
<EmojiPicker
|
||||
emojiData={emojidata}
|
||||
onClickOutside={onClickOutside}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
withOverlay={true}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box as="p" className="inline-content" ref={contentRef} />
|
||||
</Box>
|
||||
);
|
||||
@@ -156,7 +163,7 @@ export const getCalloutReactSlashMenuItems = (
|
||||
key: 'callout',
|
||||
title: t('Callout'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, {
|
||||
insertOrUpdateBlockForSlashMenu(editor, {
|
||||
type: 'callout',
|
||||
});
|
||||
},
|
||||
|
||||
+70
-17
@@ -4,8 +4,8 @@ import {
|
||||
BlockNoteEditor,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
insertOrUpdateBlock,
|
||||
} from '@blocknote/core';
|
||||
import { insertOrUpdateBlockForSlashMenu } from '@blocknote/core/extensions';
|
||||
import * as locales from '@blocknote/core/locales';
|
||||
import {
|
||||
AddFileButton,
|
||||
@@ -13,12 +13,13 @@ import {
|
||||
createReactBlockSpec,
|
||||
} from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
|
||||
import { Box, Icon } from '@/components';
|
||||
import { Box, Icon, Loading } from '@/components';
|
||||
|
||||
import { ANALYZE_URL } from '../../conf';
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
|
||||
const PDFBlockStyle = createGlobalStyle`
|
||||
@@ -66,6 +67,9 @@ const PdfBlockComponent = ({
|
||||
const pdfUrl = block.props.url;
|
||||
const { i18n, t } = useTranslation();
|
||||
const lang = i18n.resolvedLanguage;
|
||||
const [isPDFContent, setIsPDFContent] = useState<boolean | null>(null);
|
||||
const [isPDFContentLoading, setIsPDFContentLoading] =
|
||||
useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (lang && locales[lang as keyof typeof locales]) {
|
||||
@@ -82,9 +86,55 @@ const PdfBlockComponent = ({
|
||||
}
|
||||
}, [lang, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pdfUrl || pdfUrl.includes(ANALYZE_URL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validatePDFContent = async () => {
|
||||
setIsPDFContentLoading(true);
|
||||
try {
|
||||
const response = await fetch(pdfUrl, {
|
||||
credentials: 'include',
|
||||
});
|
||||
const contentType = response.headers.get('content-type');
|
||||
|
||||
if (response.ok && contentType?.includes('application/pdf')) {
|
||||
setIsPDFContent(true);
|
||||
} else {
|
||||
setIsPDFContent(false);
|
||||
}
|
||||
} catch {
|
||||
setIsPDFContent(false);
|
||||
} finally {
|
||||
setIsPDFContentLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void validatePDFContent();
|
||||
}, [pdfUrl]);
|
||||
|
||||
return (
|
||||
<Box ref={contentRef} className="bn-file-block-content-wrapper">
|
||||
<PDFBlockStyle />
|
||||
{isPDFContentLoading && <Loading />}
|
||||
{!isPDFContentLoading && isPDFContent !== null && !isPDFContent && (
|
||||
<Box
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$color="#666"
|
||||
$background="#f5f5f5"
|
||||
$border="1px solid #ddd"
|
||||
$height="300px"
|
||||
$css={css`
|
||||
text-align: center;
|
||||
`}
|
||||
contentEditable={false}
|
||||
onClick={() => editor.setTextCursorPosition(block)}
|
||||
>
|
||||
{t('Invalid or missing PDF file.')}
|
||||
</Box>
|
||||
)}
|
||||
<ResizableFileBlockWrapper
|
||||
buttonIcon={
|
||||
<Icon iconName="upload" $size="24px" $css="line-height: normal;" />
|
||||
@@ -92,18 +142,21 @@ const PdfBlockComponent = ({
|
||||
block={block as unknown as FileBlockBlock}
|
||||
editor={editor as unknown as FileBlockEditor}
|
||||
>
|
||||
<Box
|
||||
className="bn-visual-media"
|
||||
role="presentation"
|
||||
as="embed"
|
||||
$width="100%"
|
||||
$height="450px"
|
||||
type="application/pdf"
|
||||
src={pdfUrl}
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
onClick={() => editor.setTextCursorPosition(block)}
|
||||
/>
|
||||
{!isPDFContentLoading && isPDFContent && (
|
||||
<Box
|
||||
as="embed"
|
||||
className="bn-visual-media"
|
||||
role="presentation"
|
||||
$width="100%"
|
||||
$height="450px"
|
||||
type="application/pdf"
|
||||
src={pdfUrl}
|
||||
aria-label={block.props.name || t('PDF document')}
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
onClick={() => editor.setTextCursorPosition(block)}
|
||||
/>
|
||||
)}
|
||||
</ResizableFileBlockWrapper>
|
||||
</Box>
|
||||
);
|
||||
@@ -139,7 +192,7 @@ export const getPdfReactSlashMenuItems = (
|
||||
{
|
||||
title: t('PDF'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, { type: 'pdf' });
|
||||
insertOrUpdateBlockForSlashMenu(editor, { type: 'pdf' });
|
||||
},
|
||||
aliases: [t('pdf'), t('document'), t('embed'), t('file')],
|
||||
group,
|
||||
|
||||
+6
-2
@@ -79,10 +79,12 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
|
||||
|
||||
return (
|
||||
<BoxButton
|
||||
as="span"
|
||||
className="--docs--interlinking-link-inline-content"
|
||||
onClick={handleClick}
|
||||
draggable="false"
|
||||
$css={css`
|
||||
display: contents;
|
||||
display: inline;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
& svg {
|
||||
@@ -91,7 +93,9 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
&:hover {
|
||||
background-color: ${colorsTokens['gray-100']};
|
||||
background-color: var(
|
||||
--c--contextuals--background--semantic--contextual--primary
|
||||
);
|
||||
}
|
||||
transition: background-color var(--c--globals--transitions--duration)
|
||||
var(--c--globals--transitions--ease-out);
|
||||
|
||||
+1
@@ -171,6 +171,7 @@ export const SearchPage = ({
|
||||
{trigger}
|
||||
<Box
|
||||
as="input"
|
||||
name="doc-search-input"
|
||||
$padding={{ left: '3px' }}
|
||||
$css={inputStyle}
|
||||
ref={inputRef}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const ANALYZE_URL = 'media-check';
|
||||
@@ -7,6 +7,11 @@ export const useHeadings = (editor: DocsBlockNoteEditor) => {
|
||||
const { setHeadings, resetHeadings } = useHeadingStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Check if editor and its view are mounted before accessing document
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHeadings(editor);
|
||||
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
@@ -7,6 +7,11 @@ export const useShortcuts = (
|
||||
el: HTMLDivElement | null,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
// Check if editor and its view are mounted
|
||||
if (!editor || !el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === '@' && editor?.isFocused()) {
|
||||
const selection = window.getSelection();
|
||||
@@ -32,10 +37,6 @@ export const useShortcuts = (
|
||||
}
|
||||
};
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { backendUrl } from '@/api';
|
||||
|
||||
import { useCreateDocAttachment } from '../api';
|
||||
import { ANALYZE_URL } from '../conf';
|
||||
import { DocsBlockNoteEditor } from '../types';
|
||||
|
||||
export const useUploadFile = (docId: string) => {
|
||||
@@ -46,7 +47,6 @@ export const useUploadFile = (docId: string) => {
|
||||
* @param editor
|
||||
*/
|
||||
export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
|
||||
const ANALYZE_URL = 'media-check';
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
@@ -95,7 +95,12 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const imagesBlocks = editor?.document.filter(
|
||||
// Check if editor and its view are mounted before accessing document
|
||||
if (!editor?.document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imagesBlocks = editor.document.filter(
|
||||
(block) =>
|
||||
block.type === 'image' && block.props.url.includes(ANALYZE_URL),
|
||||
);
|
||||
@@ -110,6 +115,11 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
|
||||
* block to show analyzing status
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Check if editor and its view are mounted before setting up handlers
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.onUploadEnd((blockId) => {
|
||||
if (!blockId) {
|
||||
return;
|
||||
|
||||
@@ -28,6 +28,11 @@ export interface UseHeadingStore {
|
||||
export const useHeadingStore = create<UseHeadingStore>((set, get) => ({
|
||||
headings: [],
|
||||
setHeadings: (editor) => {
|
||||
// Check if editor and its view are mounted before accessing document
|
||||
if (!editor?.document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headingBlocks = editor?.document
|
||||
.filter(
|
||||
(block) =>
|
||||
|
||||
@@ -16,12 +16,12 @@ describe('useModuleExport', () => {
|
||||
const Export = await import('@/features/docs/doc-export/');
|
||||
|
||||
expect(Export.default).toBeUndefined();
|
||||
}, 10000);
|
||||
}, 15000);
|
||||
|
||||
it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
|
||||
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
|
||||
const Export = await import('@/features/docs/doc-export/');
|
||||
|
||||
expect(Export.default).toHaveProperty('ModalExport');
|
||||
});
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { deriveMediaFilename } from '../utils';
|
||||
|
||||
describe('deriveMediaFilename', () => {
|
||||
test('uses last URL segment when src is a valid URL', () => {
|
||||
const result = deriveMediaFilename({
|
||||
src: 'https://example.com/path/video.mp4',
|
||||
index: 0,
|
||||
blob: new Blob([], { type: 'video/mp4' }),
|
||||
});
|
||||
expect(result).toBe('1-video.mp4');
|
||||
});
|
||||
|
||||
test('handles URLs with query/hash and keeps the last segment', () => {
|
||||
const result = deriveMediaFilename({
|
||||
src: 'https://site.com/assets/file.name.svg?x=1#test',
|
||||
index: 0,
|
||||
blob: new Blob([], { type: 'image/svg+xml' }),
|
||||
});
|
||||
expect(result).toBe('1-file.name.svg');
|
||||
});
|
||||
|
||||
test('handles relative URLs using last segment', () => {
|
||||
const result = deriveMediaFilename({
|
||||
src: 'not a valid url',
|
||||
index: 0,
|
||||
blob: new Blob([], { type: 'image/png' }),
|
||||
});
|
||||
// "not a valid url" becomes a relative URL, so we get the last segment
|
||||
expect(result).toBe('1-not%20a%20valid%20url.png');
|
||||
});
|
||||
|
||||
test('data URLs always use media-{index+1}', () => {
|
||||
const result = deriveMediaFilename({
|
||||
src: 'data:image/png;base64,xxx',
|
||||
index: 0,
|
||||
blob: new Blob([], { type: 'image/png' }),
|
||||
});
|
||||
expect(result).toBe('media-1.png');
|
||||
});
|
||||
|
||||
test('adds extension from MIME when baseName has no extension', () => {
|
||||
const result = deriveMediaFilename({
|
||||
src: 'https://a.com/abc',
|
||||
index: 0,
|
||||
blob: new Blob([], { type: 'image/webp' }),
|
||||
});
|
||||
expect(result).toBe('1-abc.webp');
|
||||
});
|
||||
|
||||
test('does not override extension if baseName already contains one', () => {
|
||||
const result = deriveMediaFilename({
|
||||
src: 'https://a.com/image.png',
|
||||
index: 0,
|
||||
blob: new Blob([], { type: 'image/jpeg' }),
|
||||
});
|
||||
expect(result).toBe('1-image.png');
|
||||
});
|
||||
|
||||
test('handles complex MIME types (e.g., audio/mpeg)', () => {
|
||||
const result = deriveMediaFilename({
|
||||
src: 'https://a.com/song',
|
||||
index: 1,
|
||||
blob: new Blob([], { type: 'audio/mpeg' }),
|
||||
});
|
||||
expect(result).toBe('2-song.mpeg');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,569 @@
|
||||
/* Reset and Base Styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: inter, 'roboto flex variable', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
line-height: normal;
|
||||
color: #25252f;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 868px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 24px 54px 32px;
|
||||
background: #fff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Editor Container Styles */
|
||||
.--docs--editor-container {
|
||||
width: 100%;
|
||||
padding: 24px 0 32px;
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
color: #25252f;
|
||||
}
|
||||
|
||||
/* Block Spacing */
|
||||
.bn-block-outer {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bn-block-content {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
line-height: 1.5;
|
||||
color: #454558;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
line-height: 45px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 27px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.bn-inline-content {
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: #5d5d70;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #454558;
|
||||
}
|
||||
|
||||
/* Text Formatting */
|
||||
strong,
|
||||
b {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
em,
|
||||
i {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
s {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* Custom text colors */
|
||||
[data-style-type='textColor'][data-value='red'] {
|
||||
color: rgb(224 62 62);
|
||||
}
|
||||
|
||||
[data-style-type='backgroundColor'][data-value='blue'] {
|
||||
background-color: rgb(221 235 241);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.bn-block-content[data-content-type='bulletListItem'],
|
||||
.bn-block-content[data-content-type='numberedListItem'] {
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
display: list-item;
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='bulletListItem'] {
|
||||
list-style-type: disc;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='numberedListItem'] {
|
||||
list-style-type: decimal;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='bulletListItem'] p,
|
||||
.bn-block-content[data-content-type='numberedListItem'] p {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Checkboxes */
|
||||
.bn-block-content[data-content-type='checkListItem'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='checkListItem'] input[type='checkbox'] {
|
||||
margin: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='checkListItem'] p {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Quotes */
|
||||
blockquote,
|
||||
.bn-block-content[data-content-type='quote'] blockquote {
|
||||
border-left: 4px solid #a9a9bf;
|
||||
padding-left: 16px;
|
||||
margin: 0;
|
||||
color: #7d797a;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Code Blocks */
|
||||
.bn-block-content[data-content-type='codeBlock'] {
|
||||
background-color: #161616;
|
||||
border-radius: 8px;
|
||||
padding: 3px 0;
|
||||
margin: 8px 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='codeBlock'] pre {
|
||||
background-color: transparent;
|
||||
padding: 24px;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='codeBlock'] code {
|
||||
font-family: monospace;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='codeBlock'] select {
|
||||
padding: 4px 8px;
|
||||
margin: 8px 0 0 24px;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
background-color: #2a2a2a;
|
||||
color: #fff;
|
||||
font-size: 12.8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Inline Code */
|
||||
code {
|
||||
font-family: monospace;
|
||||
font-size: 16px;
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 5px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
td p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Callout Blocks */
|
||||
.bn-block-content[data-content-type='callout'] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
background-color: #fbf3db;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='callout'][data-background-color='yellow'] {
|
||||
background-color: #fbf3db;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='callout'][data-background-color='blue'] {
|
||||
background-color: #ddedf1;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='callout'][data-background-color='red'] {
|
||||
background-color: #ffe5e5;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='callout'][data-background-color='green'] {
|
||||
background-color: #e5f5e5;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='callout'] > div {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='callout'] button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='callout'] .inline-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Toggle Sections */
|
||||
.bn-toggle-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bn-toggle-button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 3px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.bn-toggle-button:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.bn-toggle-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: #454558;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.bn-toggle-wrapper[data-show-children='true'] .bn-toggle-button svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.bn-toggle-wrapper h1,
|
||||
.bn-toggle-wrapper h2,
|
||||
.bn-toggle-wrapper h3,
|
||||
.bn-toggle-wrapper h4,
|
||||
.bn-toggle-wrapper h5,
|
||||
.bn-toggle-wrapper h6,
|
||||
.bn-toggle-wrapper p {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Toggle List Items */
|
||||
.bn-block-content[data-content-type='toggleListItem'] {
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
/* Images and Media */
|
||||
img.bn-visual-media {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
video.bn-visual-media {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
max-width: 760px;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
object-fit: contain;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
audio.bn-audio {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* File Blocks */
|
||||
.bn-file-block-content-wrapper {
|
||||
margin: 8px 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
figure.bn-file-block-content-wrapper {
|
||||
margin: 8px 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='video'] .bn-file-block-content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.bn-visual-media-wrapper {
|
||||
display: flex;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.bn-file-caption,
|
||||
figcaption.bn-file-caption {
|
||||
color: #454558;
|
||||
font-size: 12.8px;
|
||||
margin-top: 4px;
|
||||
text-align: left;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Add File Button */
|
||||
.bn-add-file-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 2px dashed #d0d0d0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background-color 0.2s;
|
||||
}
|
||||
|
||||
.bn-add-file-button:hover {
|
||||
border-color: #a0a0a0;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.bn-add-file-button-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bn-add-file-button-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: #757575;
|
||||
}
|
||||
|
||||
.bn-add-file-button-text {
|
||||
margin: 0;
|
||||
color: #757575;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Select/Dropdown */
|
||||
select {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Spacing between blocks */
|
||||
.bn-block-outer + .bn-block-outer {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Heading spacing */
|
||||
.bn-block-content[data-content-type='heading'] + .bn-block-outer {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.bn-block-outer + .bn-block-content[data-content-type='heading'] {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (width <= 920px) {
|
||||
main {
|
||||
padding: 24px 32px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
main {
|
||||
padding: 16px 16px 32px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
line-height: 39px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
line-height: 33px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
line-height: 27px;
|
||||
}
|
||||
|
||||
video.bn-visual-media {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
select,
|
||||
.bn-add-file-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #000;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='codeBlock'] {
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='codeBlock'] code {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #06c;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Hidden elements for export */
|
||||
[contenteditable='false'] {
|
||||
user-select: none;
|
||||
}
|
||||
@@ -13,22 +13,30 @@ import {
|
||||
import { DocumentProps, pdf } from '@react-pdf/renderer';
|
||||
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
|
||||
import i18next from 'i18next';
|
||||
import JSZip from 'jszip';
|
||||
import { cloneElement, isValidElement, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, ButtonCloseModal, Text } from '@/components';
|
||||
import { useMediaUrl } from '@/core';
|
||||
import { useEditorStore } from '@/docs/doc-editor';
|
||||
import { Doc, useTrans } from '@/docs/doc-management';
|
||||
import { fallbackLng } from '@/i18n/config';
|
||||
|
||||
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
|
||||
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,
|
||||
} from '../utils';
|
||||
|
||||
enum DocDownloadFormat {
|
||||
HTML = 'html',
|
||||
PDF = 'pdf',
|
||||
DOCX = 'docx',
|
||||
ODT = 'odt',
|
||||
@@ -52,6 +60,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
DocDownloadFormat.PDF,
|
||||
);
|
||||
const { untitledDocument } = useTrans();
|
||||
const mediaUrl = useMediaUrl();
|
||||
|
||||
const templateOptions = useMemo(() => {
|
||||
const templateOptions = (templates?.pages || [])
|
||||
@@ -142,13 +151,47 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
});
|
||||
|
||||
blobExport = await exporter.toODTDocument(exportDocument);
|
||||
} else if (format === DocDownloadFormat.HTML) {
|
||||
// Use BlockNote "full HTML" export so that we stay closer to the editor rendering.
|
||||
const fullHtml = await editor.blocksToFullHTML();
|
||||
|
||||
// Parse HTML and fetch media so that we can package a fully offline HTML document in a ZIP.
|
||||
const domParser = new DOMParser();
|
||||
const parsedDocument = domParser.parseFromString(fullHtml, 'text/html');
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
await addMediaFilesToZip(parsedDocument, zip, mediaUrl);
|
||||
|
||||
const lang = i18next.language || fallbackLng;
|
||||
const editorHtmlWithLocalMedia = parsedDocument.body.innerHTML;
|
||||
|
||||
const htmlContent = generateHtmlDocument(
|
||||
documentTitle,
|
||||
editorHtmlWithLocalMedia,
|
||||
lang,
|
||||
);
|
||||
|
||||
zip.file('index.html', htmlContent);
|
||||
|
||||
// CSS Styles
|
||||
const cssResponse = await fetch(
|
||||
new URL('../assets/export-html-styles.txt', import.meta.url).toString(),
|
||||
);
|
||||
const cssContent = await cssResponse.text();
|
||||
zip.file('styles.css', cssContent);
|
||||
|
||||
blobExport = await zip.generateAsync({ type: 'blob' });
|
||||
} else {
|
||||
toast(t('The export failed'), VariantType.ERROR);
|
||||
setIsExporting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
downloadFile(blobExport, `${filename}.${format}`);
|
||||
const downloadExtension =
|
||||
format === DocDownloadFormat.HTML ? 'zip' : format;
|
||||
|
||||
downloadFile(blobExport, `${filename}.${downloadExtension}`);
|
||||
|
||||
toast(
|
||||
t('Your {{format}} was downloaded succesfully', {
|
||||
@@ -225,18 +268,10 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
className="--docs--modal-export-content"
|
||||
>
|
||||
<Text $variation="secondary" $size="sm" as="p">
|
||||
{t('Download your document in a .docx, .odt or .pdf format.')}
|
||||
{t(
|
||||
'Download your document in a .docx, .odt, .pdf or .html(zip) format.',
|
||||
)}
|
||||
</Text>
|
||||
<Select
|
||||
clearable={false}
|
||||
fullWidth
|
||||
label={t('Template')}
|
||||
options={templateOptions}
|
||||
value={templateSelected}
|
||||
onChange={(options) =>
|
||||
setTemplateSelected(options.target.value as string)
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
clearable={false}
|
||||
fullWidth
|
||||
@@ -245,12 +280,24 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
|
||||
{ label: t('ODT'), value: DocDownloadFormat.ODT },
|
||||
{ label: t('PDF'), value: DocDownloadFormat.PDF },
|
||||
{ label: t('HTML'), value: DocDownloadFormat.HTML },
|
||||
]}
|
||||
value={format}
|
||||
onChange={(options) =>
|
||||
setFormat(options.target.value as DocDownloadFormat)
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
clearable={false}
|
||||
fullWidth
|
||||
label={t('Template')}
|
||||
options={templateOptions}
|
||||
value={templateSelected}
|
||||
disabled={format === DocDownloadFormat.HTML}
|
||||
onChange={(options) =>
|
||||
setTemplateSelected(options.target.value as string)
|
||||
}
|
||||
/>
|
||||
|
||||
{isExporting && (
|
||||
<Box
|
||||
|
||||
@@ -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');
|
||||
@@ -179,3 +182,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,86 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React, { Fragment } from 'react';
|
||||
import { beforeEach, describe, expect, vi } from 'vitest';
|
||||
|
||||
import { AbstractAnalytic, Analytics } from '@/libs';
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { DocToolBox } from '../components/DocToolBox';
|
||||
|
||||
let flag = true;
|
||||
class TestAnalytic extends AbstractAnalytic {
|
||||
public constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public Provider() {
|
||||
return <Fragment />;
|
||||
}
|
||||
|
||||
public trackEvent() {}
|
||||
|
||||
public isFeatureFlagActivated(flagName: string): boolean {
|
||||
if (flagName === 'CopyAsHTML') {
|
||||
return flag;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('next/router', async () => ({
|
||||
...(await vi.importActual('next/router')),
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const doc = {
|
||||
nb_accesses: 1,
|
||||
abilities: {
|
||||
versions_list: true,
|
||||
destroy: true,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Analytics.clearAnalytics();
|
||||
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
|
||||
});
|
||||
|
||||
describe('DocToolBox "Copy as HTML" option', () => {
|
||||
test('renders "Copy as HTML" option when feature flag is enabled', async () => {
|
||||
new TestAnalytic();
|
||||
|
||||
render(<DocToolBox doc={doc as any} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
const optionsButton = await screen.findByLabelText(
|
||||
'Open the document options',
|
||||
);
|
||||
await userEvent.click(optionsButton);
|
||||
expect(await screen.findByText('Copy as HTML')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render "Copy as HTML" option when feature flag is disabled', async () => {
|
||||
flag = false;
|
||||
new TestAnalytic();
|
||||
|
||||
render(<DocToolBox doc={doc as any} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
const optionsButton = screen.getByLabelText('Open the document options');
|
||||
await userEvent.click(optionsButton);
|
||||
expect(screen.queryByText('Copy as HTML')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('render "Copy as HTML" option when we did not add analytics', async () => {
|
||||
render(<DocToolBox doc={doc as any} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
const optionsButton = screen.getByLabelText('Open the document options');
|
||||
await userEvent.click(optionsButton);
|
||||
expect(screen.getByText('Copy as HTML')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+4
-3
@@ -42,10 +42,11 @@ describe('DocToolBox - Licence', () => {
|
||||
});
|
||||
const optionsButton = await screen.findByLabelText('Export the document');
|
||||
await userEvent.click(optionsButton);
|
||||
|
||||
// Wait for the export modal to be visible, then assert on its content text.
|
||||
await screen.findByTestId('modal-export-title');
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Download your document in a .docx, .odt or .pdf format.',
|
||||
),
|
||||
screen.getByText(/Download your document in a .docx, .odt.*format\./i),
|
||||
).toBeInTheDocument();
|
||||
}, 10000);
|
||||
|
||||
|
||||
@@ -3,15 +3,14 @@ import React from 'react';
|
||||
|
||||
import { Text } from '@/components';
|
||||
import { useConfig } from '@/core';
|
||||
import { useDate } from '@/hook';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import {
|
||||
Doc,
|
||||
Role,
|
||||
useIsCollaborativeEditable,
|
||||
useTrans,
|
||||
} from '../../doc-management';
|
||||
} from '@/docs/doc-management';
|
||||
import { useDate } from '@/hook';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
interface DocHeaderInfoProps {
|
||||
doc: Doc;
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
KEY_LIST_DOC_VERSIONS,
|
||||
ModalSelectVersion,
|
||||
} from '@/docs/doc-versioning';
|
||||
import { useAnalytics } from '@/libs';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
|
||||
@@ -67,7 +66,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
void router.push(`/docs/${data.id}`);
|
||||
},
|
||||
});
|
||||
const { isFeatureFlagActivated } = useAnalytics();
|
||||
const removeFavoriteDoc = useDeleteFavoriteDoc({
|
||||
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
|
||||
});
|
||||
@@ -155,14 +153,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
callback: () => {
|
||||
void copyCurrentEditorToClipboard('markdown');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('Copy as {{format}}', { format: 'HTML' }),
|
||||
icon: 'content_copy',
|
||||
callback: () => {
|
||||
void copyCurrentEditorToClipboard('html');
|
||||
},
|
||||
show: isFeatureFlagActivated('CopyAsHTML'),
|
||||
showSeparator: true,
|
||||
},
|
||||
{
|
||||
|
||||
+2
-1
@@ -4,12 +4,13 @@ import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Doc, useDocUtils, useTrans } from '@/docs/doc-management';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import ChildDocument from '../assets/child-document.svg';
|
||||
import PinnedDocumentIcon from '../assets/pinned-document.svg';
|
||||
import SimpleFileIcon from '../assets/simple-document.svg';
|
||||
import { useDocUtils, useTrans } from '../hooks';
|
||||
import { Doc } from '../types';
|
||||
|
||||
const ItemTextCss = css`
|
||||
overflow: hidden;
|
||||
|
||||
+4
-7
@@ -1,15 +1,12 @@
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
Doc,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
getEmojiAndTitle,
|
||||
useUpdateDoc,
|
||||
} from '@/docs/doc-management';
|
||||
import { useBroadcastStore } from '@/stores';
|
||||
|
||||
import { KEY_DOC, KEY_LIST_DOC, useUpdateDoc } from '../api';
|
||||
import { Doc } from '../types';
|
||||
import { getEmojiAndTitle } from '../utils';
|
||||
|
||||
interface UseDocUpdateOptions {
|
||||
onSuccess?: (updatedDoc: Doc) => void;
|
||||
onError?: (error: Error) => void;
|
||||
|
||||
+18
-2
@@ -52,13 +52,29 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
onAuthenticationFailed() {
|
||||
set({ isReady: true });
|
||||
set({ isReady: true, isConnected: false });
|
||||
},
|
||||
onAuthenticated() {
|
||||
set({ isReady: true, isConnected: true });
|
||||
},
|
||||
onStatus: ({ status }) => {
|
||||
set((state) => {
|
||||
const nextConnected = status === WebSocketStatus.Connected;
|
||||
|
||||
/**
|
||||
* status === WebSocketStatus.Connected does not mean we are totally connected
|
||||
* because authentication can still be in progress and failed
|
||||
* So we only update isConnected when we loose the connection
|
||||
*/
|
||||
const connected =
|
||||
status !== WebSocketStatus.Connected
|
||||
? {
|
||||
isConnected: false,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
isConnected: nextConnected,
|
||||
...connected,
|
||||
isReady: state.isReady || status === WebSocketStatus.Disconnected,
|
||||
hasLostConnection:
|
||||
state.isConnected && !nextConnected
|
||||
|
||||
+71
-29
@@ -5,7 +5,7 @@ import {
|
||||
useTreeContext,
|
||||
} from '@gouvfr-lasuite/ui-kit';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
@@ -16,12 +16,10 @@ import {
|
||||
DocIcon,
|
||||
getEmojiAndTitle,
|
||||
useTrans,
|
||||
} from '@/features/docs/doc-management';
|
||||
} from '@/docs/doc-management';
|
||||
import { useLeftPanelStore } from '@/features/left-panel';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { useKeyboardActivation } from '../hooks/useKeyboardActivation';
|
||||
|
||||
import SubPageIcon from './../assets/sub-page-logo.svg';
|
||||
import { DocTreeItemActions } from './DocTreeItemActions';
|
||||
|
||||
@@ -46,7 +44,6 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const isSelectedNow = treeContext?.treeData.selectedNode?.id === doc.id;
|
||||
const isActive = node.isFocused || menuOpen || isSelectedNow;
|
||||
|
||||
const router = useRouter();
|
||||
const { togglePanel } = useLeftPanelStore();
|
||||
@@ -92,20 +89,34 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboardActivation(
|
||||
['Enter'],
|
||||
isActive && !menuOpen,
|
||||
handleActivate,
|
||||
true,
|
||||
'.c__tree-view',
|
||||
);
|
||||
|
||||
const docTitle = doc.title || untitledDocument;
|
||||
const hasChildren = (doc.children?.length || 0) > 0;
|
||||
const isExpanded = node.isOpen;
|
||||
const isSelected = isSelectedNow;
|
||||
const ariaLabel = docTitle;
|
||||
const isDisabled = !!doc.deleted_at;
|
||||
const actionsRef = useRef<HTMLDivElement>(null);
|
||||
const buttonOptionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// F2: focus first action button
|
||||
const shouldOpenActions = !menuOpen && node.isFocused;
|
||||
if (e.key === 'F2' && shouldOpenActions) {
|
||||
buttonOptionRef.current?.focus();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleActionsOpenChange = (isOpen: boolean) => {
|
||||
setMenuOpen(isOpen);
|
||||
|
||||
// When the menu closes (via Escape or activating an option),
|
||||
// return focus to the tree item so focus is not lost.
|
||||
if (!isOpen) {
|
||||
node.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -117,6 +128,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
||||
aria-selected={isSelected}
|
||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||
aria-disabled={isDisabled}
|
||||
onKeyDown={handleKeyDown}
|
||||
$css={css`
|
||||
background-color: var(--c--globals--colors--gray-000);
|
||||
.light-doc-item-actions {
|
||||
@@ -127,6 +139,14 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-500) !important;
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
.light-doc-item-actions {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
/* Remove visual focus from the tree item when focus is on actions or emoji button */
|
||||
&:has(.light-doc-item-actions *:focus, .--docs--doc-icon:focus-visible)
|
||||
.c__tree-view--node.isFocused {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(
|
||||
@@ -137,6 +157,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
&:focus-within {
|
||||
.light-doc-item-actions {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.row.preview & {
|
||||
background-color: inherit;
|
||||
}
|
||||
@@ -147,12 +172,44 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
||||
emoji={emoji}
|
||||
withEmojiPicker={doc.abilities.partial_update}
|
||||
defaultIcon={
|
||||
<SubPageIcon color="var(--c--contextuals--content--semantic--info--tertiary)" />
|
||||
<SubPageIcon
|
||||
color="var(--c--contextuals--content--semantic--info--tertiary)"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
}
|
||||
$size="sm"
|
||||
docId={doc.id}
|
||||
title={doc.title}
|
||||
buttonProps={{
|
||||
$css: css`
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--c--globals--colors--brand-500);
|
||||
outline-offset: var(--c--globals--spacings--4xs);
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
className="light-doc-item-actions actions"
|
||||
role="toolbar"
|
||||
aria-label={`${t('Actions for {{title}}', { title: docTitle })}`}
|
||||
$css={css`
|
||||
margin-left: auto;
|
||||
order: 2;
|
||||
`}
|
||||
>
|
||||
<DocTreeItemActions
|
||||
doc={doc}
|
||||
isOpen={menuOpen}
|
||||
onOpenChange={handleActionsOpenChange}
|
||||
parentId={node.data.parentKey}
|
||||
onCreateSuccess={afterCreate}
|
||||
actionsRef={actionsRef}
|
||||
buttonOptionRef={buttonOptionRef}
|
||||
/>
|
||||
</Box>
|
||||
<BoxButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -196,21 +253,6 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
||||
)}
|
||||
</Box>
|
||||
</BoxButton>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
className="light-doc-item-actions"
|
||||
role="toolbar"
|
||||
aria-label={`${t('Actions for {{title}}', { title: docTitle })}`}
|
||||
>
|
||||
<DocTreeItemActions
|
||||
doc={doc}
|
||||
isOpen={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
parentId={node.data.parentKey}
|
||||
onCreateSuccess={afterCreate}
|
||||
/>
|
||||
</Box>
|
||||
</TreeViewItem>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useTreeContext,
|
||||
} from '@gouvfr-lasuite/ui-kit';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
@@ -35,6 +35,9 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
const rootIsSelected =
|
||||
!!treeContext?.root?.id &&
|
||||
treeContext?.treeData.selectedNode?.id === treeContext.root.id;
|
||||
const rootItemRef = useRef<HTMLDivElement>(null);
|
||||
const rootActionsRef = useRef<HTMLDivElement>(null);
|
||||
const rootButtonOptionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -60,13 +63,11 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
});
|
||||
treeContext?.treeData.handleMove(result);
|
||||
};
|
||||
|
||||
/**
|
||||
* This function resets the tree states.
|
||||
*/
|
||||
const resetStateTree = useCallback(() => {
|
||||
if (!treeContext?.root?.id) {
|
||||
return;
|
||||
}
|
||||
treeContext?.setRoot(null);
|
||||
setInitialOpenState(undefined);
|
||||
}, [treeContext]);
|
||||
@@ -88,18 +89,45 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
selectRoot();
|
||||
}, [selectRoot]);
|
||||
|
||||
// activate root document with enter or space
|
||||
// Handle keyboard navigation for root item
|
||||
const handleRootKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
// F2: focus first action button
|
||||
if (e.key === 'F2' && !rootActionsOpen) {
|
||||
e.preventDefault();
|
||||
rootButtonOptionRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore if focus is in actions
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('.doc-tree-root-item-actions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectRoot();
|
||||
navigateToRoot();
|
||||
}
|
||||
},
|
||||
[selectRoot, navigateToRoot],
|
||||
[selectRoot, navigateToRoot, rootActionsOpen],
|
||||
);
|
||||
|
||||
// Handle menu open/close for root item - mirrors DocSubPageItem behavior
|
||||
const handleRootActionsOpenChange = useCallback((isOpen: boolean) => {
|
||||
setRootActionsOpen(isOpen);
|
||||
|
||||
// When the menu closes, return focus to the root tree item
|
||||
// (same behavior as DocSubPageItem for consistency)
|
||||
// Use requestAnimationFrame for smoother focus transition without flickering
|
||||
if (!isOpen) {
|
||||
requestAnimationFrame(() => {
|
||||
rootItemRef.current?.focus();
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* This effect is used to reset the tree when a new document
|
||||
* that is not part of the current tree is loaded.
|
||||
@@ -180,6 +208,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
$height="100%"
|
||||
role="tree"
|
||||
aria-label={t('Document tree')}
|
||||
aria-describedby="doc-tree-keyboard-instructions"
|
||||
$css={css`
|
||||
/* Remove outline from TreeViewItem wrapper elements */
|
||||
.c__tree-view--row {
|
||||
@@ -199,6 +228,12 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Keyboard instructions for screen readers */}
|
||||
<Box id="doc-tree-keyboard-instructions" className="sr-only">
|
||||
{t(
|
||||
'Use arrow keys to navigate between documents. Press Enter to open a document. Press F2 to focus the emoji button when available, then press F2 again to access document actions.',
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
$padding={{ horizontal: 'sm', top: 'sm', bottom: '4px' }}
|
||||
$css={css`
|
||||
@@ -206,6 +241,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
ref={rootItemRef}
|
||||
data-testid="doc-tree-root-item"
|
||||
role="treeitem"
|
||||
aria-label={`${t('Root document {{title}}', { title: treeContext.root?.title || t('Untitled document') })}`}
|
||||
@@ -234,7 +270,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
}
|
||||
|
||||
.doc-tree-root-item-actions {
|
||||
display: ${rootActionsOpen ? 'flex' : 'none'};
|
||||
display: flex;
|
||||
opacity: ${rootActionsOpen ? '1' : '0'};
|
||||
|
||||
&:has(.isOpen) {
|
||||
@@ -242,12 +278,17 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
}
|
||||
}
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
&:focus-visible,
|
||||
&:focus-within {
|
||||
.doc-tree-root-item-actions {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
/* Remove visual focus from the root item when focus is on the actions */
|
||||
&:has(.doc-tree-root-item-actions *:focus) {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<StyledLink
|
||||
@@ -281,7 +322,9 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
}}
|
||||
isOpen={rootActionsOpen}
|
||||
isRoot={true}
|
||||
onOpenChange={setRootActionsOpen}
|
||||
onOpenChange={handleRootActionsOpenChange}
|
||||
actionsRef={rootActionsRef}
|
||||
buttonOptionRef={rootButtonOptionRef}
|
||||
/>
|
||||
</Box>
|
||||
</StyledLink>
|
||||
|
||||
+42
-7
@@ -1,10 +1,12 @@
|
||||
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';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
@@ -17,8 +19,8 @@ import {
|
||||
useCopyDocLink,
|
||||
useCreateChildDoc,
|
||||
useDocTitleUpdate,
|
||||
useDuplicateDoc,
|
||||
} from '@/docs/doc-management';
|
||||
import { useDuplicateDoc } from '@/docs/doc-management/api';
|
||||
|
||||
import { useDetachDoc } from '../api/useDetach';
|
||||
import MoveDocIcon from '../assets/doc-extract-bold.svg';
|
||||
@@ -30,6 +32,8 @@ type DocTreeItemActionsProps = {
|
||||
onCreateSuccess?: (newDoc: Doc) => void;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
parentId?: string | null;
|
||||
actionsRef?: React.RefObject<HTMLDivElement | null>;
|
||||
buttonOptionRef?: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export const DocTreeItemActions = ({
|
||||
@@ -39,7 +43,13 @@ export const DocTreeItemActions = ({
|
||||
onCreateSuccess,
|
||||
onOpenChange,
|
||||
parentId,
|
||||
actionsRef,
|
||||
buttonOptionRef,
|
||||
}: DocTreeItemActionsProps) => {
|
||||
const internalActionsRef = useRef<HTMLDivElement | null>(null);
|
||||
const targetActionsRef = actionsRef ?? internalActionsRef;
|
||||
const internalButtonRef = useRef<HTMLDivElement | null>(null);
|
||||
const targetButtonRef = buttonOptionRef ?? internalButtonRef;
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const deleteModal = useModal();
|
||||
@@ -47,6 +57,9 @@ export const DocTreeItemActions = ({
|
||||
const { mutate: detachDoc } = useDetachDoc();
|
||||
const treeContext = useTreeContext<Doc | null>();
|
||||
|
||||
// Keyboard navigation inside the actions toolbar (ArrowLeft / ArrowRight).
|
||||
useArrowRoving(targetActionsRef.current);
|
||||
|
||||
const { mutate: duplicateDoc } = useDuplicateDoc({
|
||||
onSuccess: (duplicatedDoc) => {
|
||||
// Reset the tree context root will reset the full tree view.
|
||||
@@ -160,30 +173,45 @@ export const DocTreeItemActions = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="doc-tree-root-item-actions">
|
||||
<Box className="doc-tree-root-item-actions actions">
|
||||
<Box
|
||||
ref={targetActionsRef}
|
||||
$direction="row"
|
||||
$align="center"
|
||||
className="--docs--doc-tree-item-actions"
|
||||
$gap="4px"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<DropdownMenu
|
||||
options={options}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<Icon
|
||||
<BoxButton
|
||||
ref={targetButtonRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onOpenChange?.(!isOpen);
|
||||
}}
|
||||
iconName="more_horiz"
|
||||
variant="filled"
|
||||
aria-label={t('More options')}
|
||||
tabIndex={-1}
|
||||
$theme="brand"
|
||||
$variation="secondary"
|
||||
aria-label={t('More options')}
|
||||
/>
|
||||
$css={css`
|
||||
&:focus-visible {
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon
|
||||
iconName="more_horiz"
|
||||
variant="filled"
|
||||
$theme="brand"
|
||||
$variation="secondary"
|
||||
/>
|
||||
</BoxButton>
|
||||
</DropdownMenu>
|
||||
{doc.abilities.children_create && (
|
||||
<BoxButton
|
||||
@@ -199,6 +227,13 @@ export const DocTreeItemActions = ({
|
||||
$variation="secondary"
|
||||
aria-label={t('Add a sub page')}
|
||||
data-testid="doc-tree-item-actions-add-child"
|
||||
tabIndex={-1}
|
||||
$css={css`
|
||||
&:focus-visible {
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon variant="filled" $color="inherit" iconName="add_box" />
|
||||
</BoxButton>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useKeyboardActivation = (
|
||||
keys: string[],
|
||||
enabled: boolean,
|
||||
action: () => void,
|
||||
capture = false,
|
||||
selector: string,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (keys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
action();
|
||||
}
|
||||
};
|
||||
const treeEl = document.querySelector<HTMLElement>(selector);
|
||||
if (!treeEl) {
|
||||
return;
|
||||
}
|
||||
treeEl.addEventListener('keydown', onKeyDown, capture);
|
||||
return () => {
|
||||
treeEl.removeEventListener('keydown', onKeyDown, capture);
|
||||
};
|
||||
}, [keys, enabled, action, capture, selector]);
|
||||
};
|
||||
@@ -77,7 +77,6 @@ export function useDocVersionsInfiniteQuery(
|
||||
getNextPageParam(lastPage) {
|
||||
return lastPage.next_version_id_marker || undefined;
|
||||
},
|
||||
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
+7
-2
@@ -32,13 +32,18 @@ export const DocVersionEditor = ({
|
||||
const { replace } = useRouter();
|
||||
const [initialContent, setInitialContent] = useState<Y.XmlFragment>();
|
||||
|
||||
// Reset initialContent when versionId changes to avoid conflicts between versions
|
||||
useEffect(() => {
|
||||
if (!version?.content) {
|
||||
setInitialContent(undefined);
|
||||
}, [versionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!version?.content || isLoading || initialContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInitialContent(base64ToBlocknoteXmlFragment(version.content));
|
||||
}, [version?.content]);
|
||||
}, [versionId, version?.content, isLoading, initialContent]);
|
||||
|
||||
if (isError && error) {
|
||||
if (error.status === 404) {
|
||||
|
||||
+15
-15
@@ -4,10 +4,10 @@ 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 { useTranslation } from 'react-i18next';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { AlertModal, Card, Text } from '@/components';
|
||||
import { Doc, KEY_LIST_DOC } from '@/docs/doc-management';
|
||||
import { Doc, KEY_LIST_DOC, useTrans } from '@/docs/doc-management';
|
||||
import {
|
||||
getDocAccesses,
|
||||
getDocInvitations,
|
||||
@@ -60,6 +60,7 @@ export const DraggableDocGridContentList = ({
|
||||
const { mutate: handleDeleteInvitation } = useDeleteDocInvitation();
|
||||
const { mutate: handleDeleteAccess } = useDeleteDocAccess();
|
||||
const onDragData = useRef<DocDragEndData | null>(null);
|
||||
const { untitledDocument } = useTrans();
|
||||
|
||||
const handleMoveDoc = async () => {
|
||||
if (!onDragData.current) {
|
||||
@@ -144,8 +145,8 @@ export const DraggableDocGridContentList = ({
|
||||
return t('You must be at least the administrator of the target document');
|
||||
}
|
||||
|
||||
return selectedDoc?.title || t('Unnamed document');
|
||||
}, [canDrag, canDrop, selectedDoc, t]);
|
||||
return selectedDoc?.title || untitledDocument;
|
||||
}, [canDrag, canDrop, selectedDoc?.title, t, untitledDocument]);
|
||||
|
||||
const cannotMoveDoc =
|
||||
!canDrag || (canDrop !== undefined && !canDrop) || isError;
|
||||
@@ -193,17 +194,16 @@ export const DraggableDocGridContentList = ({
|
||||
{...modalConfirmation}
|
||||
title={t('Move document')}
|
||||
description={
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t(
|
||||
'By moving this document to <strong>{{targetDocumentTitle}}</strong>, it will lose its current access rights and inherit the permissions of that document. <strong>This access change cannot be undone.</strong>',
|
||||
{
|
||||
targetDocumentTitle:
|
||||
onDragData.current?.target.title ?? t('Unnamed document'),
|
||||
},
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Text $display="inline">
|
||||
<Trans
|
||||
i18nKey="By moving this document to <strong>{{targetDocumentTitle}}</strong>, it will lose its current access rights and inherit the permissions of that document. <strong>This access change cannot be undone.</strong>"
|
||||
values={{
|
||||
targetDocumentTitle:
|
||||
onDragData.current?.target.title ?? untitledDocument,
|
||||
}}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</Text>
|
||||
}
|
||||
confirmLabel={t('Move')}
|
||||
onConfirm={() => {
|
||||
|
||||
@@ -12,12 +12,16 @@ export const ButtonTogglePanel = () => {
|
||||
<Button
|
||||
size="medium"
|
||||
onClick={() => togglePanel()}
|
||||
aria-label={t('Open the header menu')}
|
||||
aria-label={t(
|
||||
isPanelOpen ? 'Close the header menu' : 'Open the header menu',
|
||||
)}
|
||||
aria-expanded={isPanelOpen}
|
||||
variant="tertiary"
|
||||
icon={
|
||||
<Icon $withThemeInherited iconName={isPanelOpen ? 'close' : 'menu'} />
|
||||
}
|
||||
className="--docs--button-toggle-panel"
|
||||
data-testid="header-menu-toggle"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ export const Header = () => {
|
||||
<SkipToContent />
|
||||
<Box
|
||||
as="header"
|
||||
className="--docs--header"
|
||||
role="banner"
|
||||
$css={css`
|
||||
position: fixed;
|
||||
@@ -45,7 +46,6 @@ export const Header = () => {
|
||||
border-bottom: 1px solid
|
||||
var(--c--contextuals--border--surface--primary);
|
||||
`}
|
||||
className="--docs--header"
|
||||
>
|
||||
{!isDesktop && <ButtonTogglePanel />}
|
||||
<StyledLink
|
||||
@@ -88,7 +88,12 @@ export const Header = () => {
|
||||
<LaGaufre />
|
||||
</Box>
|
||||
) : (
|
||||
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
|
||||
<Box
|
||||
className="--docs--header-block-right"
|
||||
$align="center"
|
||||
$gap={spacingsTokens['sm']}
|
||||
$direction="row"
|
||||
>
|
||||
<ButtonLogin />
|
||||
<LanguagePicker />
|
||||
<LaGaufre />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRouter } from 'next/router';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, SeparatedSection } from '@/components';
|
||||
import { useDocStore } from '@/docs/doc-management';
|
||||
|
||||
import { LeftPanelTargetFilters } from './LefPanelTargetFilters';
|
||||
import { LeftPanelDocContent } from './LeftPanelDocContent';
|
||||
@@ -11,6 +12,7 @@ export const LeftPanelContent = () => {
|
||||
const router = useRouter();
|
||||
const isHome = router.pathname === '/';
|
||||
const isDoc = router.pathname === '/docs/[id]';
|
||||
const { currentDoc } = useDocStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -36,7 +38,7 @@ export const LeftPanelContent = () => {
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{isDoc && <LeftPanelDocContent />}
|
||||
{isDoc && currentDoc && <LeftPanelDocContent doc={currentDoc} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-6
@@ -1,15 +1,13 @@
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { Doc, useDocStore } from '@/docs/doc-management';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
import { DocTree } from '@/docs/doc-tree/';
|
||||
|
||||
export const LeftPanelDocContent = () => {
|
||||
const { currentDoc } = useDocStore();
|
||||
|
||||
export const LeftPanelDocContent = ({ doc }: { doc: Doc }) => {
|
||||
const tree = useTreeContext<Doc>();
|
||||
|
||||
if (!currentDoc || !tree) {
|
||||
if (!tree) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -20,7 +18,7 @@ export const LeftPanelDocContent = () => {
|
||||
$css="width: 100%; overflow-y: auto; overflow-x: hidden;"
|
||||
className="--docs--left-panel-doc-content"
|
||||
>
|
||||
<DocTree currentDoc={currentDoc} />
|
||||
<DocTree currentDoc={doc} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ import { useLeftPanelStore } from '../stores';
|
||||
export const LeftPanelHeaderButton = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { togglePanel } = useLeftPanelStore();
|
||||
const { closePanel } = useLeftPanelStore();
|
||||
const { setIsSkeletonVisible } = useSkeletonStore();
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
|
||||
@@ -25,7 +25,7 @@ export const LeftPanelHeaderButton = () => {
|
||||
.then(() => {
|
||||
// The skeleton will be disabled by the [id] page once the data is loaded
|
||||
setIsNavigating(false);
|
||||
togglePanel();
|
||||
closePanel();
|
||||
})
|
||||
.catch(() => {
|
||||
// In case of navigation error, disable the skeleton
|
||||
|
||||
+40
-29
@@ -6,6 +6,8 @@ import {
|
||||
PanelResizeHandle,
|
||||
} from 'react-resizable-panels';
|
||||
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
// Convert a target pixel width to a percentage of the current viewport width.
|
||||
const pxToPercent = (px: number) => {
|
||||
return (px / window.innerWidth) * 100;
|
||||
@@ -24,18 +26,27 @@ export const ResizableLeftPanel = ({
|
||||
minPanelSizePx = 300,
|
||||
maxPanelSizePx = 450,
|
||||
}: ResizableLeftPanelProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const ref = useRef<ImperativePanelHandle>(null);
|
||||
const savedWidthPxRef = useRef<number>(minPanelSizePx);
|
||||
|
||||
const [panelSizePercent, setPanelSizePercent] = useState(() =>
|
||||
pxToPercent(minPanelSizePx),
|
||||
);
|
||||
|
||||
const minPanelSizePercent = pxToPercent(minPanelSizePx);
|
||||
const maxPanelSizePercent = Math.min(pxToPercent(maxPanelSizePx), 40);
|
||||
|
||||
const [panelSizePercent, setPanelSizePercent] = useState(() => {
|
||||
const initialSize = pxToPercent(minPanelSizePx);
|
||||
return Math.max(
|
||||
minPanelSizePercent,
|
||||
Math.min(initialSize, maxPanelSizePercent),
|
||||
);
|
||||
});
|
||||
|
||||
// Keep pixel width constant on window resize
|
||||
useEffect(() => {
|
||||
if (!isDesktop) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
const newPercent = pxToPercent(savedWidthPxRef.current);
|
||||
setPanelSizePercent(newPercent);
|
||||
@@ -48,7 +59,7 @@ export const ResizableLeftPanel = ({
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
}, [isDesktop]);
|
||||
|
||||
const handleResize = (sizePercent: number) => {
|
||||
const widthPx = (sizePercent / 100) * window.innerWidth;
|
||||
@@ -57,29 +68,29 @@ export const ResizableLeftPanel = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel
|
||||
ref={ref}
|
||||
order={0}
|
||||
defaultSize={panelSizePercent}
|
||||
minSize={minPanelSizePercent}
|
||||
maxSize={maxPanelSizePercent}
|
||||
onResize={handleResize}
|
||||
>
|
||||
{leftPanel}
|
||||
</Panel>
|
||||
<PanelResizeHandle
|
||||
style={{
|
||||
borderRightWidth: '1px',
|
||||
borderRightStyle: 'solid',
|
||||
borderRightColor: 'var(--c--contextuals--border--surface--primary)',
|
||||
width: '1px',
|
||||
cursor: 'col-resize',
|
||||
}}
|
||||
/>
|
||||
<Panel order={1}>{children}</Panel>
|
||||
</PanelGroup>
|
||||
</>
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel
|
||||
ref={ref}
|
||||
order={0}
|
||||
defaultSize={isDesktop ? panelSizePercent : 0}
|
||||
minSize={isDesktop ? minPanelSizePercent : 0}
|
||||
maxSize={isDesktop ? maxPanelSizePercent : 0}
|
||||
onResize={handleResize}
|
||||
>
|
||||
{leftPanel}
|
||||
</Panel>
|
||||
<PanelResizeHandle
|
||||
style={{
|
||||
borderRightWidth: '1px',
|
||||
borderRightStyle: 'solid',
|
||||
borderRightColor: 'var(--c--contextuals--border--surface--primary)',
|
||||
width: '1px',
|
||||
cursor: 'col-resize',
|
||||
}}
|
||||
disabled={!isDesktop}
|
||||
/>
|
||||
|
||||
<Panel order={1}>{children}</Panel>
|
||||
</PanelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { create } from 'zustand';
|
||||
interface LeftPanelState {
|
||||
isPanelOpen: boolean;
|
||||
togglePanel: (value?: boolean) => void;
|
||||
closePanel: () => void;
|
||||
}
|
||||
|
||||
export const useLeftPanelStore = create<LeftPanelState>((set, get) => ({
|
||||
@@ -15,4 +16,7 @@ export const useLeftPanelStore = create<LeftPanelState>((set, get) => ({
|
||||
|
||||
set({ isPanelOpen: sanitizedValue });
|
||||
},
|
||||
closePanel: () => {
|
||||
set({ isPanelOpen: false });
|
||||
},
|
||||
}));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user