🚸(backend) sort user search results by proxmity with the active user
Allows a user to find more easily the other users they search, with the following order of priority: - users they already share documents with (more recent first) - users that share the same full email domain - users that share the same partial email domain (last two parts) - other users
This commit is contained in:
+20
-8
@@ -6,13 +6,29 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- ✅(e2e) fix e2e test for other browsers #1799
|
||||
|
||||
### Changed
|
||||
|
||||
- 🚸(backend) sort user search results by proximity with the active user #1802
|
||||
|
||||
## [4.4.0] - 2026-01-13
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) add documents/all endpoint with descendants #1553
|
||||
- ✅(export) add PDF regression tests #1762
|
||||
- 📝(docs) Add language configuration documentation #1757
|
||||
- 🔒(helm) Set default security context #1750
|
||||
- ✨(backend) use langfuse to monitor AI actions
|
||||
- ✨(backend) use langfuse to monitor AI actions #1776
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) make html export accessible to screen reader users #1743
|
||||
- ♿(frontend) add missing label and fix Axes errors to improve a11y #1693
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -24,12 +40,7 @@ and this project adheres to
|
||||
### Security
|
||||
|
||||
- 🔒️(backend) validate more strictly url used by cors-proxy endpoint #1768
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) make html export accessible to screen reader users #1743
|
||||
- ♿(frontend) add missing label and fix Axes errors to improve a11y #1693
|
||||
- 🔒️(frontend) fix props vulnerability in Interlinking #1792
|
||||
|
||||
## [4.3.0] - 2026-01-05
|
||||
|
||||
@@ -987,7 +998,8 @@ and this project adheres to
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.3.0...main
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.4.0...main
|
||||
[v4.4.0]: https://github.com/suitenumerique/docs/releases/v4.4.0
|
||||
[v4.3.0]: https://github.com/suitenumerique/docs/releases/v4.3.0
|
||||
[v4.2.0]: https://github.com/suitenumerique/docs/releases/v4.2.0
|
||||
[v4.1.0]: https://github.com/suitenumerique/docs/releases/v4.1.0
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
"matchPackageNames": ["django"],
|
||||
"allowedVersions": "<6.0.0"
|
||||
},
|
||||
{
|
||||
|
||||
"groupName": "allowed celery versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["celery"],
|
||||
"allowedVersions": "<5.6.0"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
|
||||
@@ -59,7 +59,12 @@ from core.services.search_indexers import (
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
from core.tasks.mail import send_ask_for_access_mail
|
||||
from core.utils import extract_attachments, filter_descendants
|
||||
from core.utils import (
|
||||
extract_attachments,
|
||||
extract_email_domain_parts,
|
||||
filter_descendants,
|
||||
users_sharing_documents_with,
|
||||
)
|
||||
|
||||
from . import permissions, serializers, utils
|
||||
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
|
||||
@@ -218,18 +223,77 @@ 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
|
||||
# index, then only calculate precise similarity scores for sorting purposes.
|
||||
#
|
||||
# Additionally we reorder results to prefer users "closer" to the current
|
||||
# user: users they recently shared documents with, same full domain, same
|
||||
# partial domain (e.g. both end with "gouv.fr"). To achieve that without
|
||||
# complex SQL we build a proximity score in Python and returnthe top N results.
|
||||
current_user = self.request.user
|
||||
shared_map = users_sharing_documents_with(current_user)
|
||||
|
||||
return (
|
||||
user_full_domain, user_partial_domain = extract_email_domain_parts(
|
||||
current_user.email or ""
|
||||
)
|
||||
|
||||
candidates = list(
|
||||
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")[: settings.API_USERS_LIST_LIMIT]
|
||||
.order_by("-similarity")
|
||||
)
|
||||
|
||||
# Build ordering key for each candidate
|
||||
def _sort_key(u):
|
||||
# shared priority: most recent first
|
||||
# Use shared_last_at timestamp numeric for secondary ordering when shared.
|
||||
shared_last_at = shared_map.get(u.id)
|
||||
if shared_last_at:
|
||||
is_shared = 1
|
||||
shared_score = int(shared_last_at.timestamp())
|
||||
else:
|
||||
is_shared = 0
|
||||
shared_score = 0
|
||||
|
||||
# domain proximity
|
||||
candidate_full_domain, candidate_partial_domain = (
|
||||
extract_email_domain_parts(u.email or "")
|
||||
)
|
||||
|
||||
same_full_domain = (
|
||||
1
|
||||
if candidate_full_domain and candidate_full_domain == user_full_domain
|
||||
else 0
|
||||
)
|
||||
|
||||
same_partial_domain = (
|
||||
1
|
||||
if candidate_partial_domain
|
||||
and candidate_partial_domain == user_partial_domain
|
||||
else 0
|
||||
)
|
||||
|
||||
# similarity fallback
|
||||
sim = getattr(u, "similarity", 0) or 0
|
||||
|
||||
return (
|
||||
is_shared,
|
||||
shared_score,
|
||||
same_full_domain,
|
||||
same_partial_domain,
|
||||
sim,
|
||||
)
|
||||
|
||||
# Sort candidates by the key descending and return top N as a queryset-like
|
||||
# list. Keep return type consistent with previous behavior (QuerySet slice
|
||||
# was returned) by returning a list of model instances.
|
||||
candidates.sort(key=_sort_key, reverse=True)
|
||||
|
||||
return candidates[: settings.API_USERS_LIST_LIMIT]
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=False,
|
||||
methods=["get"],
|
||||
@@ -2231,8 +2295,7 @@ class InvitationViewset(
|
||||
)
|
||||
# Abilities are computed based on logged-in user's role and
|
||||
# the user role on each document access
|
||||
.annotate(user_roles=db.Subquery(user_roles_query))
|
||||
.distinct()
|
||||
.annotate(user_roles=db.Subquery(user_roles_query)).distinct()
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
Test users API endpoints in the impress core app.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -201,10 +203,85 @@ def test_api_users_list_query_accented_full_name():
|
||||
assert users == []
|
||||
|
||||
|
||||
def test_api_users_list_sorted_by_closest_match():
|
||||
"""
|
||||
Authenticated users should be able to list users and the results should be
|
||||
sorted by closest match to the query.
|
||||
|
||||
Sorting criteria are :
|
||||
- Shared documents with the user (most recent first)
|
||||
- Same full email domain (example.gouv.fr)
|
||||
- Same partial email domain (gouv.fr)
|
||||
|
||||
Case in point: the logged-in user has recently shared documents
|
||||
with [email protected] and less recently with [email protected].
|
||||
|
||||
Other users named Pierre also exist:
|
||||
- [email protected]
|
||||
- [email protected]
|
||||
- [email protected]
|
||||
|
||||
The search results should be ordered as follows:
|
||||
|
||||
# Shared with first
|
||||
- [email protected] # Most recent first
|
||||
- [email protected]
|
||||
# Same full domain second
|
||||
- [email protected]
|
||||
# Same partial domain third
|
||||
- [email protected]
|
||||
# Others last
|
||||
- [email protected]
|
||||
"""
|
||||
|
||||
user = factories.UserFactory(
|
||||
email="[email protected]", full_name="Martin Bernard"
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
pierre_1 = factories.UserFactory(email="[email protected]")
|
||||
pierre_2 = factories.UserFactory(email="[email protected]")
|
||||
pierre_3 = factories.UserFactory(email="[email protected]")
|
||||
pierre_4 = factories.UserFactory(email="[email protected]")
|
||||
pierre_5 = factories.UserFactory(email="[email protected]")
|
||||
|
||||
document_1 = factories.DocumentFactory(creator=user)
|
||||
document_2 = factories.DocumentFactory(creator=user)
|
||||
factories.UserDocumentAccessFactory(user=user, document=document_1)
|
||||
factories.UserDocumentAccessFactory(user=user, document=document_2)
|
||||
|
||||
now = timezone.now()
|
||||
last_week = now - timezone.timedelta(days=7)
|
||||
last_month = now - timezone.timedelta(days=30)
|
||||
|
||||
# The factory cannot set the created_at directly, so we force it after creation
|
||||
p1_d1 = factories.UserDocumentAccessFactory(user=pierre_1, document=document_1)
|
||||
p1_d1.created_at = last_week
|
||||
p1_d1.save()
|
||||
|
||||
p2_d2 = factories.UserDocumentAccessFactory(user=pierre_2, document=document_2)
|
||||
p2_d2.created_at = last_month
|
||||
p2_d2.save()
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=Pierre")
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["email"] for user in response.json()]
|
||||
|
||||
assert user_ids == [
|
||||
str(pierre_1.email),
|
||||
str(pierre_2.email),
|
||||
str(pierre_4.email),
|
||||
str(pierre_5.email),
|
||||
str(pierre_3.email),
|
||||
]
|
||||
|
||||
|
||||
def test_api_users_list_limit(settings):
|
||||
"""
|
||||
Authenticated users should be able to list users and the number of results
|
||||
should be limited to 10.
|
||||
should be limited to API_USERS_LIST_LIMIT (by default 5).
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
|
||||
@@ -100,3 +100,18 @@ def test_utils_get_ancestor_to_descendants_map_multiple_paths():
|
||||
"000100020005": {"000100020005"},
|
||||
"00010003": {"00010003"},
|
||||
}
|
||||
|
||||
|
||||
def test_utils_extract_email_domain_parts_when_email_is_valid():
|
||||
"""Test extraction of email domain parts."""
|
||||
email = "[email protected]"
|
||||
full_domain, partial_domain = utils.extract_email_domain_parts(email)
|
||||
assert full_domain == "numerique.gouv.fr"
|
||||
assert partial_domain == "gouv.fr"
|
||||
|
||||
|
||||
def test_utils_extract_email_domain_parts_when_email_is_empty():
|
||||
empty_email = ""
|
||||
full_domain, partial_domain = utils.extract_email_domain_parts(empty_email)
|
||||
assert full_domain == ""
|
||||
assert partial_domain == ""
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, utils
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_utils_users_sharing_documents_with():
|
||||
"""Test users_sharing_documents_with function."""
|
||||
|
||||
user = factories.UserFactory(
|
||||
email="[email protected]", full_name="Martin Bernard"
|
||||
)
|
||||
|
||||
pierre_1 = factories.UserFactory(
|
||||
email="[email protected]", full_name="Pierre Dupont"
|
||||
)
|
||||
pierre_2 = factories.UserFactory(
|
||||
email="[email protected]", full_name="Pierre Durand"
|
||||
)
|
||||
|
||||
now = timezone.now()
|
||||
yesterday = now - timezone.timedelta(days=1)
|
||||
last_week = now - timezone.timedelta(days=7)
|
||||
last_month = now - timezone.timedelta(days=30)
|
||||
|
||||
document_1 = factories.DocumentFactory(creator=user)
|
||||
document_2 = factories.DocumentFactory(creator=user)
|
||||
document_3 = factories.DocumentFactory(creator=user)
|
||||
|
||||
factories.UserDocumentAccessFactory(user=user, document=document_1)
|
||||
factories.UserDocumentAccessFactory(user=user, document=document_2)
|
||||
factories.UserDocumentAccessFactory(user=user, document=document_3)
|
||||
|
||||
# The factory cannot set the created_at directly, so we force it after creation
|
||||
doc_1_pierre_1 = factories.UserDocumentAccessFactory(
|
||||
user=pierre_1, document=document_1, created_at=last_week
|
||||
)
|
||||
doc_1_pierre_1.created_at = last_week
|
||||
doc_1_pierre_1.save()
|
||||
doc_2_pierre_2 = factories.UserDocumentAccessFactory(
|
||||
user=pierre_2, document=document_2
|
||||
)
|
||||
doc_2_pierre_2.created_at = last_month
|
||||
doc_2_pierre_2.save()
|
||||
|
||||
doc_3_pierre_2 = factories.UserDocumentAccessFactory(
|
||||
user=pierre_2, document=document_3
|
||||
)
|
||||
doc_3_pierre_2.created_at = yesterday
|
||||
doc_3_pierre_2.save()
|
||||
|
||||
shared_map = utils.users_sharing_documents_with(user)
|
||||
|
||||
assert shared_map == {
|
||||
pierre_1.id: last_week,
|
||||
pierre_2.id: yesterday,
|
||||
}
|
||||
@@ -4,10 +4,14 @@ import base64
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.db import models as db
|
||||
|
||||
import pycrdt
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from core import enums
|
||||
from core import enums, models
|
||||
|
||||
|
||||
def get_ancestor_to_descendants_map(paths, steplen):
|
||||
@@ -96,3 +100,39 @@ def extract_attachments(content):
|
||||
|
||||
xml_content = base64_yjs_to_xml(content)
|
||||
return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content)
|
||||
|
||||
|
||||
def users_sharing_documents_with(user):
|
||||
"""
|
||||
Returns a map of users sharing documents with the given user,
|
||||
sorted by last shared date.
|
||||
"""
|
||||
|
||||
user_docs_qs = models.DocumentAccess.objects.filter(user=user).values_list(
|
||||
"document_id", flat=True
|
||||
)
|
||||
shared_qs = (
|
||||
models.DocumentAccess.objects.filter(document_id__in=user_docs_qs)
|
||||
.exclude(user=user)
|
||||
.values("user")
|
||||
.annotate(last_shared=db.Max("created_at"))
|
||||
)
|
||||
return {item["user"]: item["last_shared"] for item in shared_qs}
|
||||
|
||||
|
||||
def extract_email_domain_parts(email):
|
||||
"""
|
||||
Extracts the full domain and partial domain from an email address as a tuple.
|
||||
The partial domain consists of the last two segments of the domain, eg. "gouv.fr".
|
||||
|
||||
If the email is invalid (eg, is an empty string), returns empty strings.
|
||||
"""
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError:
|
||||
return "", ""
|
||||
|
||||
domain = email.split("@", 1)[1].lower()
|
||||
parts = domain.split(".")
|
||||
partial_domain = ".".join(parts[-2:]) if len(parts) >= 2 else domain
|
||||
return domain, partial_domain
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
|
||||
"PO-Revision-Date: 2026-01-13 13:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Breton\n"
|
||||
"Language: br_FR\n"
|
||||
@@ -79,7 +79,7 @@ msgstr "Doare korf"
|
||||
msgid "Format"
|
||||
msgstr "Stumm"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "eilenn {title}"
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
|
||||
"PO-Revision-Date: 2026-01-13 13:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -79,7 +79,7 @@ msgstr "Typ"
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "Kopie von {title}"
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
|
||||
"PO-Revision-Date: 2026-01-13 13:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -79,7 +79,7 @@ msgstr ""
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
|
||||
"PO-Revision-Date: 2026-01-13 13:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Spanish\n"
|
||||
"Language: es_ES\n"
|
||||
@@ -79,7 +79,7 @@ msgstr "Tipo de Cuerpo"
|
||||
msgid "Format"
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copia de {title}"
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
|
||||
"PO-Revision-Date: 2026-01-13 13:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -79,7 +79,7 @@ msgstr "Type de corps"
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copie de {title}"
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
|
||||
"PO-Revision-Date: 2026-01-13 13:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Italian\n"
|
||||
"Language: it_IT\n"
|
||||
@@ -79,7 +79,7 @@ msgstr ""
|
||||
msgid "Format"
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copia di {title}"
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
|
||||
"PO-Revision-Date: 2026-01-13 13:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
@@ -79,7 +79,7 @@ msgstr "Text type"
|
||||
msgid "Format"
|
||||
msgstr "Formaat"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "kopie van {title}"
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
|
||||
"PO-Revision-Date: 2026-01-13 13:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Portuguese\n"
|
||||
"Language: pt_PT\n"
|
||||
@@ -79,7 +79,7 @@ msgstr "Tipo de corpo"
|
||||
msgid "Format"
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "cópia de {title}"
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
|
||||
"PO-Revision-Date: 2026-01-13 13:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Russian\n"
|
||||
"Language: ru_RU\n"
|
||||
@@ -79,7 +79,7 @@ msgstr "Тип сообщения"
|
||||
msgid "Format"
|
||||
msgstr "Формат"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "копия {title}"
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
|
||||
"PO-Revision-Date: 2026-01-13 13:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Slovenian\n"
|
||||
"Language: sl_SI\n"
|
||||
@@ -79,7 +79,7 @@ msgstr "Vrsta telesa"
|
||||
msgid "Format"
|
||||
msgstr "Oblika"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
|
||||
"PO-Revision-Date: 2026-01-13 13:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Swedish\n"
|
||||
"Language: sv_SE\n"
|
||||
@@ -79,7 +79,7 @@ msgstr ""
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
|
||||
"PO-Revision-Date: 2026-01-13 13:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Turkish\n"
|
||||
"Language: tr_TR\n"
|
||||
@@ -79,7 +79,7 @@ msgstr ""
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
|
||||
"PO-Revision-Date: 2026-01-13 13:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Ukrainian\n"
|
||||
"Language: uk_UA\n"
|
||||
@@ -79,7 +79,7 @@ msgstr "Тип вмісту"
|
||||
msgid "Format"
|
||||
msgstr "Формат"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "копія {title}"
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-16 21:44+0000\n"
|
||||
"PO-Revision-Date: 2026-01-05 08:21\n"
|
||||
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
|
||||
"PO-Revision-Date: 2026-01-13 13:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Chinese Simplified\n"
|
||||
"Language: zh_CN\n"
|
||||
@@ -79,7 +79,7 @@ msgstr "正文类型"
|
||||
msgid "Format"
|
||||
msgstr "格式"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1024 core/api/viewsets.py:1024
|
||||
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "{title} 的副本"
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "4.3.0"
|
||||
version = "4.4.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "[email protected]" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -28,7 +28,7 @@ dependencies = [
|
||||
"beautifulsoup4==4.14.3",
|
||||
"boto3==1.42.17",
|
||||
"Brotli==1.2.0",
|
||||
"celery[redis]==5.6.0",
|
||||
"celery[redis]==5.5.3",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.2.0",
|
||||
|
||||
@@ -58,7 +58,7 @@ test.describe('Doc Comments', () => {
|
||||
await page.getByRole('button', { name: '👍' }).click();
|
||||
|
||||
await expect(
|
||||
thread.getByRole('img', { name: 'E2E Chromium' }).first(),
|
||||
thread.getByRole('img', { name: `E2E ${browserName}` }).first(),
|
||||
).toBeVisible();
|
||||
await expect(thread.getByText('This is a comment').first()).toBeVisible();
|
||||
await expect(thread.getByText(`E2E ${browserName}`).first()).toBeVisible();
|
||||
|
||||
@@ -21,7 +21,7 @@ test.describe('Inherited share accesses', () => {
|
||||
`doc-share-member-row-user.test@${browserName}.test`,
|
||||
);
|
||||
await expect(user).toBeVisible();
|
||||
await expect(user.getByText('E2E Chromium')).toBeVisible();
|
||||
await expect(user.getByText(`E2E ${browserName}`)).toBeVisible();
|
||||
await expect(user.getByText('Owner')).toBeVisible();
|
||||
|
||||
await page
|
||||
|
||||
@@ -224,7 +224,9 @@ export const updateDocTitle = async (page: Page, title: string) => {
|
||||
await expect(input).toHaveText('');
|
||||
await expect(input).toBeVisible();
|
||||
await input.click();
|
||||
await input.fill(title);
|
||||
await input.fill(title, {
|
||||
force: true,
|
||||
});
|
||||
await input.click();
|
||||
await input.blur();
|
||||
await verifyDocName(page, title);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "4.3.0",
|
||||
"version": "4.4.0",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "4.3.0",
|
||||
"version": "4.4.0",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
@@ -68,6 +68,7 @@
|
||||
"react-select": "5.10.2",
|
||||
"styled-components": "6.1.19",
|
||||
"use-debounce": "10.0.6",
|
||||
"uuid": "13.0.0",
|
||||
"y-protocols": "1.0.7",
|
||||
"yjs": "*",
|
||||
"zustand": "5.0.9"
|
||||
|
||||
+87
-34
@@ -1,21 +1,23 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import {
|
||||
PartialCustomInlineContentFromConfig,
|
||||
StyleSchema,
|
||||
} from '@blocknote/core';
|
||||
import { createReactInlineContentSpec } from '@blocknote/react';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
import { validate as uuidValidate } from 'uuid';
|
||||
|
||||
import { BoxButton, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg';
|
||||
import { getEmojiAndTitle, useDoc } from '@/docs/doc-management';
|
||||
import { getEmojiAndTitle, useDoc } from '@/docs/doc-management/';
|
||||
|
||||
export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
|
||||
{
|
||||
type: 'interlinkingLinkInline',
|
||||
propSchema: {
|
||||
url: {
|
||||
default: '',
|
||||
},
|
||||
docId: {
|
||||
default: '',
|
||||
},
|
||||
@@ -27,46 +29,97 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
|
||||
},
|
||||
{
|
||||
render: ({ editor, inlineContent, updateInlineContent }) => {
|
||||
const { data: doc } = useDoc({ id: inlineContent.props.docId });
|
||||
const isEditable = editor.isEditable;
|
||||
if (!inlineContent.props.docId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the content title if the referenced doc title changes
|
||||
* Should not happen
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (
|
||||
isEditable &&
|
||||
doc?.title &&
|
||||
doc.title !== inlineContent.props.title
|
||||
) {
|
||||
updateInlineContent({
|
||||
type: 'interlinkingLinkInline',
|
||||
props: {
|
||||
...inlineContent.props,
|
||||
title: doc.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!uuidValidate(inlineContent.props.docId)) {
|
||||
Sentry.captureException(
|
||||
new Error(`Invalid docId: ${inlineContent.props.docId}`),
|
||||
{
|
||||
extra: { info: 'InterlinkingLinkInlineContent' },
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* ⚠️ When doing collaborative editing, doc?.title might be out of sync
|
||||
* causing an infinite loop of updates.
|
||||
* To prevent this, we only run this effect when doc?.title changes,
|
||||
* not when inlineContent.props.title changes.
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [doc?.title, isEditable]);
|
||||
updateInlineContent({
|
||||
type: 'interlinkingLinkInline',
|
||||
props: {
|
||||
docId: '',
|
||||
title: '',
|
||||
},
|
||||
});
|
||||
|
||||
return <LinkSelected {...inlineContent.props} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkSelected
|
||||
docId={inlineContent.props.docId}
|
||||
title={inlineContent.props.title}
|
||||
isEditable={editor.isEditable}
|
||||
updateInlineContent={updateInlineContent}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface LinkSelectedProps {
|
||||
url: string;
|
||||
docId: string;
|
||||
title: string;
|
||||
isEditable: boolean;
|
||||
updateInlineContent: (
|
||||
update: PartialCustomInlineContentFromConfig<
|
||||
{
|
||||
readonly type: 'interlinkingLinkInline';
|
||||
readonly propSchema: {
|
||||
readonly docId: {
|
||||
readonly default: '';
|
||||
};
|
||||
readonly title: {
|
||||
readonly default: '';
|
||||
};
|
||||
};
|
||||
readonly content: 'none';
|
||||
},
|
||||
StyleSchema
|
||||
>,
|
||||
) => void;
|
||||
}
|
||||
const LinkSelected = ({ url, title }: LinkSelectedProps) => {
|
||||
export const LinkSelected = ({
|
||||
docId,
|
||||
title,
|
||||
isEditable,
|
||||
updateInlineContent,
|
||||
}: LinkSelectedProps) => {
|
||||
const { data: doc } = useDoc({ id: docId });
|
||||
|
||||
/**
|
||||
* Update the content title if the referenced doc title changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isEditable && doc?.title && doc.title !== title) {
|
||||
updateInlineContent({
|
||||
type: 'interlinkingLinkInline',
|
||||
props: {
|
||||
docId,
|
||||
title: doc.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ When doing collaborative editing, doc?.title might be out of sync
|
||||
* causing an infinite loop of updates.
|
||||
* To prevent this, we only run this effect when doc?.title changes,
|
||||
* not when inlineContent.props.title changes.
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [doc?.title, docId, isEditable]);
|
||||
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title);
|
||||
@@ -74,7 +127,7 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
void router.push(url);
|
||||
void router.push(`/docs/${docId}/`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
-1
@@ -247,7 +247,6 @@ export const SearchPage = ({
|
||||
{
|
||||
type: 'interlinkingLinkInline',
|
||||
props: {
|
||||
url: `/docs/${doc.id}`,
|
||||
docId: doc.id,
|
||||
title: doc.title || untitledDocument,
|
||||
},
|
||||
|
||||
+10
-2
@@ -1,16 +1,24 @@
|
||||
import { ExternalHyperlink, TextRun } from 'docx';
|
||||
|
||||
import { getEmojiAndTitle } from '@/docs/doc-management';
|
||||
|
||||
import { DocsExporterDocx } from '../types';
|
||||
|
||||
export const inlineContentMappingInterlinkingLinkDocx: DocsExporterDocx['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
|
||||
(inline) => {
|
||||
if (!inline.props.docId) {
|
||||
return new TextRun('');
|
||||
}
|
||||
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title);
|
||||
|
||||
return new ExternalHyperlink({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `📄${inline.props.title}`,
|
||||
text: `${emoji || '📄'}${titleWithoutEmoji}`,
|
||||
bold: true,
|
||||
}),
|
||||
],
|
||||
link: window.location.origin + inline.props.url,
|
||||
link: window.location.origin + `/docs/${inline.props.docId}/`,
|
||||
});
|
||||
};
|
||||
|
||||
+9
-3
@@ -1,11 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getEmojiAndTitle } from '@/docs/doc-management';
|
||||
|
||||
import { DocsExporterODT } from '../types';
|
||||
|
||||
export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
|
||||
(inline) => {
|
||||
const url = window.location.origin + inline.props.url;
|
||||
const title = inline.props.title;
|
||||
if (!inline.props.docId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title);
|
||||
const url = window.location.origin + `/docs/${inline.props.docId}/`;
|
||||
|
||||
// Create ODT hyperlink using React.createElement to avoid TypeScript JSX namespace issues
|
||||
// Uses the same structure as BlockNote's default link mapping
|
||||
@@ -18,6 +24,6 @@ export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings'
|
||||
xlinkShow: 'replace',
|
||||
xlinkHref: url,
|
||||
},
|
||||
`📄${title}`,
|
||||
`${emoji || '📄'}${titleWithoutEmoji}`,
|
||||
);
|
||||
};
|
||||
|
||||
+11
-3
@@ -1,21 +1,29 @@
|
||||
import { Image, Link, Text } from '@react-pdf/renderer';
|
||||
|
||||
import { getEmojiAndTitle } from '@/docs/doc-management';
|
||||
|
||||
import DocSelectedIcon from '../assets/doc-selected.png';
|
||||
import { DocsExporterPDF } from '../types';
|
||||
|
||||
export const inlineContentMappingInterlinkingLinkPDF: DocsExporterPDF['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
|
||||
(inline) => {
|
||||
if (!inline.props.docId) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title);
|
||||
|
||||
return (
|
||||
<Link
|
||||
src={window.location.origin + inline.props.url}
|
||||
src={window.location.origin + `/docs/${inline.props.docId}/`}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'black',
|
||||
}}
|
||||
>
|
||||
{' '}
|
||||
<Image src={DocSelectedIcon.src} />{' '}
|
||||
<Text>{inline.props.title}</Text>{' '}
|
||||
{emoji || <Image src={DocSelectedIcon.src} />}{' '}
|
||||
<Text>{titleWithoutEmoji}</Text>{' '}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -779,12 +779,12 @@
|
||||
"Document deleted": "Document supprimé",
|
||||
"Document duplicated successfully!": "Document dupliqué avec succès !",
|
||||
"Document editor": "Éditeur de document",
|
||||
"Document emoji": "Émoji de document",
|
||||
"Document owner": "Propriétaire du document",
|
||||
"Document role text": "Texte du rôle du document",
|
||||
"Document sections": "Sections du document",
|
||||
"Document title": "Titre du document",
|
||||
"Document tree": "Arborescence du document",
|
||||
"Document viewer": "Visualiseur de document",
|
||||
"Document visibility": "Visibilité du document",
|
||||
"Documents grid": "Grille des documents",
|
||||
"Docx": "Docx",
|
||||
@@ -826,6 +826,7 @@
|
||||
"Image 403": "Image 403",
|
||||
"Image: {{title}}": "Image : {{title}}",
|
||||
"Insufficient access rights to view the document.": "Droits d'accès insuffisants pour voir le document.",
|
||||
"Invalid or missing PDF file.": "Fichier PDF non valide ou manquant.",
|
||||
"Invite": "Inviter",
|
||||
"Invite new members": "Inviter de nouveaux membres",
|
||||
"Invite {{count}} members_many": "Inviter {{count}} membres",
|
||||
@@ -884,6 +885,7 @@
|
||||
"Others are editing. Your network prevent changes.": "D'autres sont en cours d'édition. Votre réseau empêche les changements.",
|
||||
"Owner": "Propriétaire",
|
||||
"PDF": "PDF",
|
||||
"PDF document": "Document PDF",
|
||||
"Page Not Found - Error 404": "Page introuvable - Erreur 404",
|
||||
"Pending invitations": "Invitations en attente",
|
||||
"People with access via the parent document": "Personnes ayant accès au document parent",
|
||||
@@ -1222,7 +1224,6 @@
|
||||
"Document sections": "Document secties",
|
||||
"Document title": "Documenttitel",
|
||||
"Document tree": "Boomstructuur document",
|
||||
"Document viewer": "Document-viewer",
|
||||
"Document visibility": "Document toegankelijkheid",
|
||||
"Documents grid": "Documenten overzicht",
|
||||
"Docx": "Docx",
|
||||
@@ -1264,6 +1265,7 @@
|
||||
"Image 403": "Afbeelding 403",
|
||||
"Image: {{title}}": "Afbeelding: {{title}}",
|
||||
"Insufficient access rights to view the document.": "Onvoldoende toegangsrechten om het document te bekijken.",
|
||||
"Invalid or missing PDF file.": "Ongeldig of ontbrekend PDF-bestand.",
|
||||
"Invite": "Uitnodigen",
|
||||
"Invite new members": "Nieuwe leden uitnodigen",
|
||||
"Invite {{count}} members_many": "Nodig {{count}} leden uit",
|
||||
@@ -1322,6 +1324,7 @@
|
||||
"Others are editing. Your network prevent changes.": "Anderen zijn aan het bewerken. Uw netwerk voorkomt wijzigingen.",
|
||||
"Owner": "Eigenaar",
|
||||
"PDF": "PDF",
|
||||
"PDF document": "Pdf-document",
|
||||
"Page Not Found - Error 404": "Pagina niet gevonden - Fout 404",
|
||||
"Pending invitations": "Openstaande uitnodigingen",
|
||||
"People with access via the parent document": "Gebruikers met toegang via het bovenliggend document",
|
||||
@@ -1516,12 +1519,12 @@
|
||||
"Document deleted": "Документ удалён",
|
||||
"Document duplicated successfully!": "Документ успешно дублирован!",
|
||||
"Document editor": "Редактор документа",
|
||||
"Document emoji": "Эмодзи документа",
|
||||
"Document owner": "Владелец документа",
|
||||
"Document role text": "Текст роли документа",
|
||||
"Document sections": "Разделы документа",
|
||||
"Document title": "Заголовок документа",
|
||||
"Document tree": "Иерархия документа",
|
||||
"Document viewer": "Просмотрщик документа",
|
||||
"Document visibility": "Видимость документа",
|
||||
"Documents grid": "Сетка документов",
|
||||
"Docx": "Docx",
|
||||
@@ -1563,6 +1566,7 @@
|
||||
"Image 403": "Изображение 403",
|
||||
"Image: {{title}}": "Изображение: {{title}}",
|
||||
"Insufficient access rights to view the document.": "Недостаточно прав доступа для просмотра документа.",
|
||||
"Invalid or missing PDF file.": "Повреждённый или отсутствующий PDF-файл.",
|
||||
"Invite": "Приглашение",
|
||||
"Invite new members": "Пригласить новых участников",
|
||||
"Invite {{count}} members_many": "Пригласить {{count}} участников",
|
||||
@@ -1621,6 +1625,7 @@
|
||||
"Others are editing. Your network prevent changes.": "Другие участники редактируют этот документ. Ваша сеть не позволяет вам присоединиться.",
|
||||
"Owner": "Владелец",
|
||||
"PDF": "PDF",
|
||||
"PDF document": "Документ PDF",
|
||||
"Page Not Found - Error 404": "Страница не найдена - ошибка 404",
|
||||
"Pending invitations": "Ожидающие приглашения",
|
||||
"People with access via the parent document": "Люди, имеющие доступ через родительский документ",
|
||||
@@ -1918,12 +1923,12 @@
|
||||
"Document deleted": "Документ видалено",
|
||||
"Document duplicated successfully!": "Документ успішно продубльовано!",
|
||||
"Document editor": "Редактор документа",
|
||||
"Document emoji": "Емодзі документу",
|
||||
"Document owner": "Власник документа",
|
||||
"Document role text": "Текст ролі документа",
|
||||
"Document sections": "Розділи документу",
|
||||
"Document title": "Назва документа",
|
||||
"Document tree": "Дерево документа",
|
||||
"Document viewer": "Переглядач документа",
|
||||
"Document visibility": "Видимість документа",
|
||||
"Documents grid": "Сітка документів",
|
||||
"Docx": "Docx",
|
||||
@@ -1965,6 +1970,7 @@
|
||||
"Image 403": "Зображення 403",
|
||||
"Image: {{title}}": "Зображення: {{title}}",
|
||||
"Insufficient access rights to view the document.": "Недостатньо прав для перегляду документа.",
|
||||
"Invalid or missing PDF file.": "Неприпустимий або відсутній PDF-файл.",
|
||||
"Invite": "Запрошення",
|
||||
"Invite new members": "Запросити нових учасників",
|
||||
"Invite {{count}} members_many": "Запросити {{count}} учасників",
|
||||
@@ -2023,6 +2029,7 @@
|
||||
"Others are editing. Your network prevent changes.": "Інші учасники редагують документ. Ваша мережа не дозволяє вам вносити зміни.",
|
||||
"Owner": "Власник",
|
||||
"PDF": "PDF",
|
||||
"PDF document": "Документ PDF",
|
||||
"Page Not Found - Error 404": "Сторінку не знайдено - Помилка 404",
|
||||
"Pending invitations": "Запрошення в очікуванні",
|
||||
"People with access via the parent document": "Люди з доступом через батьківський документ",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "impress",
|
||||
"version": "4.3.0",
|
||||
"version": "4.4.0",
|
||||
"private": true,
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eslint-plugin-docs",
|
||||
"version": "4.3.0",
|
||||
"version": "4.4.0",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "packages-i18n",
|
||||
"version": "4.3.0",
|
||||
"version": "4.4.0",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server-y-provider",
|
||||
"version": "4.3.0",
|
||||
"version": "4.4.0",
|
||||
"description": "Y.js provider for docs",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
environments:
|
||||
dev:
|
||||
values:
|
||||
- version: 4.3.0
|
||||
- version: 4.4.0
|
||||
feature:
|
||||
values:
|
||||
- version: 4.3.0
|
||||
- version: 4.4.0
|
||||
feature: ci
|
||||
domain: example.com
|
||||
imageTag: demo
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: docs
|
||||
version: 4.3.0
|
||||
version: 4.4.0
|
||||
appVersion: latest
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "4.3.0",
|
||||
"version": "4.4.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Reference in New Issue
Block a user