Compare commits

..

50 Commits

Author SHA1 Message Date
Anthony LC
0d0e17c8d5 🔖(minor) release 3.10.0
Added:
- (frontend) enable ODT export for documents
- (frontend) improve mobile UX by showing subdocs count

Changed:
- ♻️(frontend) preserve @ character when esc is pressed
  after typing it
- ♻️(frontend) make summary button fixed to remain visible
  during scroll
- ♻️(frontend) pdf embed use full width

Fixed:
- (frontend) improve accessibility:
  - (frontend) improve ARIA in doc grid and editor
    for a11y
  - (frontend) improve accessibility and styling
    of summary table
  - (frontend) add focus trap and enter key support
    to remove doc modal
- 🐛(docx) fix image overflow by limiting width to
  600px during export
- 🐛(frontend) fix fallback translations with Trans
- 🐛(pdf) fix table cell alignment issue in exported
  documents
- 🐛(pdf) preserve image aspect ratio in PDF export
2025-11-19 14:48:08 +01:00
Anthony LC
257de6d068 📌(frontend) pin Blocknote to 0.42.3
We were pinned to a specific package registry
coming from a pull request, the new version is now
published to npm, so we can use the version from there.
2025-11-19 14:48:08 +01:00
AntoLC
5a4c02a978 🌐(i18n) update translated strings
Update translated files with new translations
2025-11-19 12:26:04 +01:00
Manuel Raynaud
0090ccc981 🔥(backend) remove api managing templates
A complete API was able to manage templates lifecycle, from the creation
to the deletion and managing accesses on them. This API is not used by
the frontend application, is not finished. A connected user can interact
with this API and lead to unwanted behavior in the interface. Refering
ot issue #1222 templates can maybe totaly remove in the future. While
it's here and used, we only keep list and retrive endpoints. The
template management can still be done in the admin interface.
2025-11-19 11:23:31 +00:00
Anthony LC
d403878f8c 🐛(frontend) fix alignment of side menu
Recent refactoring moved the side menu position.
This commit fixes its alignment.
2025-11-19 10:39:35 +01:00
Anthony LC
191b046641 📌(frontend) pin Blocknote to fix mounting issue
We have a mounting issue with the Blocknote
version 0.42.1. To not wait for the next Blocknote
release, we pin the Blocknote packages
to a specific pull request version.
2025-11-19 10:05:25 +01:00
Anthony LC
aeac49d760 🔥(frontend) remove fix about color in export
The last version of Blocknote fixed the issue
with colors in exports.
We can remove our workaround now.
2025-11-18 16:19:56 +01:00
Anthony LC
b5dcbbb057 ⬆️(frontend) upgrade Blocknote to 0.42.1
We upgraded Blocknote to version 0.42.1 to benefit
from the latest features and bug fixes.
There is some compatibility issues with
tiptap extensions, so we had to pin tiptap
to "3.10.2" to ensure everything works smoothly.
There is as well some issues with prosemirror,
we pinned the problematic packages.
2025-11-18 16:19:56 +01:00
Cyril
2e64298ff4 (pdf) preserve image aspect ratio in PDF export
images were distorted in PDF exports; height is now computed to fix that

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-18 15:36:42 +01:00
Manuel Raynaud
8dad9ea6c4 🔧(helm) create a symlink between helmfile config
We want to have both helmfile.yaml and helmfile.gotmpl present.
helmfile.gotmpl is for now a symlink of helmfile.yaml
2025-11-18 11:10:03 +00:00
Anthony LC
3ae8046ffc 🐛(frontend) fix fallback translations with Trans
The fallback of the Trans component didn't work
anymore after upgrade to 16.2.3 of react-i18next.
Upgrading to 16.3.3 fixed the issue.
We added a test to cover this case.
2025-11-17 15:23:48 +01:00
Anthony LC
a4e3168682 🐛(export) fix export unsupported colors
Some colors bind to a text style are not supported.
It comes often from a paste style, we don't
display them if they are not supported by the
editor.
2025-11-17 10:33:17 +01:00
renovate[bot]
c8955133a4 ⬆️(dependencies) update python dependencies 2025-11-17 08:29:46 +00:00
Anthony LC
b069310bf0 🛂(frontend) disabled role not allowed to be assigned
We disable roles that the current user is not allowed
to assign when sharing a document. This prevents
users from selecting roles they cannot actually
assign, improving the user experience and reducing
confusion.
2025-11-17 08:48:16 +01:00
Manuel Raynaud
1292c33a58 ♻️(backend) rely on set_role_to from DocumentAskForAccess abilities
Like in other abilities, we compute a set_role_to property on the
abilities. This set_role_to contains all the roles lower or equal than
the current user role. We rely on this propoerty to validate the accept
endpoint and it will be used by the front allpication to built the role
select list.
2025-11-17 08:48:16 +01:00
Manuel Raynaud
bf68a5ae40 🔒️(backend) remove owner as valid role for ask_for_access serializer
When a ask_for_access creation is made, we explicitly remove the owner
role to prevent role escalation.
2025-11-17 08:47:15 +01:00
Manuel Raynaud
8799b4aa2f 🔒️(backend) role in ask_for_access must be lower than user role
We check that the role set in a ask_for_access is not higher than the
user's role accepting the request. We prevent case where ad min will
grant a user owner in order to take control of the document. Only owner
can accept an owner role.
2025-11-17 08:47:14 +01:00
Cyril
d96abb1ccf (frontend) make summary button fixed to remain visible during scroll
ensures persistent access to table of contents by fixing button position

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-14 13:42:56 +01:00
Cyril
dc12a99d4a (pdf) fix table cell alignment issue in exported documents
ensures correct horizontal alignment of text based on cell props

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-14 13:10:03 +01:00
Cyril
82a0c1a770 (frontend) add unit test for mobile rendering in docheaderinfo
ensures numchild count is displayed correctly on mobile interface

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-13 13:41:35 +01:00
Cyril
a758254b60 (frontend) improve mobile UX by showing subdocs count
helps users notice root documents have children in mobile view

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-13 13:41:25 +01:00
Cyril
6314cb3a18 (frontend) add focus trap and enter key support to remove doc modal
improves a11y by enabling keyboard-triggered modal with proper focus trap

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-13 12:59:11 +01:00
Cyril
3e410e3519 (frontend) enable ODT export for documents
provides ODT export with support for callout, upload, interlinking and tests

Signed-off-by: Cyril <c.gromoff@gmail.com>

(frontend) add image and interlinking support for odt export

Added image mapping with SVG conversion and clickable document links.

Signed-off-by: Cyril <c.gromoff@gmail.com>

(e2e) add e2e tests for odt export and interlinking features

covers odt document export and cross-section interlinking use cases

Signed-off-by: Cyril <c.gromoff@gmail.com>

(odt) add generic helper and style callout block for odt export

create odtRegisterParagraphStyleForBlock and apply background/padding styles

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-13 11:14:09 +01:00
Cyril
aba7959344 (frontend) fix pdf embed to use full width
Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-12 11:28:10 +01:00
Cyril
3d45c7c215 (frontend) improve accessibility and styling of summary table
adds semantic structure, aria attributes, and token-based focus styling

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-12 10:02:05 +01:00
Cyril
cdb26b480a (frontend) preserve @ character when esc is pressed after typing it
improves user experience by keeping @ symbol after cancelling mention trigger

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-12 09:24:14 +01:00
Cyril
23a0f2761f (docx) fix image overflow by limiting width to 600px during export
ensures all images keep proportions and stay within page bounds in docx export

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-12 08:40:05 +01:00
Cyril
0d596e338c (frontend) move editor button out of grid and fix roles/aria-label
improves accessibility and layout consistency of editor interface

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-10 18:13:33 +01:00
Anthony LC
3ab01c98c8 🔖(minor) release 3.9.0
Added
- (frontend) create skeleton component for DocEditor
- (frontend) add an EmojiPicker in the document
tree and title
- (frontend) ajustable left panel

Changed:
- ♻️(frontend) adapt custom blocks to new implementation
- ♻️(backend) increase user short_name field length
- 🚸(frontend) separate viewers from editors

Fixed:
- 🐛(frontend) fix duplicate document entries in grid
- 🐛(backend) fix trashbin list
- (frontend) improve accessibility:
  - (frontend) remove empty alt on logo due to Axe
  a11y error
- 🐛(backend) fix s3 version_id validation
- 🐛(frontend) retry check media status after page reload
- 🐛(frontend) fix Interlinking memory leak
- 🐛(frontend) button new doc UI fix
- 🐛(frontend) interlinking UI fix
2025-11-10 11:35:03 +01:00
AntoLC
6445c05e29 🌐(i18n) update translated strings
Update translated files with new translations
2025-11-10 11:35:03 +01:00
renovate[bot]
b9b25eb1f6 ⬆️(dependencies) update django to v5.2.8 [SECURITY] 2025-11-10 11:19:53 +01:00
renovate[bot]
de157b4f52 ⬆️(dependencies) update Brotli to v1.2.0 [SECURITY] 2025-11-10 11:19:53 +01:00
Anthony LC
e5581e52f7 ♻️(frontend) better handling css doc states
We simplify the way we handle different doc
states (deleted / readonly) in the CSS, we avoid
props drilling and are more component focused.
2025-11-07 15:31:05 +01:00
Anthony LC
b91840c819 🩹(frontend) set correctly query data when 401
When receiving a 401 error, we should set the
auth query data to null, not to an object
with user: null and authenticated: false.
This ensures that components relying on the
auth state can correctly interpret the
unauthenticated status.
2025-11-07 09:54:10 +01:00
Anthony LC
a9b77fb9a7 💄(frontend) visual improvements around the Icon
With time some visual inconsistencies have crept
into the DropButton and Icon component.
This commit aims to harmonize the appearance
with the design system.
2025-11-07 09:25:48 +01:00
Anthony LC
66f83db0e5 (frontend) add "Add Emoji" button to doc options menu
- Add "Add Emoji" button to doc options menu
- Remove default emoji when none selected
- Improve doc options styling
2025-11-07 09:24:58 +01:00
Anthony LC
f9ff578c6b 🥅(frontend) improve error handling during upload
Catch and log errors when replacing blocks during
file upload.
2025-11-06 13:21:36 +01:00
Anthony LC
1372438f8e 🐛(frontend) fix memory leak in Interlinking
When doing collaborative editing, doc?.title might
be out of sync for other users when updated by
another user.
This causes the useEffect to run repeatedly,
causing an infinite loop of updates.
We now trigger the effect only when doc?.title changes,
not when the customInlineContent changes.
2025-11-06 13:21:36 +01:00
Anthony LC
c5d5d3dec4 ️(frontend) improve unsubscribe logic when unmounting components
We can now unsubscribe on the editor events,
improving performance and preventing memory leaks.
2025-11-06 13:21:35 +01:00
Anthony LC
ad16c0843c (test) adapt tests with updated dependencies
- update e2e tests to match changed function signatures
- remove unused pdf-parse type definitions
- fix type error in hocuspocusWS tests
2025-11-06 12:43:34 +01:00
renovate[bot]
78a6307656 ⬆️(dependencies) update js dependencies 2025-11-05 16:17:04 +01:00
Anthony LC
d7d468f51f ✏️(frontend) fix typo listInvalidQueries
In many places the property name "listInvalidQueries"
was misspelled.
2025-11-05 12:23:35 +01:00
Anthony LC
eb71028f6b 🚸(frontend) separate viewers from editors
We are now totally separating the viewers with
the editors. We will not load the provider
when we are in viewer mode, meaning the
viewers will not be aware of other users and
will not show their cursors anymore.
We still get the document updates in real-time.
2025-11-05 12:23:35 +01:00
Anthony LC
39c22b074d 🚚(frontend) better separation concern doc-versioning features
We move some components related to doc versioning
into the doc-versioning feature folder to have a
better separation of concerns.
We don't need a provider for the doc versioning components
since they will receive the doc data directly via
a request.
2025-11-05 12:09:20 +01:00
Anthony LC
d5c3f248a5 🐛(frontend) fix flaky test
Depend the month the test could fail if the current
month has 30 or 31 days, so change 30 to 35 to be sure
that it will always be at least 1 month ago.
2025-11-04 10:28:48 +01:00
Anthony LC
91217b3c4f 🐛(frontend) retry check media status after page reload
Previous refactoring removed the retry logic for
checking media status after a page reload.
This commit reintroduces that functionality
to ensure uploads are properly processed even after
a page reload. We improve the test coverage
to validate this behavior.
2025-11-04 10:28:48 +01:00
Manuel Raynaud
ab271bc90d (backend) fix test_backend authentication test suite
The last version of django-lasuite introduces a modification on the
query made. We have to update the tests because there is one more sql
query made.
2025-10-30 15:30:12 +01:00
Manuel Raynaud
82e1783317 📌(backend) pint pylint to version < 4.0.0
django-pylint is not compatible with pylint 4. We have to pin the pylint
version while the compatibility is not effective.
See https://github.com/pylint-dev/pylint-django/issues/467
2025-10-30 15:30:12 +01:00
renovate[bot]
aa2b9ed5f2 ⬆️(dependencies) update python dependencies 2025-10-30 15:30:12 +01:00
Manuel Raynaud
1c96d645ba 🐛(backend) fix s3 version_id validation
The regex used on the version_detail endpoint path is not fully
compatible with the S3 spec. In the S3 specs, Version IDs are Unicode,
UTF-8 encoded, URL-ready, opaque strings that are no more than 1,024
bytes long. We don't accept all unicode characters but enough to be
compliant.
2025-10-30 15:29:11 +01:00
129 changed files with 4076 additions and 4390 deletions

View File

@@ -6,23 +6,66 @@ and this project adheres to
## [Unreleased]
## [3.10.0] - 2025-11-18
### Added
- ✨(export) enable ODT export for documents #1524
- ✨(frontend) improve mobile UX by showing subdocs count #1540
### Changed
- ♻️(frontend) preserve @ character when esc is pressed after typing it #1512
- ♻️(frontend) make summary button fixed to remain visible during scroll #1581
- ♻️(frontend) pdf embed use full width #1526
### Fixed
- ♿(frontend) improve accessibility:
- ♿(frontend) improve ARIA in doc grid and editor for a11y #1519
- ♿(frontend) improve accessibility and styling of summary table #1528
- ♿(frontend) add focus trap and enter key support to remove doc modal #1531
- 🐛(frontend) fix alignment of side menu #1597
- 🐛(frontend) fix fallback translations with Trans #1620
- 🐛(export) fix image overflow by limiting width to 600px during export #1525
- 🐛(export) fix table cell alignment issue in exported documents #1582
- 🐛(export) preserve image aspect ratio in PDF export #1622
- 🐛(export) Export fails when paste with style #1552
### Security
- mitigate role escalation in the ask_for_access viewset #1580
### Removed
- 🔥(backend) remove api managing templates
## [3.9.0] - 2025-11-10
### Added
- ✨(frontend) create skeleton component for DocEditor #1491
- ✨(frontend) add an EmojiPicker in the document tree and title #1381
- ✨(frontend) ajustable left panel #1456
### Changed
- ♻️(frontend) adapt custom blocks to new implementation #1375
- ♻️(backend) increase user short_name field length
- ♻️(backend) increase user short_name field length #1510
- 🚸(frontend) separate viewers from editors #1509
### Fixed
- 🐛(frontend) fix duplicate document entries in grid #1479
- 🐛(frontend) show full nested doc names with ajustable bar #1456
- 🐛(backend) fix trashbin list
- 🐛(backend) fix trashbin list #1520
- ♿(frontend) improve accessibility:
- ♿(frontend) remove empty alt on logo due to Axe a11y error #1516
- 🐛(backend) fix s3 version_id validation #1543
- 🐛(frontend) retry check media status after page reload #1555
- 🐛(frontend) fix Interlinking memory leak #1560
- 🐛(frontend) button new doc UI fix #1557
- 🐛(frontend) interlinking UI fix #1557
## [3.8.2] - 2025-10-17
@@ -835,7 +878,9 @@ 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/v3.8.2...main
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.10.0...main
[v3.10.0]: https://github.com/suitenumerique/docs/releases/v3.10.0
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1
[v3.8.0]: https://github.com/suitenumerique/docs/releases/v3.8.0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -85,44 +85,4 @@ THEME_CUSTOMIZATION_FILE_PATH=<path>
### Example of JSON
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
----
# **Custom Export Templates** 📄
You can define custom export templates to add introductory content, such as headers or titles, to documents before exporting them as PDF, Docx, etc...
Export Templates are managed through the admin interface and can be selected by users during the export process.
### Benefits
This feature offers several advantages:
* **Header customization** 📄: Add custom headers, titles, or branding to exported documents.
* **No code changes required** 🔧: Templates are managed through the admin interface without needing developer intervention.
* **Flexible content** 🌟: Use HTML to create headers that match your organization's style.
### Limitations ⚠️
- Currently, templates are only prepended to the document.
More complex layouts are not supported at this time.
- The `CSS` and `Description` fields are being ignored at this time.
- <b>Due to technical conversion limitations, not all HTML can be converted to the internal format!</b>
### How to Use
1. Create the Template in a new document.
![Build Template in Editor](./assets/export-template-tutorial/build-template-in-editor.png)
2. Copy it as HTML code using the `Copy as HTML` feature:
![Three Dots Copy as HTML](./assets/export-template-tutorial/three-dots-copy-as-html.png)
3. Log in to the admin interface at `/admin` (backend container).
2. Create a new template:
![Admin Create Template Workflow](./assets/export-template-tutorial/admin-create-template-workflow.png)
- **Title**: Enter a descriptive name for the template.
- **Code**: Paste the HTML content from step 2.
- **Public**: Check this box to make the template available in the frontend export modal.
- **Save** the template.
Once saved, users can select the template from the export modal in the frontend during the export process.
![Select Template in Editor](./assets/export-template-tutorial/select-template-in-editor.png)
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json

View File

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

View File

@@ -786,7 +786,9 @@ class DocumentAskForAccessCreateSerializer(serializers.Serializer):
"""Serializer for creating a document ask for access."""
role = serializers.ChoiceField(
choices=models.RoleChoices.choices,
choices=[
role for role in choices.RoleChoices if role != models.RoleChoices.OWNER
],
required=False,
default=models.RoleChoices.READER,
)
@@ -810,11 +812,11 @@ class DocumentAskForAccessSerializer(serializers.ModelSerializer):
]
read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"]
def get_abilities(self, invitation) -> dict:
def get_abilities(self, instance) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return invitation.get_abilities(request.user)
return instance.get_abilities(request.user)
return {}

View File

@@ -1105,7 +1105,7 @@ class DocumentViewSet(
@drf.decorators.action(
detail=True,
methods=["get", "delete"],
url_path="versions/(?P<version_id>[0-9a-z-]+)",
url_path=r"versions/(?P<version_id>[A-Za-z0-9._+\-=~]{1,1024})",
)
# pylint: disable=unused-argument
def versions_detail(self, request, pk, version_id, *args, **kwargs):
@@ -1831,10 +1831,7 @@ class DocumentAccessViewSet(
class TemplateViewSet(
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Template ViewSet"""
@@ -1890,100 +1887,6 @@ class TemplateViewSet(
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
@transaction.atomic
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
obj = serializer.save()
models.TemplateAccess.objects.create(
template=obj,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
class TemplateAccessViewSet(
ResourceAccessViewsetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with template accesses.
GET /api/v1.0/templates/<template_id>/accesses/:<template_access_id>
Return list of all template accesses related to the logged-in user or one
template access if an id is provided.
POST /api/v1.0/templates/<template_id>/accesses/ with expected data:
- user: str
- role: str [administrator|editor|reader]
Return newly created template access
PUT /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return updated template access
PATCH /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return partially updated template access
DELETE /api/v1.0/templates/<template_id>/accesses/<template_access_id>/
Delete targeted template access
"""
lookup_field = "pk"
permission_classes = [permissions.ResourceAccessPermission]
throttle_scope = "template_access"
queryset = models.TemplateAccess.objects.select_related("user").all()
resource_field_name = "template"
serializer_class = serializers.TemplateAccessSerializer
@cached_property
def template(self):
"""Get related template from resource ID in url."""
try:
return models.Template.objects.get(pk=self.kwargs["resource_id"])
except models.Template.DoesNotExist as excpt:
raise drf.exceptions.NotFound() from excpt
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
user = self.request.user
teams = user.teams
queryset = self.filter_queryset(self.get_queryset())
# Limit to resource access instances related to a resource THAT also has
# a resource access instance for the logged-in user (we don't want to list
# only the resource access instances pointing to the logged-in user)
queryset = queryset.filter(
db.Q(template__accesses__user=user)
| db.Q(template__accesses__team__in=teams),
).distinct()
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
def perform_create(self, serializer):
"""
Actually create the new template access:
- Ensures the `template_id` is explicitly set from the URL.
- If the assigned role is `OWNER`, checks that the requesting user is an owner
of the document. This is the only permission check deferred until this step;
all other access checks are handled earlier in the permission lifecycle.
"""
role = serializer.validated_data.get("role")
if (
role == choices.RoleChoices.OWNER
and self.template.get_role(self.request.user) != choices.RoleChoices.OWNER
):
raise drf.exceptions.PermissionDenied(
"Only owners of a template can assign other users as owners."
)
serializer.save(template_id=self.kwargs["resource_id"])
class InvitationViewset(
drf.mixins.CreateModelMixin,
@@ -2162,7 +2065,18 @@ class DocumentAskForAccessViewSet(
serializer = serializers.RoleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
document_ask_for_access.accept(role=serializer.validated_data.get("role"))
target_role = serializer.validated_data.get(
"role", document_ask_for_access.role
)
abilities = document_ask_for_access.get_abilities(request.user)
if target_role not in abilities["set_role_to"]:
return drf.response.Response(
{"detail": "You cannot accept a role higher than your own."},
status=drf.status.HTTP_400_BAD_REQUEST,
)
document_ask_for_access.accept(role=target_role)
return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)

View File

@@ -1205,23 +1205,14 @@ class DocumentAskForAccess(BaseModel):
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
roles = []
user_role = self.document.get_role(user)
is_admin_or_owner = user_role in PRIVILEGED_ROLES
if user.is_authenticated:
teams = user.teams
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.document.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (self._meta.model.DoesNotExist, IndexError):
roles = []
is_admin_or_owner = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
set_role_to = [
role
for role in RoleChoices.values
if RoleChoices.get_priority(role) <= RoleChoices.get_priority(user_role)
]
return {
"destroy": is_admin_or_owner,
@@ -1229,6 +1220,7 @@ class DocumentAskForAccess(BaseModel):
"partial_update": is_admin_or_owner,
"retrieve": is_admin_or_owner,
"accept": is_admin_or_owner,
"set_role_to": set_role_to,
}
def accept(self, role=None):

View File

@@ -57,7 +57,7 @@ def test_authentication_getter_existing_user_via_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(3): # user by sub, user by mail, update sub
with django_assert_num_queries(4): # user by sub, user by mail, update sub
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -214,7 +214,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
with django_assert_num_queries(3):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -256,7 +256,7 @@ def test_authentication_getter_existing_user_change_fields_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(3):
with django_assert_num_queries(4):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)

View File

@@ -115,7 +115,10 @@ def test_api_documents_ask_for_access_create_authenticated_non_root_document():
assert response.status_code == 404
def test_api_documents_ask_for_access_create_authenticated_specific_role():
@pytest.mark.parametrize(
"role", [role for role in RoleChoices if role != RoleChoices.OWNER]
)
def test_api_documents_ask_for_access_create_authenticated_specific_role(role):
"""
Authenticated users should be able to create a document ask for access with a specific role.
"""
@@ -127,17 +130,35 @@ def test_api_documents_ask_for_access_create_authenticated_specific_role():
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": RoleChoices.EDITOR},
data={"role": role},
)
assert response.status_code == 201
assert DocumentAskForAccess.objects.filter(
document=document,
user=user,
role=RoleChoices.EDITOR,
role=role,
).exists()
def test_api_documents_ask_for_access_create_authenticated_owner_role():
"""
Authenticated users should not be able to create a document ask for access with the owner role.
"""
document = DocumentFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": RoleChoices.OWNER},
)
assert response.status_code == 400
assert response.json() == {"role": ['"owner" is not a valid choice.']}
def test_api_documents_ask_for_access_create_authenticated_already_has_access():
"""Authenticated users with existing access can ask for access with a different role."""
user = UserFactory()
@@ -266,6 +287,7 @@ def test_api_documents_ask_for_access_list_authenticated_own_request():
"update": False,
"partial_update": False,
"retrieve": False,
"set_role_to": [],
},
}
],
@@ -335,6 +357,15 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
expected_set_role_to = [
RoleChoices.READER,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
if role == RoleChoices.OWNER:
expected_set_role_to.append(RoleChoices.OWNER)
assert response.json() == {
"count": 3,
"next": None,
@@ -354,6 +385,7 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
"update": True,
"partial_update": True,
"retrieve": True,
"set_role_to": expected_set_role_to,
},
}
for document_ask_for_access in document_ask_for_accesses
@@ -446,6 +478,13 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 200
expected_set_role_to = [
RoleChoices.READER,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
if role == RoleChoices.OWNER:
expected_set_role_to.append(RoleChoices.OWNER)
assert response.json() == {
"id": str(document_ask_for_access.id),
"document": str(document.id),
@@ -460,6 +499,7 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
"update": True,
"partial_update": True,
"retrieve": True,
"set_role_to": expected_set_role_to,
},
}
@@ -749,6 +789,53 @@ def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update
assert document_access.role == RoleChoices.ADMIN
def test_api_documents_ask_for_access_accept_admin_cannot_accept_owner_role():
"""
Admin users should not be able to accept document ask for access with the owner role.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, RoleChoices.ADMIN)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
data={"role": RoleChoices.OWNER},
)
assert response.status_code == 400
assert response.json() == {
"detail": "You cannot accept a role higher than your own."
}
def test_api_documents_ask_for_access_accept_owner_can_accept_owner_role():
"""
Owner users should be able to accept document ask for access with the owner role.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, RoleChoices.OWNER)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
data={"role": RoleChoices.OWNER},
)
assert response.status_code == 204
assert not DocumentAskForAccess.objects.filter(
id=document_ask_for_access.id
).exists()
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_accept_authenticated_non_root_document(role):
"""

View File

@@ -1,799 +0,0 @@
"""
Test template accesses API endpoints for users in impress's core app.
"""
import random
from unittest import mock
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_template_accesses_list_anonymous():
"""Anonymous users should not be allowed to list template accesses."""
template = factories.TemplateFactory()
factories.UserTemplateAccessFactory.create_batch(2, template=template)
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_template_accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list template accesses for a template
to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
factories.UserTemplateAccessFactory.create_batch(3, template=template)
# Accesses for other templates to which the user is related should not be listed either
other_access = factories.UserTemplateAccessFactory(user=user)
factories.UserTemplateAccessFactory(template=other_access.template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == []
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
"""
Authenticated users should be able to list template accesses for a template
to which they are directly related, whatever their role in the template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
user_access = None
if via == USER:
user_access = models.TemplateAccess.objects.create(
template=template,
user=user,
role=random.choice(models.RoleChoices.values),
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
user_access = models.TemplateAccess.objects.create(
template=template,
team="lasuite",
role=random.choice(models.RoleChoices.values),
)
access1 = factories.TeamTemplateAccessFactory(template=template)
access2 = factories.UserTemplateAccessFactory(template=template)
# Accesses for other templates to which the user is related should not be listed either
other_access = factories.UserTemplateAccessFactory(user=user)
factories.UserTemplateAccessFactory(template=other_access.template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/",
)
assert response.status_code == 200
content = response.json()
assert len(content) == 3
assert sorted(content, key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": str(user.id) if via == "user" else None,
"team": "lasuite" if via == "team" else "",
"role": user_access.role,
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": None,
"team": access1.team,
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_template_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a template access.
"""
access = factories.UserTemplateAccessFactory()
response = APIClient().get(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_template_accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a template access for
a template to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access = factories.UserTemplateAccessFactory(template=template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Accesses related to another template should be excluded even if the user is related to it
for access in [
factories.UserTemplateAccessFactory(),
factories.UserTemplateAccessFactory(user=user),
]:
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 404
assert response.json() == {
"detail": "No TemplateAccess matches the given query."
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_teams):
"""
A user who is related to a template should be allowed to retrieve the
associated template user accesses.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(template=template, team="lasuite")
access = factories.UserTemplateAccessFactory(template=template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": str(access.user.id),
"team": "",
"role": access.role,
"abilities": access.get_abilities(user),
}
def test_api_template_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a template access."""
access = factories.UserTemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 401
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_template_accesses_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a template access for a template to which
they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
access = factories.UserTemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_authenticated_editor_or_reader(
via, role, mock_user_teams
):
"""Editors or readers of a template should not be allowed to update its accesses."""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
access = factories.UserTemplateAccessFactory(template=template)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_except_owner(via, mock_user_teams):
"""
A user who is a direct administrator in a template should be allowed to update a user
access for this template, as long as they don't try to set the role to owner.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(
template=template,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "editor", "reader"]),
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_from_owner(via, mock_user_teams):
"""
A user who is an administrator in a template, should not be allowed to update
the user access of an "owner" for this template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template, user=other_user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_to_owner(via, mock_user_teams):
"""
A user who is an administrator in a template, should not be allowed to update
the user access of another user to grant template ownership.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template,
user=other_user,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": "owner",
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner(via, mock_user_teams):
"""
A user who is an owner in a template should be allowed to update
a user access for this template whatever the role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template,
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner_self(via, mock_user_teams):
"""
A user who is owner of a template should be allowed to update
their own user access provided there are other owners in the template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
else:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "editor", "reader"])
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
assert access.role == "owner"
# Add another owner and it should now work
factories.UserTemplateAccessFactory(template=template, role="owner")
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
# Delete
def test_api_template_accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a template access."""
access = factories.UserTemplateAccessFactory()
response = APIClient().delete(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert models.TemplateAccess.objects.count() == 1
def test_api_template_accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a template access for a
template to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
access = factories.UserTemplateAccessFactory()
response = client.delete(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_teams):
"""
Authenticated users should not be allowed to delete a template access for a
template in which they are a simple editor or reader.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
access = factories.UserTemplateAccessFactory(template=template)
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrators_except_owners(
via, mock_user_teams
):
"""
Users who are administrators in a template should be allowed to delete an access
from the template provided it is not ownership.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(
template=template, role=random.choice(["reader", "editor", "administrator"])
)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TemplateAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_teams):
"""
Users who are administrators in a template should not be allowed to delete an ownership
access from the template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(template=template, role="owner")
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners(via, mock_user_teams):
"""
Users should be able to delete the template access of another user
for a template of which they are owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
access = factories.UserTemplateAccessFactory(template=template)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TemplateAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
"""
It should not be possible to delete the last owner access from a template
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
assert models.TemplateAccess.objects.count() == 2
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
def test_api_template_accesses_throttling(settings):
"""Test api template accesses throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template_access"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template_access"] = "2/minute"
template = factories.TemplateFactory()
user = factories.UserFactory()
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope template_access", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template_access"] = current_rate

View File

@@ -1,206 +0,0 @@
"""
Test template accesses create API endpoint for users in impress's core app.
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_template_accesses_create_anonymous():
"""Anonymous users should not be allowed to create template accesses."""
template = factories.TemplateFactory()
other_user = factories.UserFactory()
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"template": str(template.id),
"role": random.choice(models.RoleChoices.values),
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TemplateAccess.objects.exists() is False
def test_api_template_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create template accesses for a template to
which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
template = factories.TemplateFactory()
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_editor_or_reader(
via, role, mock_user_teams
):
"""Editors or readers of a template should not be allowed to create template accesses."""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": new_role,
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_administrator(via, mock_user_teams):
"""
Administrators of a template should be able to create template accesses
except for the "owner" role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a template can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_template_access.get_abilities(user),
"id": str(new_template_access.id),
"team": "",
"role": role,
"user": str(other_user.id),
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_owner(via, mock_user_teams):
"""
Owners of a template should be able to create template accesses whatever the role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"id": str(new_template_access.id),
"user": str(other_user.id),
"team": "",
"role": role,
"abilities": new_template_access.get_abilities(user),
}

View File

@@ -42,7 +42,5 @@ def test_api_templates_create_authenticated():
format="json",
)
assert response.status_code == 201
template = Template.objects.get()
assert template.title == "my template"
assert template.accesses.filter(role="owner", user=user).exists()
assert response.status_code == 405
assert not Template.objects.exists()

View File

@@ -8,7 +8,6 @@ import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@@ -25,7 +24,7 @@ def test_api_templates_delete_anonymous():
assert models.Template.objects.count() == 1
def test_api_templates_delete_authenticated_unrelated():
def test_api_templates_delete_not_implemented():
"""
Authenticated users should not be allowed to delete a template to which they are not
related.
@@ -36,72 +35,11 @@ def test_api_templates_delete_authenticated_unrelated():
client.force_login(user)
is_public = random.choice([True, False])
template = factories.TemplateFactory(is_public=is_public)
template = factories.TemplateFactory(is_public=is_public, users=[(user, "owner")])
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 403 if is_public else 404
assert response.status_code == 405
assert models.Template.objects.count() == 1
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
@pytest.mark.parametrize("via", VIA)
def test_api_templates_delete_authenticated_member_or_administrator(
via, role, mock_user_teams
):
"""
Authenticated users should not be allowed to delete a template for which they are
only a member or administrator.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
response = client.delete(
f"/api/v1.0/templates/{template.id}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Template.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_templates_delete_authenticated_owner(via, mock_user_teams):
"""
Authenticated users should be able to delete a template they own.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
response = client.delete(
f"/api/v1.0/templates/{template.id}/",
)
assert response.status_code == 204
assert models.Template.objects.exists() is False

View File

@@ -2,14 +2,11 @@
Tests for Templates API endpoint in impress's core app: update
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@@ -17,7 +14,6 @@ pytestmark = pytest.mark.django_db
def test_api_templates_update_anonymous():
"""Anonymous users should not be allowed to update a template."""
template = factories.TemplateFactory()
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
@@ -28,145 +24,18 @@ def test_api_templates_update_anonymous():
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
def test_api_templates_update_authenticated_unrelated():
def test_api_templates_update_not_implemented():
"""
Authenticated users should not be allowed to update a template to which they are not related.
Authenticated users should not be allowed to update a template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_readers(via, mock_user_teams):
"""
Users who are readers of a template should not be allowed to update it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="reader"
)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_editor_or_administrator_or_owner(
via, role, mock_user_teams
):
"""Administrator or owner of a template should be allowed to update it."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 200
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
for key, value in template_values.items():
if key in ["id", "accesses"]:
assert value == old_template_values[key]
else:
assert value == new_template_values[key]
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_owners(via, mock_user_teams):
"""Administrators of a template should be allowed to update it."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
old_template_values = serializers.TemplateSerializer(instance=template).data
template = factories.TemplateFactory(users=[(user, "owner")])
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
@@ -176,55 +45,10 @@ def test_api_templates_update_authenticated_owners(via, mock_user_teams):
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
)
assert response.status_code == 200
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
for key, value in template_values.items():
if key in ["id", "accesses"]:
assert value == old_template_values[key]
else:
assert value == new_template_values[key]
assert response.status_code == 405
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_administrator_or_owner_of_another(via, mock_user_teams):
"""
Being administrator or owner of a template should not grant authorization to update
another template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role=random.choice(["administrator", "owner"])
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template,
team="lasuite",
role=random.choice(["administrator", "owner"]),
)
is_public = random.choice([True, False])
template = factories.TemplateFactory(title="Old title", is_public=is_public)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
response = client.patch(
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
)
assert response.status_code == 403 if is_public else 404
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
assert response.status_code == 405

View File

@@ -34,15 +34,6 @@ document_related_router.register(
)
# - Routes nested under a template
template_related_router = DefaultRouter()
template_related_router.register(
"accesses",
viewsets.TemplateAccessViewSet,
basename="template_accesses",
)
urlpatterns = [
path(
f"api/{settings.API_VERSION}/",
@@ -54,10 +45,6 @@ urlpatterns = [
r"^documents/(?P<resource_id>[0-9a-z-]*)/",
include(document_related_router.urls),
),
re_path(
r"^templates/(?P<resource_id>[0-9a-z-]*)/",
include(template_related_router.urls),
),
]
),
),

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Titouroù personel"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Aotreoù"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Deiziadoù a-bouez"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Gwezennadur"
@@ -79,7 +79,7 @@ msgstr "Doare korf"
msgid "Format"
msgstr "Stumm"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "eilenn {title}"
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr "anv klok"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "anv berr"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "postel identelezh"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "postel ar merour"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "yezh"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "Ar yezh a vo implijet evit etrefas an implijer."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Ar gwerzhid-eur a vo implijet evit etrefas an implijer."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "trevnad"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Pe vefe an implijer un aparailh pe un implijer gwirion."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "statud ar skipailh"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Ma c'hall an implijer kevreañ ouzh al lec'hienn verañ-mañ."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "oberiant"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ma rank bezañ tretet an implijer-mañ evel oberiant. Diziuzit an dra-mañ e-plas dilemel kontoù."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "implijer"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "implijerien"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "titl"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "bomm"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Restr"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Restroù"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Restr hep titl"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} en deus rannet ur restr ganeoc'h!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, 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:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, 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:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Roud liamm ar restr/an implijer"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Roudoù liamm ar restr/an implijer"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
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:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Restr muiañ-karet"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Restroù muiañ-karet"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
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:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Liamm restr/implijer"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Liammoù restr/implijer"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "An implijer-mañ a zo dija er restr-mañ."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
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:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Goulenn tizhout ar restr"
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Goulennoù tizhout ar restr"
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
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:1261 core/models.py:1261
#: build/lib/core/models.py:1255 core/models.py:1255
#, 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:1265 core/models.py:1265
#: build/lib/core/models.py:1259 core/models.py:1259
#, 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:1271 core/models.py:1271
#: build/lib/core/models.py:1265 core/models.py:1265
#, 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:1283 core/models.py:1283
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "deskrivadur"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "kod"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "publik"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1283 core/models.py:1283
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:1295 core/models.py:1295
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Patrom"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Patromoù"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "Liamm patrom/implijer"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Liammoù patrom/implijer"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "An implijer-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "postel"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Pedadenn d'ur restr"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Pedadennoù d'ur restr"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Persönliche Daten"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Berechtigungen"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Wichtige Daten"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Baumstruktur"
@@ -79,7 +79,7 @@ msgstr "Typ"
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "Kopie von {title}"
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr "Name"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "Kurzbezeichnung"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "Admin E-Mail-Adresse"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "Sprache"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "Gerät"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "Status des Teammitgliedes"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "aktiviert"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "Benutzer"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "Titel"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "Auszug"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, 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:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, 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:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
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:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
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:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
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:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr ""
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr ""
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr ""
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr ""
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr ""
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr ""
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr ""
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr ""
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr ""
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr ""
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr ""
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Información Personal"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Permisos"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Fechas importantes"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Estructura en árbol"
@@ -79,7 +79,7 @@ msgstr "Tipo de Cuerpo"
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "copia de {title}"
@@ -178,228 +178,228 @@ msgstr "Obligatorio. 255 caracteres o menos. Solo caracteres ASCII."
msgid "full name"
msgstr "nombre completo"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "nombre abreviado"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "correo electrónico de identidad"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "correo electrónico del administrador"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "idioma"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "El idioma en el que el usuario desea ver la interfaz."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "La zona horaria en la que el usuario quiere ver los tiempos."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "dispositivo"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Si el usuario es un dispositivo o un usuario real."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "rol en el equipo"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Si el usuario puede iniciar sesión en esta página web de administración."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "activo"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si este usuario debe ser considerado como activo. Deseleccionar en lugar de eliminar cuentas."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "usuario"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "usuarios"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "título"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "resumen"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Documentos"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Documento sin título"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "¡{name} ha compartido un documento contigo!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, 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:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha compartido un documento contigo: {title}"
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Traza del enlace de documento/usuario"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Trazas del enlace de documento/usuario"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
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:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Documento favorito"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Documentos favoritos"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
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:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Relación documento/usuario"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Relaciones documento/usuario"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Este usuario ya forma parte del documento."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Este equipo ya forma parte del documento."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
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:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Solicitud de acceso"
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Solicitud de accesos"
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
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:1261 core/models.py:1261
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "¡{name} desea acceder a un documento!"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} desea acceso al siguiente documento:"
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1265 core/models.py:1265
#, 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:1283 core/models.py:1283
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "descripción"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "código"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "público"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1283 core/models.py:1283
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:1295 core/models.py:1295
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Plantilla"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Plantillas"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "Relación plantilla/usuario"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Relaciones plantilla/usuario"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "Este usuario ya forma parte de la plantilla."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Este equipo ya se encuentra en esta plantilla."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "dirección de correo electrónico"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Invitación al documento"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Invitaciones a documentos"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Este correo electrónico está asociado a un usuario registrado."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Infos Personnelles"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Permissions"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Arborescence"
@@ -79,7 +79,7 @@ msgstr "Type de corps"
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "copie de {title}"
@@ -178,228 +178,228 @@ msgstr "Obligatoire. 255 caractères ou moins. Caractères ASCII uniquement."
msgid "full name"
msgstr "nom complet"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "nom court"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "adresse e-mail de l'administrateur"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "langue"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "appareil"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "statut d'équipe"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "actif"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "utilisateur"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "utilisateurs"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "titre"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "extrait"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Document"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Documents"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Document sans titre"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, 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:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, 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:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Trace du lien document/utilisateur"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Traces du lien document/utilisateur"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
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:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Document favori"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Documents favoris"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
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:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Relation document/utilisateur"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Relations document/utilisateur"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Cet utilisateur est déjà dans ce document."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Cette équipe est déjà dans ce document."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
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:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
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:1261 core/models.py:1261
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} souhaiterait accéder au document suivant !"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1259 core/models.py:1259
#, 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:1271 core/models.py:1271
#: build/lib/core/models.py:1265 core/models.py:1265
#, 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:1283 core/models.py:1283
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "description"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "public"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1283 core/models.py:1283
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:1295 core/models.py:1295
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Modèle"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Modèles"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "Relation modèle/utilisateur"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Relations modèle/utilisateur"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "Cet utilisateur est déjà dans ce modèle."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Cette équipe est déjà modèle."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "adresse e-mail"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Invitation à un document"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Invitations à un document"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Informazioni personali"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Permessi"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Date importanti"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Struttura ad albero"
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "copia di {title}"
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr "nome completo"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "nome"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "indirizzo email di identità"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "Indirizzo email dell'amministratore"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "lingua"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "La lingua in cui l'utente vuole vedere l'interfaccia."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Il fuso orario in cui l'utente vuole vedere gli orari."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "dispositivo"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Se l'utente è un dispositivo o un utente reale."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "stato del personale"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Indica se l'utente può accedere a questo sito amministratore."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "attivo"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Indica se questo utente deve essere trattato come attivo. Deseleziona invece di eliminare gli account."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "utente"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "utenti"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "titolo"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Documenti"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Documento senza titolo"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ha condiviso un documento con te!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, 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:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, 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:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Documento preferito"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Documenti preferiti"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Questo utente è già presente in questo documento."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Questo team è già presente in questo documento."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "descrizione"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "pubblico"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr "Indica se questo modello è pubblico per chiunque."
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Modello"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Modelli"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "Questo utente è già in questo modello."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Questo team è già in questo modello."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "indirizzo e-mail"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Invito al documento"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Inviti al documento"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Questa email è già associata a un utente registrato."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -17,22 +17,22 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Toestemmingen"
msgstr "Machtigingen"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Belangrijke datums"
msgstr "Belangrijke data"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Document structuur"
msgstr "Boomstructuur"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
@@ -40,7 +40,7 @@ msgstr "Titel"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Ik ben Eigenaar"
msgstr "Ik ben eigenaar"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Masked"
@@ -48,15 +48,15 @@ msgstr "Gemaskeerd"
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
msgid "Favorite"
msgstr "Favoriete"
msgstr "Favoriet"
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
msgid "A new document was created on your behalf!"
msgstr "Een nieuw document was gecreëerd voor u!"
msgstr "Een nieuw document is namens u gemaakt!"
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
msgid "You have been granted ownership of a new document:"
msgstr "U heeft eigenaarschap van een nieuw document:"
msgstr "U heeft eigenaarschap van een nieuw document gekregen:"
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
@@ -79,7 +79,7 @@ msgstr "Text type"
msgid "Format"
msgstr "Formaat"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "kopie van {title}"
@@ -92,11 +92,11 @@ msgstr "Lezer"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Bewerker"
msgstr "Redacteur"
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Administrator"
msgstr "Beheerder"
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
@@ -104,7 +104,7 @@ msgstr "Eigenaar"
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Niet toegestaan"
msgstr "Beperkt"
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
@@ -148,11 +148,11 @@ msgstr "primaire sleutel voor dossier als UUID"
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr "gemaakt op"
msgstr "gecreëerd op"
#: build/lib/core/models.py:88 core/models.py:88
msgid "date and time at which a record was created"
msgstr "datum en tijd wanneer dossier was gecreëerd"
msgstr "datum en tijd waarop dossier is gecreeërd"
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
@@ -164,7 +164,7 @@ msgstr "datum en tijd waarop dossier laatst was gewijzigd"
#: build/lib/core/models.py:130 core/models.py:130
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "Wij konden geen gebruiker vinden met deze id, maar de email is al geassocieerd met een geregistreerde gebruiker."
msgstr "Wij konden geen gebruiker vinden met dit id, maar de email is al geassocieerd met een geregistreerde gebruiker."
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
@@ -172,234 +172,234 @@ msgstr "id"
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr "Vereist. Minder dan 255 ASCII tekens."
msgstr "Vereist. 255 tekens of minder. Alleen ASCII tekens."
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr "volledige naam"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "gebruikersnaam"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "identiteit email adres"
msgstr "identiteit emailadres"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "admin email adres"
msgstr "admin emailadres"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "taal"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wilt zien."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker de tijden wilt zien."
msgstr "De tijdzone waarin de gebruiker de tijden wil zien."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "apparaat"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "beheerder status"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen in het admin gedeelte."
msgstr "Of de gebruiker kan inloggen in het beheer gedeelte."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "actief"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Of een gebruiker als actief moet worden beschouwd. Deselecteer dit in plaats van het account te deleten."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "gebruiker"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "gebruikers"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "titel"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "uittreksel"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Document"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Documenten"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Naamloos Document"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} heeft een document met gedeeld!"
msgstr "{name} heeft een document met u gedeeld!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, 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:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, 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:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Document/gebruiker url"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Document/gebruiker url"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "Een url bestaat al voor dit document/deze gebruiker."
msgstr "Een link bestaat al voor dit document/deze gebruiker."
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Document favoriet"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Document favorieten"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dit document is al in gebruik als favoriete door dezelfde gebruiker."
msgstr "Dit document is al in gebruik als favoriet door dezelfde gebruiker."
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Document/gebruiker relatie"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Document/gebruiker relaties"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "De gebruiker is al in dit document."
msgstr "De gebruiker bestaat al in dit document."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Het team is al in dit document."
msgstr "Dit team bestaat al in dit document."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
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:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Document verzoekt om toegang"
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Document verzoekt om toegangen"
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
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:1261 core/models.py:1261
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} wenst toegang tot een document!"
msgstr "{name} verzoekt toegang tot een document!"
#: build/lib/core/models.py:1259 core/models.py:1259
#, 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:1265 core/models.py:1265
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} wenst toegang tot het volgende document:"
#: build/lib/core/models.py:1271 core/models.py:1271
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} vraagt toegang tot het document: {title}"
msgstr "{name} verzoekt toegang tot het document: {title}"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "omschrijving"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "publiek"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr "Of dit template als publiek is en door iedereen te gebruiken is."
msgstr "Of dit sjabloon door iedereen publiekelijk te gebruiken is."
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Template"
msgstr "Sjabloon"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Templates"
msgstr "Sjabloon"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "Template/gebruiker relatie"
msgstr "Sjabloon/gebruiker relatie"
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Sjabloon/gebruiker relaties"
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "Template/user relations"
msgstr "Template/gebruiker relaties"
msgid "This user is already in this template."
msgstr "De gebruiker bestaat al in dit sjabloon."
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This user is already in this template."
msgstr "De gebruiker bestaat al in dit template."
#: build/lib/core/models.py:1362 core/models.py:1362
msgid "This team is already in this template."
msgstr "Het team bestaat al in dit template."
msgstr "Het team bestaat al in dit sjabloon."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "email adres"
msgstr "e-mailadres"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Document uitnodiging"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Document uitnodigingen"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Informações Pessoais"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Permissões"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Datas importantes"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Estrutura de árvore"
@@ -79,7 +79,7 @@ msgstr "Tipo de corpo"
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "cópia de {title}"
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr ""
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr ""
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr ""
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr ""
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr ""
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr ""
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr ""
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr ""
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr ""
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr ""
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Личная информация"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Разрешения"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Важные даты"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Древовидная структура"
@@ -79,7 +79,7 @@ msgstr "Тип сообщения"
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "копия {title}"
@@ -178,228 +178,228 @@ msgstr "Обязательно. 255 символов или меньше. Тол
msgid "full name"
msgstr "полное имя"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "короткое имя"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "личный адрес электронной почты"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "e-mail администратора"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "язык"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "Язык, на котором пользователь хочет видеть интерфейс."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Часовой пояс, в котором пользователь хочет видеть время."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "устройство"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Пользователь является устройством или человеком."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "статус сотрудника"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Может ли пользователь войти на этот административный сайт."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "активный"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Должен ли пользователь рассматриваться как активный. Альтернатива удалению учётных записей."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "пользователь"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "пользователи"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "заголовок"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "отрывок"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Документ"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Документы"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Безымянный документ"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} делится с вами документом!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} делится с вами документом: {title}"
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Трассировка связи документ/пользователь"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Трассировка связей документ/пользователь"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Избранный документ"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Избранные документы"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Этот документ уже помечен как избранный для этого пользователя."
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Отношение документ/пользователь"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Отношения документ/пользователь"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Этот пользователь уже имеет доступ к этому документу."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Эта команда уже имеет доступ к этому документу."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Документ запрашивает доступ"
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Документ запрашивает доступы"
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr "Этот пользователь уже запросил доступ к этому документу."
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хочет получить доступ к документу!"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} хочет получить доступ к следующему документу:"
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запрашивает доступ к документу: {title}"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "описание"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "доступно всем"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr "Этот шаблон доступен всем пользователям."
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Шаблоны"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "Отношение шаблон/пользователь"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Отношения шаблон/пользователь"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "Этот пользователь уже указан в этом шаблоне."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Эта команда уже указана в этом шаблоне."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "адрес электронной почты"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Приглашение для документа"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Приглашения для документов"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Этот адрес уже связан с зарегистрированным пользователем."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Osebni podatki"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Dovoljenja"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Pomembni datumi"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Drevesna struktura"
@@ -79,7 +79,7 @@ msgstr "Vrsta telesa"
msgid "Format"
msgstr "Oblika"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr "polno ime"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "kratko ime"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "elektronski naslov identitete"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "elektronski naslov skrbnika"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "jezik"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "Jezik, v katerem uporabnik želi videti vmesnik."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Časovni pas, v katerem želi uporabnik videti uro."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "naprava"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Ali je uporabnik naprava ali pravi uporabnik."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "kadrovski status"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Ali se uporabnik lahko prijavi na to skrbniško mesto."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "aktivni"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ali je treba tega uporabnika obravnavati kot aktivnega. Namesto brisanja računov počistite to izbiro."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "uporabnik"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "uporabniki"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "naslov"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "odlomek"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Dokumenti"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Dokument brez naslova"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} je delil dokument z vami!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, 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:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} je delil dokument z vami: {title}"
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Dokument/sled povezave uporabnika"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Sledi povezav dokumenta/uporabnika"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Priljubljeni dokument"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Priljubljeni dokumenti"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
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:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Odnos dokument/uporabnik"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Odnosi dokument/uporabnik"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Ta uporabnik je že v tem dokumentu."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Ta ekipa je že v tem dokumentu."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
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:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "opis"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "koda"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "javno"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr "Ali je ta predloga javna za uporabo."
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Predloga"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Predloge"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "Odnos predloga/uporabnik"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Odnosi med predlogo in uporabnikom"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "Ta uporabnik je že v tej predlogi."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Ta ekipa je že v tej predlogi."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "elektronski naslov"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Vabilo na dokument"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Vabila na dokument"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Personuppgifter"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Behörigheter"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Viktiga datum"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr ""
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr ""
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr ""
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr ""
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr ""
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "aktiv"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr ""
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr ""
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr ""
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr ""
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr ""
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "e-postadress"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Bjud in dokument"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Inbjudningar dokument"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Denna e-postadress är redan associerad med en registrerad användare."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr ""
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr ""
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr ""
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr ""
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr ""
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr ""
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr ""
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr ""
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr ""
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr ""
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr ""
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Особисті дані"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Дозволи"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Важливі дати"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Ієрархічна структура"
@@ -79,7 +79,7 @@ msgstr "Тип вмісту"
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "копія {title}"
@@ -178,228 +178,228 @@ msgstr "Обов'язкове. 255 символів або менше. Тіль
msgid "full name"
msgstr "повне ім'я"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "коротке ім'я"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "адреса електронної пошти особи"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "електронна адреса адміністратора"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "мова"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "Мова, якою користувач хоче бачити інтерфейс."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Часовий пояс, в якому користувач хоче бачити час."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "пристрій"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Чи є користувач пристроєм чи реальним користувачем."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "статус співробітника"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Чи може користувач увійти на цей сайт адміністратора."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "активний"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Чи слід ставитися до цього користувача як до активного. Зніміть вибір замість видалення облікового запису."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "користувач"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "користувачі"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "заголовок"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "уривок"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Документ"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Документи"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Документ без назви"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ділиться з вами документом!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ділиться з вами документом: {title}"
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Трасування посилання Документ/користувач"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Трасування посилань Документ/користувач"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Обраний документ"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Обрані документи"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Цей документ вже вказаний як обраний для одного користувача."
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Цей користувач вже має доступ до цього документу."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Ця команда вже має доступ до цього документа."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr "Вкажіть користувача або команду, а не обох."
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Запит доступу до документа"
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Запит доступу для документа"
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr "Цей користувач вже попросив доступ до цього документа."
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хоче отримати доступ до документа!"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} бажає отримати доступ до наступного документа:"
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запитує доступ до документа: {title}"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "опис"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "публічне"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr "Чи є цей шаблон публічним для будь-кого користувача."
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "Шаблони"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "Цей користувач вже має доступ до цього шаблону."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "Ця команда вже має доступ до цього шаблону."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "електронна адреса"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "Запрошення до редагування документа"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "Запрошення до редагування документів"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "个人信息"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "权限"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "重要日期"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "树状结构"
@@ -79,7 +79,7 @@ msgstr "正文类型"
msgid "Format"
msgstr "格式"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "{title} 的副本"
@@ -178,228 +178,228 @@ msgstr "必填项。限255个字符以内。仅支持ASCII字符。"
msgid "full name"
msgstr "全名"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "简称"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "身份电子邮件地址"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "管理员电子邮件地址"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "语言"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "用户希望看到的界面语言。"
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "用户查看时间希望的时区。"
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "设备"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "用户是设备还是真实用户。"
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "员工状态"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "用户是否可以登录该管理员站点。"
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "激活"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "是否应将此用户视为活跃用户。取消选择此选项而不是删除账户。"
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "用户"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "个用户"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
msgid "title"
msgstr "标题"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "摘要"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "文档"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "个文档"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "未命名文档"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} 与您共享了一个文档!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} 邀请您以“{role}”角色访问以下文档:"
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} 与您共享了一个文档:{title}"
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "文档/用户链接跟踪"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "个文档/用户链接跟踪"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "此文档/用户的链接跟踪已存在。"
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "文档收藏"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "文档收藏夹"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "该文档已被同一用户的收藏关系实例关联。"
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "文档/用户关系"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "文档/用户关系集"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "该用户已在此文档中。"
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "该团队已在此文档中。"
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
msgid "Either user or team must be set, not both."
msgstr "必须设置用户或团队之一,不能同时设置两者。"
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "文档需要访问权限"
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "文档需要访问权限"
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr "用户已申请该文档的访问权限。"
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1255 core/models.py:1255
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} 申请访问文档!"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} 申请访问以下文档:"
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name}申请文档:{title}的访问权限"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1277 core/models.py:1277
msgid "description"
msgstr "说明"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1278 core/models.py:1278
msgid "code"
msgstr "代码"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1279 core/models.py:1279
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1281 core/models.py:1281
msgid "public"
msgstr "公开"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr "该模板是否公开供任何人使用。"
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Template"
msgstr "模板"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1290 core/models.py:1290
msgid "Templates"
msgstr "模板"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1343 core/models.py:1343
msgid "Template/user relation"
msgstr "模板/用户关系"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "模板/用户关系集"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "该用户已在此模板中。"
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This team is already in this template."
msgstr "该团队已在此模板中。"
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1433 core/models.py:1433
msgid "email address"
msgstr "电子邮件地址"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1452 core/models.py:1452
msgid "Document invitation"
msgstr "文档邀请"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1453 core/models.py:1453
msgid "Document invitations"
msgstr "文档邀请"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1473 core/models.py:1473
msgid "This email is already associated to a registered user."
msgstr "此电子邮件已经与现有注册用户关联。"

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "3.8.2"
version = "3.10.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,42 +25,42 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.13.4",
"boto3==1.39.4",
"Brotli==1.1.0",
"beautifulsoup4==4.14.2",
"boto3==1.40.74",
"Brotli==1.2.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-cors-headers==4.9.0",
"django-countries==8.1.0",
"django-csp==4.0",
"django-filter==25.1",
"django-lasuite[all]==0.0.14",
"django-filter==25.2",
"django-lasuite[all]==0.0.18",
"django-parler==2.3",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.7",
"django==5.2.8",
"django-treebeard==4.7.1",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.24.0",
"lxml==6.0.0",
"markdown==3.8.2",
"jsonschema==4.25.1",
"lxml==6.0.2",
"markdown==3.10",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"openai==1.95.0",
"psycopg[binary]==3.2.9",
"pycrdt==0.12.25",
"nested-multipart-parser==1.6.0",
"openai==2.8.0",
"psycopg[binary]==3.2.12",
"pycrdt==0.12.43",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.4",
"sentry-sdk==2.32.0",
"whitenoise==6.9.0",
"requests==2.32.5",
"sentry-sdk==2.44.0",
"whitenoise==6.11.0",
]
[project.urls]
@@ -73,21 +73,21 @@ dependencies = [
dev = [
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.7.1",
"freezegun==1.5.2",
"drf-spectacular-sidecar==2025.10.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.4.0",
"pyfakefs==5.9.1",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"pylint-django==2.6.1",
"pylint==3.3.7",
"pytest-cov==6.2.1",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==8.4.1",
"pytest==9.0.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.7",
"ruff==0.12.2",
"types-requests==2.32.4.20250611",
"responses==0.25.8",
"ruff==0.14.5",
"types-requests==2.32.4.20250913",
]
[tool.setuptools]

View File

@@ -494,7 +494,7 @@ test.describe('Doc Editor', () => {
if (request.method().includes('GET')) {
await route.fulfill({
json: {
status: requestCount ? 'ready' : 'processing',
status: requestCount > 1 ? 'ready' : 'processing',
file: '/anything.html',
},
});
@@ -518,6 +518,12 @@ test.describe('Doc Editor', () => {
await fileChooser.setFiles(path.join(__dirname, 'assets/test.html'));
await expect(editor.getByText('Analyzing file...')).toBeVisible();
// To be sure the retry happens even after a page reload
await page.reload();
await expect(editor.getByText('Analyzing file...')).toBeVisible();
// The retry takes a few seconds
await expect(editor.getByText('test.html')).toBeVisible({
timeout: 7000,
@@ -800,6 +806,12 @@ test.describe('Doc Editor', () => {
});
await expect(interlinkChild1).toBeVisible({ timeout: 10000 });
await expect(interlinkChild1.locator('svg').first()).toBeVisible();
await page.keyboard.press('@');
await page.keyboard.press('Escape');
await expect(editor.getByText('@')).toBeVisible();
});
test('it checks multiple big doc scroll to the top', async ({

View File

@@ -2,12 +2,11 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import { pdf } from 'pdf-parse';
import { PDFParse } from 'pdf-parse';
import {
TestLanguage,
createDoc,
randomName,
verifyDocName,
waitForLanguageSwitch,
} from './utils-common';
@@ -32,7 +31,7 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
page.getByText('Download your document in a .docx or .pdf format.'),
page.getByText('Download your document in a .docx, .odt or .pdf format.'),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
@@ -86,11 +85,16 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfInfo = await pdfParse.getInfo();
const pdfText = await pdfParse.getText();
expect(pdfData.total).toBe(2);
expect(pdfData.text).toContain('Hello\n\nWorld\n\n'); // This is the doc text
expect(pdfData.info?.Title).toBe(randomDoc);
expect(pdfInfo.total).toBe(2);
expect(pdfText.pages).toStrictEqual([
{ text: 'Hello', num: 1 },
{ text: 'World', num: 2 },
]);
expect(pdfInfo?.info.Title).toBe(randomDoc);
});
test('it exports the doc to docx', async ({ page, browserName }) => {
@@ -138,6 +142,51 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
});
test('it exports the doc to odt', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor-odt', browserName, 1);
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World ODT');
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();
await expect(image).toBeVisible();
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Odt' }).click();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.odt`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.odt`);
});
/**
* 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
@@ -219,10 +268,10 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfExport = await pdf(pdfBuffer);
const pdfText = pdfExport.text;
expect(pdfText).toContain('Hello World');
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain('Hello World');
});
test('it exports the doc with quotes', async ({ page, browserName }) => {
@@ -265,9 +314,9 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
expect(pdfData.text).toContain('Hello World'); // This is the pdf text
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain('Hello World');
});
test('it exports the doc with multi columns', async ({
@@ -320,46 +369,33 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
expect(pdfData.text).toContain('Column 1');
expect(pdfData.text).toContain('Column 2');
expect(pdfData.text).toContain('Column 3');
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain('Column 1');
expect(pdfText.text).toContain('Column 2');
expect(pdfText.text).toContain('Column 3');
});
test('it injects the correct language attribute into PDF export', async ({
page,
browserName,
}) => {
const [randomDocFrench] = await createDoc(
page,
'doc-language-export-french',
browserName,
1,
);
await waitForLanguageSwitch(page, TestLanguage.French);
// Wait for the page to be ready after language switch
await page.waitForLoadState('domcontentloaded');
const header = page.locator('header').first();
await header.locator('h1').getByText('Docs').click();
const randomDocFrench = randomName(
'doc-language-export-french',
browserName,
1,
)[0];
await page
.getByRole('button', {
name: 'Nouveau doc',
})
.click();
const input = page.getByRole('textbox', { name: 'Titre du document' });
await expect(input).toBeVisible();
await expect(input).toHaveText('', { timeout: 10000 });
await input.click();
await input.fill(randomDocFrench);
await input.blur();
const editor = page.locator('.ProseMirror.bn-editor');
await editor.click();
await editor.fill('Contenu de test pour export en français');
await writeInEditor({
page,
text: 'Contenu de test pour export en français',
});
await page
.getByRole('button', {
@@ -447,8 +483,72 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${docChild}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain(randomDoc);
});
expect(pdfData.text).toContain(randomDoc);
test('it exports the doc with interlinking to odt', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'export-interlinking-odt',
browserName,
1,
);
await verifyDocName(page, randomDoc);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'export-interlink-child-odt',
);
await verifyDocName(page, docChild);
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Link a doc').first().click();
const input = page.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
);
const searchContainer = page.locator('.quick-search-container');
await input.fill('export-interlink');
await expect(searchContainer).toBeVisible();
await expect(searchContainer.getByText(randomDoc)).toBeVisible();
// We are in docChild, we want to create a link to randomDoc (parent)
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,
});
await expect(interlink).toBeVisible();
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Odt' }).click();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${docChild}.odt`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${docChild}.odt`);
});
});

View File

@@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import { createDoc, getGridRow } from './utils-common';
import { createDoc, getGridRow, verifyDocName } from './utils-common';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
type SmallDoc = {
id: string;
@@ -11,7 +12,7 @@ test.describe('Documents Grid mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test('it checks the grid when mobile', async ({ page }) => {
await page.route('**/documents/**', async (route) => {
await page.route(/.*\/documents\/.*/, async (route) => {
const request = route.request();
if (request.method().includes('GET') && request.url().includes('page=')) {
await route.fulfill({
@@ -133,6 +134,8 @@ test.describe('Document grid item options', () => {
test('it deletes the document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `delete doc`, browserName);
await verifyDocName(page, docTitle);
await page.goto('/');
await expect(page.getByText(docTitle)).toBeVisible();
@@ -161,7 +164,7 @@ test.describe('Document grid item options', () => {
test("it checks if the delete option is disabled if we don't have the destroy capability", async ({
page,
}) => {
await page.route('*/**/api/v1.0/documents/?page=1', async (route) => {
await page.route(/.*\/api\/v1.0\/documents\/\?page=1/, async (route) => {
await route.fulfill({
json: {
results: [
@@ -192,90 +195,68 @@ test.describe('Document grid item options', () => {
});
await page.goto('/');
const button = page.getByTestId(
`docs-grid-actions-button-mocked-document-id`,
);
const button = page
.getByTestId(`docs-grid-actions-button-mocked-document-id`)
.first();
await expect(button).toBeVisible();
await button.click();
const removeButton = page.getByTestId(
`docs-grid-actions-remove-mocked-document-id`,
);
const removeButton = page
.getByTestId(`docs-grid-actions-remove-mocked-document-id`)
.first();
await expect(removeButton).toBeVisible();
await removeButton.isDisabled();
});
});
test.describe('Documents filters', () => {
test('it checks the prebuild left panel filters', async ({ page }) => {
test('it checks the left panel filters', async ({ page, browserName }) => {
void page.goto('/');
// Create my doc
const [docName] = await createDoc(page, 'my-doc', browserName, 1);
await verifyDocName(page, docName);
// Another user create a doc and share it with me
const { cleanup, otherPage, otherBrowserName } =
await connectOtherUserToDoc({
browserName,
docUrl: '/',
});
const [docShareName] = await createDoc(
otherPage,
'my-share-doc',
otherBrowserName,
1,
);
await verifyDocName(otherPage, docShareName);
await otherPage.getByRole('button', { name: 'Share' }).click();
await addNewMember(otherPage, 0, 'Editor', browserName);
// Let's check the filters
await page.getByRole('button', { name: 'Back to homepage' }).click();
const row = await getGridRow(page, docName);
const rowShare = await getGridRow(page, docShareName);
// All Docs
const response = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
const allCount = result.count as number;
await expect(page.getByTestId('grid-loader')).toBeHidden();
await expect(row).toBeVisible();
await expect(rowShare).toBeVisible();
const allDocs = page.getByLabel('All docs');
const myDocs = page.getByLabel('My docs');
const sharedWithMe = page.getByLabel('Shared with me');
// Initial state
await expect(allDocs).toBeVisible();
await expect(allDocs).toHaveAttribute('aria-current', 'page');
await expect(myDocs).toBeVisible();
await expect(myDocs).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await expect(myDocs).not.toHaveAttribute('aria-current');
await expect(sharedWithMe).toBeVisible();
await expect(sharedWithMe).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
await expect(sharedWithMe).not.toHaveAttribute('aria-current');
await allDocs.click();
await page.waitForURL('**/?target=all_docs');
let url = new URL(page.url());
let target = url.searchParams.get('target');
expect(target).toBe('all_docs');
// My docs
await myDocs.click();
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('my_docs');
const responseMyDocs = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1&is_creator_me=true') &&
response.status() === 200,
);
const resultMyDocs = await responseMyDocs.json();
const countMyDocs = resultMyDocs.count as number;
await expect(page.getByTestId('grid-loader')).toBeHidden();
expect(countMyDocs).toBeLessThanOrEqual(allCount);
// My Docs
await page.getByRole('link', { name: 'My docs' }).click();
await expect(row).toBeVisible();
await expect(rowShare).toBeHidden();
// Shared with me
await sharedWithMe.click();
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('shared_with_me');
const responseSharedWithMe = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1&is_creator_me=false') &&
response.status() === 200,
);
const resultSharedWithMe = await responseSharedWithMe.json();
const countSharedWithMe = resultSharedWithMe.count as number;
await expect(page.getByTestId('grid-loader')).toBeHidden();
expect(countSharedWithMe).toBeLessThanOrEqual(allCount);
expect(countSharedWithMe + countMyDocs).toEqual(allCount);
await page.getByRole('link', { name: 'Shared with me' }).click();
await expect(row).toBeHidden();
await expect(rowShare).toBeVisible();
await cleanup();
});
});

View File

@@ -68,9 +68,18 @@ test.describe('Doc Header', () => {
await createDoc(page, 'doc-update-emoji', browserName, 1);
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
const optionMenu = page.getByLabel('Open the document options');
const addEmojiMenuItem = page.getByRole('menuitem', { name: 'Add emoji' });
const removeEmojiMenuItem = page.getByRole('menuitem', {
name: 'Remove emoji',
});
// Top parent should not have emoji picker
await expect(emojiPicker).toBeHidden();
await optionMenu.click();
await expect(addEmojiMenuItem).toBeHidden();
await expect(removeEmojiMenuItem).toBeHidden();
await page.keyboard.press('Escape');
const { name: docChild } = await createRootSubPage(
page,
@@ -80,13 +89,23 @@ test.describe('Doc Header', () => {
await verifyDocName(page, docChild);
await expect(emojiPicker).toBeVisible();
// Emoji picker should be hidden initially
await expect(emojiPicker).toBeHidden();
// Add emoji
await optionMenu.click();
await expect(removeEmojiMenuItem).toBeHidden();
await addEmojiMenuItem.click();
await expect(emojiPicker).toHaveText('📄');
// Change emoji
await emojiPicker.click({
delay: 100,
});
await page.getByRole('button', { name: '😀' }).first().click();
await expect(emojiPicker).toHaveText('😀');
// Update title
const docTitle = page.getByRole('textbox', { name: 'Document title' });
await docTitle.fill('Hello Emoji World');
await docTitle.blur();
@@ -95,6 +114,12 @@ test.describe('Doc Header', () => {
// Check the tree
const row = await getTreeRow(page, 'Hello Emoji World');
await expect(row.getByText('😀')).toBeVisible();
// Remove emoji
await optionMenu.click();
await expect(addEmojiMenuItem).toBeHidden();
await removeEmojiMenuItem.click();
await expect(emojiPicker).toBeHidden();
});
test('it deletes the doc', async ({ page, browserName }) => {

View File

@@ -31,7 +31,7 @@ test.describe('Document list members', () => {
return cleanUrl.split('/').pop() || '';
})();
await page.route('**/documents/**/accesses/', async (route) => {
await page.route(/.*\/documents\/.*\/accesses\//, async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page') ?? '1';
@@ -160,6 +160,9 @@ test.describe('Document list members', () => {
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
);
await expect(soloOwner).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Administrator' }),
).toBeDisabled();
await list.click({
force: true, // Force click to close the dropdown
@@ -186,9 +189,17 @@ test.describe('Document list members', () => {
await list.click();
await expect(currentUserRole).toBeVisible();
await newUserRoles.click();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeDisabled();
await list.click({
force: true, // Force click to close the dropdown
});
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await list.click();
await list.click({
force: true, // Force click to close the dropdown
});
await expect(currentUserRole).toBeHidden();
});

View File

@@ -119,6 +119,10 @@ test.describe('Doc Trashbin', () => {
await row.getByText(subDocName).click();
await verifyDocName(page, subDocName);
await expect(
page.locator('.--docs--editor-container.--docs--doc-deleted'),
).toBeVisible();
await expect(page.getByLabel('Alert deleted document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeDisabled();
await expect(page.locator('.bn-editor')).toHaveAttribute(

View File

@@ -352,7 +352,7 @@ test.describe('Doc Tree', () => {
await page.getByRole('menuitem', { name: 'Remove emoji' }).click();
await expect(row.getByText('😀')).toBeHidden();
await expect(titleEmojiPicker).not.toHaveText('😀');
await expect(titleEmojiPicker).toBeHidden();
});
});

View File

@@ -7,7 +7,7 @@ import {
keyCloakSignIn,
verifyDocName,
} from './utils-common';
import { writeInEditor } from './utils-editor';
import { getEditor, writeInEditor } from './utils-editor';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
@@ -182,15 +182,14 @@ test.describe('Doc Visibility: Restricted', () => {
});
test.describe('Doc Visibility: Public', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('It checks a public doc in read only mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Public read only',
@@ -200,6 +199,8 @@ test.describe('Doc Visibility: Public', () => {
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello Public Viewonly' });
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
@@ -241,49 +242,67 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByTestId('search-docs-button')).toBeVisible();
await expect(page.getByTestId('new-doc-button')).toBeVisible();
const urlDoc = page.url();
const docUrl = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl,
withoutSignIn: true,
});
await expectLoginPage(page);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByTestId('search-docs-button')).toBeHidden();
await expect(page.getByTestId('new-doc-button')).toBeHidden();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
const card = page.getByLabel('It is the card information');
await expect(otherPage.locator('h2').getByText(docTitle)).toBeVisible();
await expect(otherPage.getByTestId('search-docs-button')).toBeHidden();
await expect(otherPage.getByTestId('new-doc-button')).toBeHidden();
await expect(
otherPage.getByRole('button', { name: 'Share' }),
).toBeVisible();
const card = otherPage.getByLabel('It is the card information');
await expect(card).toBeVisible();
await expect(card.getByText('Reader')).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText(
otherPage.locator('.--docs--editor-container.--docs--doc-readonly'),
).toBeVisible();
const otherEditor = await getEditor({ page: otherPage });
await expect(otherEditor).toHaveAttribute('contenteditable', 'false');
await expect(otherEditor.getByText('Hello Public Viewonly')).toBeVisible();
// Cursor and selection of the anonymous user are not visible
await otherEditor.getByText('Hello Public').selectText();
await expect(
page.locator('.collaboration-cursor-custom__base'),
).toBeHidden();
await expect(page.locator('.ProseMirror-yjs-selection')).toBeHidden();
// Can still see changes made by others
await writeInEditor({ page, text: 'Can you see it ?' });
await expect(otherEditor.getByText('Can you see it ?')).toBeVisible();
await otherPage.getByRole('button', { name: 'Share' }).click();
await expect(
otherPage.getByText(
'You can view this document but need additional access to see its members or modify settings.',
),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Request access' }),
otherPage.getByRole('button', { name: 'Request access' }),
).toBeHidden();
await cleanup();
});
test('It checks a public doc in editable mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello Public Editable' });
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
@@ -317,20 +336,47 @@ test.describe('Doc Visibility: Public', () => {
cardContainer.getByText('Public document', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
const docUrl = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl,
withoutSignIn: true,
docTitle,
});
await expectLoginPage(page);
await expect(otherPage.getByTestId('search-docs-button')).toBeHidden();
await expect(otherPage.getByTestId('new-doc-button')).toBeHidden();
await page.goto(urlDoc);
const otherEditor = await getEditor({ page: otherPage });
await expect(otherEditor).toHaveAttribute('contenteditable', 'true');
await expect(otherEditor.getByText('Hello Public Editable')).toBeVisible();
await verifyDocName(page, docTitle);
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
// We can see the collaboration cursor of the anonymous user
await otherEditor.getByText('Hello Public').selectText();
await expect(
page.locator('.collaboration-cursor-custom__base').getByText('Anonymous'),
).toBeVisible();
await expect(
otherPage.getByRole('button', { name: 'Share' }),
).toBeVisible();
const card = otherPage.getByLabel('It is the card information');
await expect(card).toBeVisible();
await expect(card.getByText('Editor')).toBeVisible();
await otherPage.getByRole('button', { name: 'Share' }).click();
await expect(
otherPage.getByText(
'You can view this document but need additional access to see its members or modify settings.',
),
).toBeVisible();
await expect(
otherPage.getByRole('button', { name: 'Request access' }),
).toBeHidden();
await cleanup();
});
});

View File

@@ -27,6 +27,33 @@ test.describe('Home page', () => {
// Check the titles
const h2 = page.locator('h2');
await expect(h2.getByText('Govs ❤️ Open Source.')).toBeVisible();
await expect(page.getByText('Docs is built on top of')).toBeVisible();
await expect(
page.getByRole('link', {
name: 'Django Rest Framework',
}),
).toHaveAttribute('href', 'https://www.django-rest-framework.org/');
await expect(page.getByText('You can easily self-host Docs')).toBeVisible();
await expect(
page
.getByRole('link', {
name: 'licence',
})
.first(),
).toHaveAttribute(
'href',
'https://github.com/suitenumerique/docs/blob/main/LICENSE',
);
await expect(
page.getByText('Docs is the result of a joint effort lead by the French'),
).toBeVisible();
await expect(
page
.getByRole('link', {
name: 'Zendis',
})
.first(),
).toHaveAttribute('href', 'https://zendis.de/');
await expect(
h2.getByText('Collaborative writing, Simplified.'),
).toBeVisible();

View File

@@ -1,37 +0,0 @@
/**
* Type definitions for pdf-parse library
* The library doesn't export complete type definitions for the parsed PDF data
*/
declare module 'pdf-parse' {
export interface PdfInfo {
Title?: string;
Author?: string;
Subject?: string;
Keywords?: string;
Creator?: string;
Producer?: string;
CreationDate?: string;
ModDate?: string;
[key: string]: unknown;
}
export interface PdfData {
/** Total number of pages */
numpages: number;
/** Alias for numpages */
total?: number;
/** Extracted text content from the PDF */
text: string;
/** PDF metadata information */
info?: PdfInfo;
/** PDF metadata (alternative structure) */
metadata?: unknown;
/** PDF version */
version?: string;
}
export function pdf(buffer: Buffer): Promise<PdfData>;
export default pdf;
}

View File

@@ -31,7 +31,7 @@ export const overrideConfig = async (
page: Page,
newConfig: { [_K in keyof typeof CONFIG]?: unknown },
) =>
await page.route('**/api/v1.0/config/', async (route) => {
await page.route(/.*\/api\/v1.0\/config\/.*/, async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
@@ -204,7 +204,7 @@ export const waitForResponseCreateDoc = (page: Page) => {
};
export const mockedDocument = async (page: Page, data: object) => {
await page.route('**/documents/**/', async (route) => {
await page.route(/\**\/documents\/\**/, async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
@@ -256,7 +256,7 @@ export const mockedDocument = async (page: Page, data: object) => {
};
export const mockedListDocs = async (page: Page, data: object[] = []) => {
await page.route('**/documents/**/', async (route) => {
await page.route(/\**\/documents\/\**/, async (route) => {
const request = route.request();
if (request.method().includes('GET') && request.url().includes('page=')) {
await route.fulfill({
@@ -277,6 +277,7 @@ export const expectLoginPage = async (page: Page) =>
).toBeVisible({
timeout: 10000,
});
// language helper
export const TestLanguage = {
English: {
@@ -300,7 +301,7 @@ export async function waitForLanguageSwitch(
page: Page,
lang: TestLanguageValue,
) {
await page.route('**/api/v1.0/users/**', async (route, request) => {
await page.route(/\**\/api\/v1.0\/users\/\**/, async (route, request) => {
if (request.method().includes('PATCH')) {
await route.fulfill({
json: {

View File

@@ -7,14 +7,14 @@ import {
verifyDocName,
} from './utils-common';
export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader';
export type Role = 'Administrator' | 'Owner' | 'Editor' | 'Reader';
export type LinkReach = 'Private' | 'Connected' | 'Public';
export type LinkRole = 'Reading' | 'Editing';
export const addNewMember = async (
page: Page,
index: number,
role: 'Administrator' | 'Owner' | 'Editor' | 'Reader',
role: Role,
fillText = 'user.test',
) => {
const responsePromiseSearchUser = page.waitForResponse(
@@ -118,12 +118,16 @@ export const connectOtherUserToDoc = async ({
await otherPage.goto(docUrl);
if (!withoutSignIn) {
await otherPage
const loginFromApp = otherPage
.getByRole('main', { name: 'Main content' })
.getByLabel('Login')
.click({
timeout: 15000,
});
.getByLabel('Login');
const loginFromHome = otherPage.getByRole('button', {
name: 'Start Writing',
});
await loginFromApp.or(loginFromHome).first().click({
timeout: 15000,
});
await keyCloakSignIn(otherPage, otherBrowserName, false);
}
@@ -159,7 +163,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
...json,
},
];
await page.route('**/invitations/**/', async (route) => {
await page.route(/.*\/invitations\/.*/, async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
@@ -180,7 +184,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
});
await page.route(
'**/invitations/120ec765-43af-4602-83eb-7f4e1224548a/**/',
/.*\/invitations\/120ec765-43af-4602-83eb-7f4e1224548a\/.*/,
async (route) => {
const request = route.request();
if (request.method().includes('DELETE')) {
@@ -195,7 +199,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
};
export const mockedAccesses = async (page: Page, json?: object) => {
await page.route('**/accesses/**/', async (route) => {
await page.route(/.*\/accesses\/.*/, async (route) => {
const request = route.request();
if (

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "3.8.2",
"version": "3.10.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.55.1",
"@playwright/test": "1.56.1",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-plugin-docs": "*",
@@ -23,7 +23,7 @@
},
"dependencies": {
"convert-stream": "1.0.2",
"pdf-parse": "2.1.7"
"pdf-parse": "2.4.5"
},
"packageManager": "yarn@1.22.22"
}

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "3.8.2",
"version": "3.10.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -19,50 +19,51 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@blocknote/code-block": "0.41.1",
"@blocknote/core": "0.41.1",
"@blocknote/mantine": "0.41.1",
"@blocknote/react": "0.41.1",
"@blocknote/xl-docx-exporter": "0.41.1",
"@blocknote/xl-multi-column": "0.41.1",
"@blocknote/xl-pdf-exporter": "0.41.1",
"@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",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/material-symbols-outlined": "5.2.25",
"@fontsource-variable/material-symbols-outlined": "5.2.28",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.16.2",
"@hocuspocus/provider": "3.3.0",
"@mantine/core": "8.3.4",
"@mantine/hooks": "8.3.4",
"@hocuspocus/provider": "3.4.0",
"@mantine/core": "8.3.6",
"@mantine/hooks": "8.3.6",
"@openfun/cunningham-react": "3.2.3",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.17.0",
"@tanstack/react-query": "5.90.2",
"@tiptap/extensions": "3.4.4",
"@sentry/nextjs": "10.22.0",
"@tanstack/react-query": "5.90.6",
"@tiptap/extensions": "*",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.25",
"crisp-sdk-web": "1.0.26",
"docx": "*",
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.5.0",
"i18next": "25.5.3",
"emoji-regex": "10.6.0",
"i18next": "25.6.0",
"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.271.0",
"posthog-js": "1.284.0",
"react": "*",
"react-aria-components": "1.13.0",
"react-dom": "*",
"react-i18next": "16.0.0",
"react-intersection-observer": "9.16.0",
"react-i18next": "16.3.3",
"react-intersection-observer": "10.0.0",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.1.19",
@@ -83,13 +84,13 @@
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "5.0.4",
"@vitejs/plugin-react": "5.1.0",
"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.0.0",
"jsdom": "27.1.0",
"node-fetch": "2.7.0",
"prettier": "3.6.2",
"stylelint": "16.25.0",
@@ -97,8 +98,8 @@
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack": "5.102.0",
"vitest": "4.0.6",
"webpack": "5.102.1",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"

View File

@@ -31,7 +31,7 @@ const StyledButton = styled(Button)<StyledButtonProps>`
font-weight: 500;
font-size: 0.938rem;
padding: 0;
${({ $css }) => $css};
border-radius: 4px;
&:hover {
background-color: var(
--c--components--button--primary-text--background--color-hover
@@ -41,6 +41,7 @@ const StyledButton = styled(Button)<StyledButtonProps>`
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-400);
border-radius: 4px;
}
${({ $css }) => $css};
`;
export interface DropButtonProps {

View File

@@ -13,7 +13,7 @@ export const Icon = ({
iconName,
disabled,
variant = 'outlined',
$variation,
$variation = 'text',
...textProps
}: IconProps) => {
const hasLabel = 'aria-label' in textProps || 'aria-labelledby' in textProps;
@@ -41,15 +41,19 @@ type IconOptionsProps = TextType & {
isHorizontal?: boolean;
};
export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
export const IconOptions = ({
isHorizontal,
$css,
...props
}: IconOptionsProps) => {
return (
<Icon
{...props}
iconName={isHorizontal ? 'more_horiz' : 'more_vert'}
$css={css`
user-select: none;
${props.$css}
${$css}
`}
{...props}
/>
);
};

View File

@@ -12,6 +12,7 @@ import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useKeyboardAction } from '@/hooks';
import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
@@ -57,6 +58,7 @@ export const DropdownMenu = ({
testId,
}: PropsWithChildren<DropdownMenuProps>) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const keyboardAction = useKeyboardAction();
const [isOpen, setIsOpen] = useState(opened ?? false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const blockButtonRef = useRef<HTMLDivElement>(null);
@@ -93,6 +95,14 @@ export const DropdownMenu = ({
}
}, [isOpen, options]);
const triggerOption = useCallback(
(option: DropdownMenuOption) => {
onOpenChange?.(false);
void option.callback?.();
},
[onOpenChange],
);
if (disabled) {
return children;
}
@@ -170,9 +180,9 @@ export const DropdownMenu = ({
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenChange?.(false);
void option.callback?.();
triggerOption(option);
}}
onKeyDown={keyboardAction(() => triggerOption(option))}
key={option.label}
$align="center"
$justify="space-between"

View File

@@ -44,6 +44,11 @@
contain: content;
}
.c__button--medium {
min-height: var(--c--components--button--medium-height);
height: auto;
}
/**
* Modal
*/

View File

@@ -44,7 +44,7 @@ export function useAuthQuery(
staleTime: 1000 * 60 * 15, // 15 minutes
retry: (failureCount, error) => {
// we assume that a 401 means the user is not logged in
if (error.status == 401) {
if (error.status === 401) {
return false;
}
return failureCount < DEFAULT_QUERY_RETRY;

View File

@@ -1,4 +1,5 @@
import { APIError, errorCauses } from '@/api';
import { sleep } from '@/utils';
interface CheckDocMediaStatusResponse {
file?: string;
@@ -25,3 +26,28 @@ export const checkDocMediaStatus = async ({
return response.json() as Promise<CheckDocMediaStatusResponse>;
};
/**
* Upload file can be analyzed on the server side,
* we had this function to wait for the analysis to be done
* before returning the file url. It will keep the loader
* on the upload button until the analysis is done.
* @param url
* @returns Promise<CheckDocMediaStatusResponse> status_code
* @description Waits for the upload to be analyzed by checking the status of the file.
*/
export const loopCheckDocMediaStatus = async (
url: string,
): Promise<CheckDocMediaStatusResponse> => {
const SLEEP_TIME = 5000;
const response = await checkDocMediaStatus({
urlMedia: url,
});
if (response.status === 'ready') {
return response;
} else {
await sleep(SLEEP_TIME);
return await loopCheckDocMediaStatus(url);
}
};

View File

@@ -12,16 +12,12 @@ import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
import {
Doc,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
import { Doc, useProviderStore } from '@/docs/doc-management';
import { useAuth } from '@/features/auth';
import {
@@ -84,26 +80,21 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { setEditor } = useEditorStore();
const { t } = useTranslation();
const { isSynced: isConnectedToCollabServer } = useProviderStore();
const refEditorContainer = useRef<HTMLDivElement>(null);
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
const isDeletedDoc = !!doc.deleted_at;
useSaveDoc(doc.id, provider.document, !readOnly, isConnectedToCollabServer);
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
const { i18n } = useTranslation();
const lang = i18n.resolvedLanguage;
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const collabName = readOnly
? 'Reader'
: user?.full_name || user?.email || t('Anonymous');
const collabName = user?.full_name || user?.email || t('Anonymous');
const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity';
const editor: DocsBlockNoteEditor = useCreateBlockNote(
{
collaboration: {
provider,
provider: provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: collabName,
@@ -117,10 +108,6 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
renderCursor: (user: { color: string; name: string }) => {
const cursorElement = document.createElement('span');
if (user.name === 'Reader') {
return cursorElement;
}
cursorElement.classList.add('collaboration-cursor-custom__base');
const caretElement = document.createElement('span');
caretElement.classList.add('collaboration-cursor-custom__caret');
@@ -169,7 +156,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
);
useHeadings(editor);
useShortcuts(editor);
useShortcuts(editor, refEditorContainer.current);
useUploadStatus(editor);
useEffect(() => {
@@ -181,12 +170,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
}, [setEditor, editor]);
return (
<Box
$padding={{ top: 'md' }}
$background="white"
$css={cssEditor(readOnly, isDeletedDoc)}
className="--docs--editor-container"
>
<Box ref={refEditorContainer} $css={cssEditor}>
{errorAttachment && (
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
<TextErrors
@@ -201,8 +185,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
editor={editor}
formattingToolbar={false}
slashMenu={false}
editable={!readOnly}
theme="light"
aria-label={t('Document editor')}
>
<BlockNoteSuggestionMenu />
<BlockNoteToolbar />
@@ -211,14 +195,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
);
};
interface BlockNoteEditorVersionProps {
interface BlockNoteReaderProps {
initialContent: Y.XmlFragment;
}
export const BlockNoteEditorVersion = ({
initialContent,
}: BlockNoteEditorVersionProps) => {
const readOnly = true;
export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => {
const { setEditor } = useEditorStore();
const { t } = useTranslation();
const editor = useCreateBlockNote(
{
collaboration: {
@@ -234,9 +217,26 @@ export const BlockNoteEditorVersion = ({
[initialContent],
);
useEffect(() => {
setEditor(editor);
return () => {
setEditor(undefined);
};
}, [setEditor, editor]);
useHeadings(editor);
return (
<Box $css={cssEditor(readOnly, true)} className="--docs--editor-container">
<BlockNoteView editor={editor} editable={!readOnly} theme="light" />
<Box $css={cssEditor}>
<BlockNoteView
editor={editor}
editable={false}
theme="light"
aria-label={t('Document version viewer')}
formattingToolbar={false}
slashMenu={false}
/>
</Box>
);
};

View File

@@ -1,32 +1,87 @@
import { Loader } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import clsx from 'clsx';
import { useEffect } from 'react';
import { css } from 'styled-components';
import * as Y from 'yjs';
import { Box, Loading, Text, TextErrors } from '@/components';
import { DocHeader, DocVersionHeader } from '@/docs/doc-header/';
import { Box, Loading } from '@/components';
import { DocHeader } from '@/docs/doc-header/';
import {
Doc,
base64ToBlocknoteXmlFragment,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
import { TableContent } from '@/docs/doc-table-content/';
import { Versions, useDocVersion } from '@/docs/doc-versioning/';
import { useSkeletonStore } from '@/features/skeletons';
import { useResponsiveStore } from '@/stores';
import { BlockNoteEditor, BlockNoteEditorVersion } from './BlockNoteEditor';
import { BlockNoteEditor, BlockNoteReader } from './BlockNoteEditor';
interface DocEditorContainerProps {
docHeader: React.ReactNode;
docEditor: React.ReactNode;
isDeletedDoc: boolean;
readOnly: boolean;
}
export const DocEditorContainer = ({
docHeader,
docEditor,
isDeletedDoc,
readOnly,
}: DocEditorContainerProps) => {
const { isDesktop } = useResponsiveStore();
return (
<>
<Box
$maxWidth="868px"
$width="100%"
$height="100%"
className="--docs--doc-editor"
>
<Box
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
className="--docs--doc-editor-header"
>
{docHeader}
</Box>
<Box
$direction="row"
$width="100%"
$css="overflow-x: clip; flex: 1;"
$position="relative"
className="--docs--doc-editor-content"
>
<Box $css="flex:1;" $position="relative" $width="100%">
<Box
$padding={{ top: 'md', bottom: '2rem' }}
$background="white"
className={clsx('--docs--editor-container', {
'--docs--doc-readonly': readOnly,
'--docs--doc-deleted': isDeletedDoc,
})}
$height="100%"
>
{docEditor}
</Box>
</Box>
</Box>
</Box>
</>
);
};
interface DocEditorProps {
doc: Doc;
versionId?: Versions['version_id'];
}
export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
export const DocEditor = ({ doc }: DocEditorProps) => {
const { isDesktop } = useResponsiveStore();
const isVersion = !!versionId && typeof versionId === 'string';
const { provider, isReady } = useProviderStore();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const isDeletedDoc = !!doc.deleted_at;
const readOnly =
!doc.abilities.partial_update || !isEditable || isLoading || isDeletedDoc;
const { setIsSkeletonVisible } = useSkeletonStore();
const isProviderReady = isReady && provider;
@@ -42,8 +97,9 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
return (
<>
{isDesktop && !isVersion && (
{isDesktop && (
<Box
$height="100vh"
$position="absolute"
$css={css`
top: 72px;
@@ -53,102 +109,22 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
<TableContent />
</Box>
)}
<Box
$maxWidth="868px"
$width="100%"
$height="100%"
className="--docs--doc-editor"
>
<Box
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
className="--docs--doc-editor-header"
>
{isVersion ? <DocVersionHeader /> : <DocHeader doc={doc} />}
</Box>
<Box
$direction="row"
$width="100%"
$css="overflow-x: clip; flex: 1;"
$position="relative"
className="--docs--doc-editor-content"
>
<Box $css="flex:1;" $position="relative" $width="100%">
{isVersion ? (
<DocVersionEditor docId={doc.id} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} provider={provider} />
)}
</Box>
</Box>
</Box>
<DocEditorContainer
docHeader={<DocHeader doc={doc} />}
docEditor={
readOnly ? (
<BlockNoteReader
initialContent={provider.document.getXmlFragment(
'document-store',
)}
/>
) : (
<BlockNoteEditor doc={doc} provider={provider} />
)
}
isDeletedDoc={isDeletedDoc}
readOnly={readOnly}
/>
</>
);
};
interface DocVersionEditorProps {
docId: Doc['id'];
versionId: Versions['version_id'];
}
export const DocVersionEditor = ({
docId,
versionId,
}: DocVersionEditorProps) => {
const {
data: version,
isLoading,
isError,
error,
} = useDocVersion({
docId,
versionId,
});
const { replace } = useRouter();
const [initialContent, setInitialContent] = useState<Y.XmlFragment>();
useEffect(() => {
if (!version?.content) {
return;
}
setInitialContent(base64ToBlocknoteXmlFragment(version.content));
}, [version?.content]);
if (isError && error) {
if (error.status === 404) {
void replace(`/404`);
return null;
}
return (
<Box $margin="large" className="--docs--doc-version-editor-error">
<TextErrors
causes={error.cause}
icon={
error.status === 502 ? (
<Text
className="material-icons"
$theme="danger"
aria-hidden={true}
>
wifi_off
</Text>
) : undefined
}
/>
</Box>
);
}
if (isLoading || !version || !initialContent) {
return (
<Box $align="center" $justify="center" $height="100%">
<Loader />
</Box>
);
}
return <BlockNoteEditorVersion initialContent={initialContent} />;
};

View File

@@ -173,5 +173,4 @@ export const getCalloutFormattingToolbarItems = (
name: t('Callout'),
type: 'callout',
icon: () => <Icon iconName="lightbulb" $size="16px" />,
isSelected: (block) => block.type === 'callout',
});

View File

@@ -22,8 +22,8 @@ import { Box, Icon } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
const PDFBlockStyle = createGlobalStyle`
.bn-block-content[data-content-type="pdf"] {
width: fit-content;
.bn-block-content[data-content-type="pdf"] .bn-file-block-content-wrapper[style*="fit-content"] {
width: 100% !important;
}
`;
@@ -46,7 +46,7 @@ type CreatePDFBlockConfig = BlockConfig<
interface PdfBlockComponentProps {
block: BlockNoDefaults<
Record<'callout', CreatePDFBlockConfig>,
Record<'pdf', CreatePDFBlockConfig>,
InlineContentSchema,
StyleSchema
>;

View File

@@ -1,34 +1,146 @@
import {
BlockConfig,
BlockNoDefaults,
BlockNoteEditor,
InlineContentSchema,
StyleSchema,
} from '@blocknote/core';
import { createReactBlockSpec } from '@blocknote/react';
import { t } from 'i18next';
import { useEffect } from 'react';
import { Box, Text } from '@/components';
import { useMediaUrl } from '@/core';
import { loopCheckDocMediaStatus } from '../../api';
import Loader from '../../assets/loader.svg';
import Warning from '../../assets/warning.svg';
type UploadLoaderPropSchema = {
readonly information: { readonly default: '' };
readonly type: {
readonly default: 'loading';
readonly values: readonly ['loading', 'warning'];
};
readonly blockUploadName: { readonly default: '' };
readonly blockUploadShowPreview: { readonly default: true };
readonly blockUploadType: {
readonly default: '';
};
readonly blockUploadUrl: { readonly default: '' };
};
type UploadLoaderBlockConfig = BlockConfig<
'uploadLoader',
UploadLoaderPropSchema,
'none'
>;
type UploadLoaderBlockType = BlockNoDefaults<
Record<'uploadLoader', UploadLoaderBlockConfig>,
InlineContentSchema,
StyleSchema
>;
type UploadLoaderEditor = BlockNoteEditor<
Record<'uploadLoader', UploadLoaderBlockConfig>,
InlineContentSchema,
StyleSchema
>;
interface UploadLoaderBlockComponentProps {
block: UploadLoaderBlockType;
editor: UploadLoaderEditor;
contentRef: (node: HTMLElement | null) => void;
}
const UploadLoaderBlockComponent = ({
block,
editor,
}: UploadLoaderBlockComponentProps) => {
const mediaUrl = useMediaUrl();
useEffect(() => {
if (!block.props.blockUploadUrl || block.props.type !== 'loading') {
return;
}
const url = block.props.blockUploadUrl;
loopCheckDocMediaStatus(url)
.then((response) => {
// Replace the loading block with the resource block (image, audio, video, pdf ...)
try {
editor.replaceBlocks(
[block.id],
[
{
type: block.props.blockUploadType,
props: {
url: `${mediaUrl}${response.file}`,
showPreview: block.props.blockUploadShowPreview,
name: block.props.blockUploadName,
caption: '',
backgroundColor: 'default',
textAlignment: 'left',
},
} as never,
],
);
} catch {
/* During collaboration, another user might have updated the block */
}
})
.catch((error) => {
console.error('Error analyzing file:', error);
try {
editor.updateBlock(block.id, {
type: 'uploadLoader',
props: {
type: 'warning',
information: t(
'The antivirus has detected an anomaly in your file.',
),
},
});
} catch {
/* During collaboration, another user might have updated the block */
}
});
}, [block, editor, mediaUrl]);
return (
<Box className="bn-visual-media-wrapper" $direction="row" $gap="0.5rem">
{block.props.type === 'warning' ? (
<Warning />
) : (
<Loader style={{ animation: 'spin 1.5s linear infinite' }} />
)}
<Text>{block.props.information}</Text>
</Box>
);
};
export const UploadLoaderBlock = createReactBlockSpec(
{
type: 'uploadLoader',
propSchema: {
information: { default: '' as const },
information: { default: '' },
type: {
default: 'loading' as const,
values: ['loading', 'warning'] as const,
default: 'loading',
values: ['loading', 'warning'],
},
blockUploadName: { default: '' },
blockUploadShowPreview: { default: true },
blockUploadType: {
default: '',
},
blockUploadUrl: { default: '' },
},
content: 'none',
},
{
render: ({ block }) => {
return (
<Box className="bn-visual-media-wrapper" $direction="row" $gap="0.5rem">
{block.props.type === 'warning' ? (
<Warning />
) : (
<Loader style={{ animation: 'spin 1.5s linear infinite' }} />
)}
<Text>{block.props.information}</Text>
</Box>
);
},
render: (props) => <UploadLoaderBlockComponent {...props} />,
},
);

View File

@@ -29,6 +29,9 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
render: ({ inlineContent, updateInlineContent }) => {
const { data: doc } = useDoc({ id: inlineContent.props.docId });
/**
* Update the content title if the referenced doc title changes
*/
useEffect(() => {
if (doc?.title && doc.title !== inlineContent.props.title) {
updateInlineContent({
@@ -39,7 +42,15 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
},
});
}
}, [inlineContent.props, doc?.title, updateInlineContent]);
/**
* ⚠️ 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]);
return <LinkSelected {...inlineContent.props} />;
},
@@ -66,7 +77,7 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
onClick={handleClick}
draggable="false"
$css={css`
display: inline;
display: contents;
padding: 0.1rem 0.4rem;
border-radius: 4px;
& svg {
@@ -78,6 +89,10 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
background-color: ${colorsTokens['greyscale-100']};
}
transition: background-color 0.2s ease-in-out;
.--docs--doc-deleted & {
pointer-events: none;
}
`}
>
{emoji ? (

View File

@@ -3,6 +3,7 @@ import {
StyleSchema,
} from '@blocknote/core';
import { useBlockNoteEditor } from '@blocknote/react';
import type { KeyboardEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -99,6 +100,55 @@ export const SearchPage = ({
}, 100);
}, [inputRef]);
const closeSearch = (insertContent: string) => {
updateInlineContent({
type: 'interlinkingSearchInline',
props: {
disabled: true,
trigger,
},
});
contentRef(null);
editor.focus();
editor.insertInlineContent([insertContent]);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
e.preventDefault();
// Keep the trigger character ('@' or '/') in the editor when closing with Escape
closeSearch(trigger);
} else if (e.key === 'Backspace' && search.length === 0) {
e.preventDefault();
closeSearch('');
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
// Allow arrow keys to be handled by the command menu for navigation
const commandList = e.currentTarget
.closest('.inline-content')
?.nextElementSibling?.querySelector('[cmdk-list]');
// Create a synthetic keyboard event for the command menu
const syntheticEvent = new KeyboardEvent('keydown', {
key: e.key,
bubbles: true,
cancelable: true,
});
commandList?.dispatchEvent(syntheticEvent);
e.preventDefault();
} else if (e.key === 'Enter') {
// Handle Enter key to select the currently highlighted item
const selectedItem = e.currentTarget
.closest('.inline-content')
?.nextElementSibling?.querySelector(
'[cmdk-item][data-selected="true"]',
) as HTMLElement;
selectedItem?.click();
e.preventDefault();
}
};
return (
<Box as="span" $position="relative">
<Box
@@ -124,50 +174,7 @@ export const SearchPage = ({
const value = (e.target as HTMLInputElement).value;
setSearch(value);
}}
onKeyDown={(e) => {
if (
(e.key === 'Backspace' && search.length === 0) ||
e.key === 'Escape'
) {
e.preventDefault();
updateInlineContent({
type: 'interlinkingSearchInline',
props: {
disabled: true,
trigger,
},
});
contentRef(null);
editor.focus();
editor.insertInlineContent(['']);
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
// Allow arrow keys to be handled by the command menu for navigation
const commandList = e.currentTarget
.closest('.inline-content')
?.nextElementSibling?.querySelector('[cmdk-list]');
// Create a synthetic keyboard event for the command menu
const syntheticEvent = new KeyboardEvent('keydown', {
key: e.key,
bubbles: true,
cancelable: true,
});
commandList?.dispatchEvent(syntheticEvent);
e.preventDefault();
} else if (e.key === 'Enter') {
// Handle Enter key to select the currently highlighted item
const selectedItem = e.currentTarget
.closest('.inline-content')
?.nextElementSibling?.querySelector(
'[cmdk-item][data-selected="true"]',
) as HTMLElement;
selectedItem?.click();
e.preventDefault();
}
}}
onKeyDown={handleKeyDown}
/>
</Box>
<Box
@@ -224,6 +231,8 @@ export const SearchPage = ({
},
});
contentRef(null);
editor.insertInlineContent([
{
type: 'interlinkingLinkInline',

View File

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

View File

@@ -43,7 +43,7 @@ describe('useSaveDoc', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
@@ -62,37 +62,6 @@ describe('useSaveDoc', () => {
addEventListenerSpy.mockRestore();
});
it('should not save when canSave is false', () => {
vi.useFakeTimers();
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
content: 'test-content',
title: 'test-title',
}),
});
renderHook(() => useSaveDoc(docId, yDoc, false, true), {
wrapper: AppWrapper,
});
act(() => {
// Trigger a local update
yDoc.getMap('test').set('key', 'value');
// Advance timers to trigger the save interval
vi.advanceTimersByTime(61000);
});
// Since canSave is false, no API call should be made
expect(fetchMock.calls().length).toBe(0);
vi.useRealTimers();
});
it('should save when there are local changes', async () => {
vi.useFakeTimers();
const yDoc = new Y.Doc();
@@ -106,7 +75,7 @@ describe('useSaveDoc', () => {
}),
});
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
@@ -143,7 +112,7 @@ describe('useSaveDoc', () => {
}),
});
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
@@ -163,7 +132,7 @@ describe('useSaveDoc', () => {
const docId = 'test-doc-id';
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, true), {
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});

View File

@@ -9,12 +9,13 @@ export const useHeadings = (editor: DocsBlockNoteEditor) => {
useEffect(() => {
setHeadings(editor);
editor?.onChange(() => {
const unsubscribe = editor?.onChange(() => {
setHeadings(editor);
});
return () => {
resetHeadings();
unsubscribe();
};
}, [editor, resetHeadings, setHeadings]);
};

View File

@@ -13,11 +13,10 @@ const SAVE_INTERVAL = 60000;
export const useSaveDoc = (
docId: string,
yDoc: Y.Doc,
canSave: boolean,
isConnectedToCollabServer: boolean,
) => {
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => {
setIsLocalChange(false);
},
@@ -47,7 +46,7 @@ export const useSaveDoc = (
}, [yDoc]);
const saveDoc = useCallback(() => {
if (!canSave || !isLocalChange) {
if (!isLocalChange) {
return false;
}
@@ -58,14 +57,7 @@ export const useSaveDoc = (
});
return true;
}, [
canSave,
isLocalChange,
updateDoc,
docId,
yDoc,
isConnectedToCollabServer,
]);
}, [isLocalChange, updateDoc, docId, yDoc, isConnectedToCollabServer]);
const router = useRouter();

View File

@@ -2,7 +2,10 @@ import { useEffect } from 'react';
import { DocsBlockNoteEditor } from '../types';
export const useShortcuts = (editor: DocsBlockNoteEditor) => {
export const useShortcuts = (
editor: DocsBlockNoteEditor,
el: HTMLDivElement | null,
) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === '@' && editor?.isFocused()) {
@@ -29,11 +32,14 @@ export const useShortcuts = (editor: DocsBlockNoteEditor) => {
}
};
// Attach the event listener to the document instead of the window
document.addEventListener('keydown', handleKeyDown);
if (!el) {
return;
}
el.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
el.removeEventListener('keydown', handleKeyDown);
};
}, [editor]);
}, [editor, el]);
};

View File

@@ -1,36 +1,12 @@
import { useCallback, useEffect, useRef } from 'react';
import { captureException } from '@sentry/nextjs';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { backendUrl } from '@/api';
import { useMediaUrl } from '@/core/config';
import { sleep } from '@/utils';
import { checkDocMediaStatus, useCreateDocAttachment } from '../api';
import { useCreateDocAttachment } from '../api';
import { DocsBlockNoteEditor } from '../types';
/**
* Upload file can be analyzed on the server side,
* we had this function to wait for the analysis to be done
* before returning the file url. It will keep the loader
* on the upload button until the analysis is done.
* @param url
* @returns Promise<CheckDocMediaStatusResponse> status_code
* @description Waits for the upload to be analyzed by checking the status of the file.
*/
const loopCheckDocMediaStatus = async (url: string) => {
const SLEEP_TIME = 5000;
const response = await checkDocMediaStatus({
urlMedia: url,
});
if (response.status === 'ready') {
return response;
} else {
await sleep(SLEEP_TIME);
return await loopCheckDocMediaStatus(url);
}
};
export const useUploadFile = (docId: string) => {
const {
mutateAsync: createDocAttachment,
@@ -63,94 +39,9 @@ export const useUploadFile = (docId: string) => {
export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
const ANALYZE_URL = 'media-check';
const { t } = useTranslation();
const mediaUrl = useMediaUrl();
const timeoutIds = useRef<Record<string, NodeJS.Timeout>>({});
const blockAnalyzeProcess = useCallback(
(editor: DocsBlockNoteEditor, blockId: string, url: string) => {
if (timeoutIds.current[url]) {
clearTimeout(timeoutIds.current[url]);
}
// Delay to let the time to the dom to be rendered
const timoutId = setTimeout(() => {
// Replace the resource block by a loading block
const { insertedBlocks, removedBlocks } = editor.replaceBlocks(
[blockId],
[
{
type: 'uploadLoader',
props: {
information: t('Analyzing file...'),
type: 'loading',
},
},
],
);
loopCheckDocMediaStatus(url)
.then((response) => {
if (insertedBlocks.length === 0 || removedBlocks.length === 0) {
return;
}
const loadingBlockId = insertedBlocks[0].id;
const removedBlock = removedBlocks[0];
removedBlock.props = {
...removedBlock.props,
url: `${mediaUrl}${response.file}`,
};
// Replace the loading block with the resource block (image, audio, video, pdf ...)
editor.replaceBlocks([loadingBlockId], [removedBlock]);
})
.catch((error) => {
console.error('Error analyzing file:', error);
const loadingBlock = insertedBlocks[0];
if (!loadingBlock) {
return;
}
loadingBlock.props = {
...loadingBlock.props,
type: 'warning',
information: t(
'The antivirus has detected an anomaly in your file.',
),
};
editor.updateBlock(loadingBlock.id, loadingBlock);
});
}, 250);
timeoutIds.current[url] = timoutId;
},
[t, mediaUrl],
);
useEffect(() => {
const blocksAnalyze = editor?.document.filter(
(block) => 'url' in block.props && block.props.url.includes(ANALYZE_URL),
);
if (!blocksAnalyze?.length) {
return;
}
blocksAnalyze.forEach((block) => {
if (!('url' in block.props)) {
return;
}
blockAnalyzeProcess(editor, block.id, block.props.url);
});
}, [blockAnalyzeProcess, editor]);
useEffect(() => {
editor.onChange((_, context) => {
const unsubscribe = editor.onChange((_, context) => {
const blocksChanges = context.getChanges();
if (!blocksChanges.length) {
@@ -169,11 +60,49 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
return;
}
blockAnalyzeProcess(
editor,
blockChanges.block.id,
blockChanges.block.props.url,
);
const blockUploadUrl = blockChanges.block.props.url;
const blockUploadType = blockChanges.block.type;
const blockUploadName = blockChanges.block.props.name;
const blockUploadShowPreview =
('showPreview' in blockChanges.block.props &&
blockChanges.block.props.showPreview) ||
false;
const timeoutId = setTimeout(() => {
// Replace the resource block by a uploadLoader block
// to show analyzing status
try {
editor.replaceBlocks(
[blockChanges.block.id],
[
{
type: 'uploadLoader',
props: {
information: t('Analyzing file...'),
type: 'loading',
blockUploadName,
blockUploadType,
blockUploadUrl,
blockUploadShowPreview,
},
},
],
);
} catch (error) {
captureException(error, {
extra: { info: 'Error replacing block for upload loader' },
});
}
}, 250);
return () => {
clearTimeout(timeoutId);
unsubscribe();
};
});
}, [blockAnalyzeProcess, mediaUrl, editor, t]);
return () => {
unsubscribe();
};
}, [editor, t]);
};

View File

@@ -1,165 +1,157 @@
import { css } from 'styled-components';
export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
export const cssEditor = css`
&,
& > .bn-container,
& .ProseMirror {
height: 100%;
padding-bottom: 2rem;
/**
* WCAG Accessibility contrast fixes for BlockNote editor
*/
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
color: #767676 !important;
font-weight: 400;
}
/**
* Ensure long placeholder text is truncated with ellipsis
*/
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: inherit;
height: inherit;
}
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child) {
position: relative;
}
/**
* Ensure images with unsafe URLs are not interactive
*/
img.bn-visual-media[src*='-unsafe'] {
pointer-events: none;
}
/**
* Collaboration cursor styles
*/
.collaboration-cursor-custom__base {
position: relative;
}
.collaboration-cursor-custom__caret {
position: absolute;
height: 100%;
width: 2px;
bottom: 4%;
left: -1px;
}
.collaboration-cursor-custom__label {
color: #0d0d0d;
font-size: 12px;
font-weight: 600;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
position: absolute;
top: -17px;
left: 0px;
padding: 0px 6px;
border-radius: 0px;
white-space: nowrap;
transition: clip-path 0.3s ease-in-out;
border-radius: 4px 4px 4px 0;
box-shadow: inset -2px 2px 6px #ffffff00;
clip-path: polygon(0 85%, 4% 85%, 4% 100%, 0% 100%);
}
.collaboration-cursor-custom__base[data-active]
.collaboration-cursor-custom__label {
pointer-events: none;
box-shadow: inset -2px 2px 6px #ffffff88;
clip-path: polygon(0 0, 100% 0%, 100% 100%, 0% 100%);
}
/**
* Side menu
*/
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 50px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 43px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 35px;
}
.bn-side-menu[data-block-type='divider'] {
height: 38px;
}
.bn-side-menu .mantine-UnstyledButton-root svg {
color: #767676 !important;
}
/**
* Callout, Paragraph and Heading blocks
*/
.bn-block {
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-outer {
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block > .bn-block-content[data-background-color] {
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
.bn-inline-content {
text-decoration: none;
}
h1 {
font-size: 1.875rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
a {
color: var(--c--theme--colors--greyscale-600);
cursor: pointer;
}
.bn-block-group
.bn-block-group
.bn-block-outer:not([data-prev-depth-changed]):before {
border-left: none;
}
${isDeletedDoc &&
`
.node-interlinkingLinkInline button {
pointer-events: none;
}
`}
}
& .bn-editor {
color: var(--c--theme--colors--greyscale-700);
}
/**
* Quotes
*/
blockquote {
border-left: 4px solid var(--c--theme--colors--greyscale-300);
font-style: italic;
}
/**
* WCAG Accessibility contrast fixes for BlockNote editor
*/
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
color: #767676 !important;
font-weight: 400;
}
/**
* Divider
*/
[data-content-type='divider'] hr {
background: #d3d2cf;
margin: 1rem 0;
width: 100%;
border: 1px solid #d3d2cf;
}
/**
* Ensure long placeholder text is truncated with ellipsis
*/
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: inherit;
height: inherit;
}
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child) {
position: relative;
}
/**
* Ensure images with unsafe URLs are not interactive
*/
img.bn-visual-media[src*='-unsafe'] {
pointer-events: none;
}
/**
* Collaboration cursor styles
*/
.collaboration-cursor-custom__base {
position: relative;
}
.collaboration-cursor-custom__caret {
position: absolute;
height: 100%;
width: 2px;
bottom: 4%;
left: -1px;
}
.collaboration-cursor-custom__label {
color: #0d0d0d;
font-size: 12px;
font-weight: 600;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
position: absolute;
top: -17px;
left: 0px;
padding: 0px 6px;
border-radius: 0px;
white-space: nowrap;
transition: clip-path 0.3s ease-in-out;
border-radius: 4px 4px 4px 0;
box-shadow: inset -2px 2px 6px #ffffff00;
clip-path: polygon(0 85%, 4% 85%, 4% 100%, 0% 100%);
}
.collaboration-cursor-custom__base[data-active]
.collaboration-cursor-custom__label {
pointer-events: none;
box-shadow: inset -2px 2px 6px #ffffff88;
clip-path: polygon(0 0, 100% 0%, 100% 100%, 0% 100%);
}
/**
* Side menu
*/
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 54px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 43px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 35px;
}
.bn-side-menu[data-block-type='divider'] {
height: 38px;
}
.bn-side-menu .mantine-UnstyledButton-root svg {
color: #767676 !important;
}
/**
* Callout, Paragraph and Heading blocks
*/
.bn-block {
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-outer {
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block > .bn-block-content[data-background-color] {
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
.bn-inline-content {
text-decoration: none;
}
h1 {
font-size: 1.875rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
a {
color: var(--c--theme--colors--greyscale-600);
cursor: pointer;
}
.bn-block-group
.bn-block-group
.bn-block-outer:not([data-prev-depth-changed]):before {
border-left: none;
}
/**
* Quotes
*/
blockquote {
border-left: 4px solid var(--c--theme--colors--greyscale-300);
font-style: italic;
}
/**
* Divider
*/
[data-content-type='divider'] hr {
background: #d3d2cf;
margin: 1rem 0;
width: 100%;
border: 1px solid #d3d2cf;
}
& .bn-block-outer:not(:first-child) {
@@ -187,8 +179,10 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
}
@media screen and (width <= 560px) {
.--docs--doc-readonly & .bn-editor {
padding-left: 10px;
}
& .bn-editor {
${readonly && `padding-left: 10px;`}
padding-right: 10px;
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { DocsExporterODT } from '../types';
import { odtRegisterParagraphStyleForBlock } from '../utils';
export const blockMappingCalloutODT: DocsExporterODT['mappings']['blockMapping']['callout'] =
(block, exporter) => {
// Map callout to paragraph with emoji prefix
const emoji = block.props.emoji || '💡';
// Transform inline content (text, bold, links, etc.)
const inlineContent = exporter.transformInlineContent(block.content);
// Resolve background and alignment → create a dedicated paragraph style
const styleName = odtRegisterParagraphStyleForBlock(
exporter,
{
backgroundColor: block.props.backgroundColor,
textAlignment: block.props.textAlignment,
},
{ paddingCm: 0.42 },
);
return React.createElement(
'text:p',
{
'text:style-name': styleName,
},
`${emoji} `,
...inlineContent,
);
};

View File

@@ -31,15 +31,10 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
const svgText = await blob.text();
const FALLBACK_SIZE = 536;
previewWidth = previewWidth || blob.size || FALLBACK_SIZE;
pngConverted = await convertSvgToPng(svgText, previewWidth);
const img = new Image();
img.src = pngConverted;
await new Promise((resolve) => {
img.onload = () => {
dimensions = { width: img.width, height: img.height };
resolve(null);
};
});
const result = await convertSvgToPng(svgText, previewWidth);
pngConverted = result.png;
dimensions = { width: result.width, height: result.height };
} else {
dimensions = await getImageDimensions(blob);
}
@@ -50,9 +45,9 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
const { width, height } = dimensions;
if (previewWidth && previewWidth > MAX_WIDTH) {
previewWidth = MAX_WIDTH;
}
// Ensure the final width never exceeds MAX_WIDTH to prevent images
// from overflowing the page width in the exported document
const finalWidth = Math.min(previewWidth || width, MAX_WIDTH);
return [
new Paragraph({
@@ -71,8 +66,8 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
}
: undefined,
transformation: {
width: previewWidth || width,
height: ((previewWidth || width) / width) * height,
width: finalWidth,
height: (finalWidth / width) * height,
},
}),
],

View File

@@ -0,0 +1,135 @@
import React from 'react';
import { DocsExporterODT } from '../types';
import { convertSvgToPng, odtRegisterParagraphStyleForBlock } from '../utils';
const MAX_WIDTH = 600;
export const blockMappingImageODT: DocsExporterODT['mappings']['blockMapping']['image'] =
async (block, exporter) => {
try {
const blob = await exporter.resolveFile(block.props.url);
if (!blob || !blob.type) {
console.warn(`Failed to resolve image: ${block.props.url}`);
return null;
}
let pngConverted: string | undefined;
let dimensions: { width: number; height: number } | undefined;
let previewWidth = block.props.previewWidth || undefined;
if (!blob.type.includes('image')) {
console.warn(`Not an image type: ${blob.type}`);
return null;
}
if (blob.type.includes('svg')) {
const svgText = await blob.text();
const FALLBACK_SIZE = 536;
previewWidth = previewWidth || blob.size || FALLBACK_SIZE;
const result = await convertSvgToPng(svgText, previewWidth);
pngConverted = result.png;
dimensions = { width: result.width, height: result.height };
} else {
dimensions = await getImageDimensions(blob);
}
if (!dimensions) {
return null;
}
const { width, height } = dimensions;
if (previewWidth && previewWidth > MAX_WIDTH) {
previewWidth = MAX_WIDTH;
}
// Convert image to base64 for ODT embedding
const arrayBuffer = pngConverted
? await (await fetch(pngConverted)).arrayBuffer()
: await blob.arrayBuffer();
const base64 = btoa(
Array.from(new Uint8Array(arrayBuffer))
.map((byte) => String.fromCharCode(byte))
.join(''),
);
const finalWidth = previewWidth || width;
const finalHeight = ((previewWidth || width) / width) * height;
const baseParagraphProps = {
backgroundColor: block.props.backgroundColor,
textAlignment: block.props.textAlignment,
};
const paragraphStyleName = odtRegisterParagraphStyleForBlock(
exporter,
baseParagraphProps,
{ paddingCm: 0 },
);
// Convert pixels to cm (ODT uses cm for dimensions)
const widthCm = finalWidth / 37.795275591;
const heightCm = finalHeight / 37.795275591;
// Create ODT image structure using React.createElement
const frame = React.createElement(
'text:p',
{
'text:style-name': paragraphStyleName,
},
React.createElement(
'draw:frame',
{
'draw:name': `Image${Date.now()}`,
'text:anchor-type': 'as-char',
'svg:width': `${widthCm}cm`,
'svg:height': `${heightCm}cm`,
},
React.createElement(
'draw:image',
{
xlinkType: 'simple',
xlinkShow: 'embed',
xlinkActuate: 'onLoad',
},
React.createElement('office:binary-data', {}, base64),
),
),
);
// Add caption if present
if (block.props.caption) {
const captionStyleName = odtRegisterParagraphStyleForBlock(
exporter,
baseParagraphProps,
{ paddingCm: 0, parentStyleName: 'Caption' },
);
return [
frame,
React.createElement(
'text:p',
{ 'text:style-name': captionStyleName },
block.props.caption,
),
];
}
return frame;
} catch (error) {
console.error(`Error processing image for ODT export:`, error);
return null;
}
};
async function getImageDimensions(blob: Blob) {
if (typeof window !== 'undefined') {
const bmp = await createImageBitmap(blob);
const { width, height } = bmp;
bmp.close();
return { width, height };
}
}

View File

@@ -6,12 +6,14 @@ import { convertSvgToPng } from '../utils';
const PIXELS_PER_POINT = 0.75;
const FONT_SIZE = 16;
const MAX_WIDTH = 600;
export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['image'] =
async (block, exporter) => {
const blob = await exporter.resolveFile(block.props.url);
let pngConverted: string | undefined;
let width = block.props.previewWidth || undefined;
let dimensions: { width: number; height: number } | undefined;
let previewWidth = block.props.previewWidth || undefined;
if (!blob.type.includes('image')) {
return <View wrap={false} />;
@@ -20,16 +22,33 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
if (blob.type.includes('svg')) {
const svgText = await blob.text();
const FALLBACK_SIZE = 536;
width = width || blob.size || FALLBACK_SIZE;
pngConverted = await convertSvgToPng(svgText, width);
previewWidth = previewWidth || FALLBACK_SIZE;
const result = await convertSvgToPng(svgText, previewWidth);
pngConverted = result.png;
dimensions = { width: result.width, height: result.height };
} else {
dimensions = await getImageDimensions(blob);
}
if (!dimensions) {
return <View wrap={false} />;
}
const { width, height } = dimensions;
// Ensure the final width never exceeds MAX_WIDTH to prevent images
// from overflowing the page width in the exported document
const finalWidth = Math.min(previewWidth || width, MAX_WIDTH);
const finalHeight = (finalWidth / width) * height;
return (
<View wrap={false}>
<Image
src={pngConverted || blob}
style={{
width: width ? width * PIXELS_PER_POINT : undefined,
width: finalWidth * PIXELS_PER_POINT,
height: finalHeight * PIXELS_PER_POINT,
maxWidth: '100%',
}}
/>
@@ -38,6 +57,21 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
);
};
async function getImageDimensions(blob: Blob) {
if (typeof window !== 'undefined') {
const url = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = url;
return new Promise<{ width: number; height: number }>((resolve) => {
img.onload = () => {
URL.revokeObjectURL(url);
resolve({ width: img.naturalWidth, height: img.naturalHeight });
};
});
}
}
function caption(
props: Partial<DefaultProps & { caption: string; previewWidth: number }>,
) {

View File

@@ -1,11 +1,14 @@
export * from './calloutDocx';
export * from './calloutODT';
export * from './calloutPDF';
export * from './headingPDF';
export * from './imageDocx';
export * from './imageODT';
export * from './imagePDF';
export * from './paragraphPDF';
export * from './quoteDocx';
export * from './quotePDF';
export * from './tablePDF';
export * from './uploadLoaderPDF';
export * from './uploadLoaderDocx';
export * from './uploadLoaderODT';
export * from './uploadLoaderPDF';

View File

@@ -23,8 +23,9 @@ export const blockMappingParagraphPDF: DocsExporterPDF['mappings']['blockMapping
});
}
}
return (
<Text key={block.id}>
<Text key={'paragraph' + block.id}>
{exporter.transformInlineContent(block.content)}
</Text>
);

View File

@@ -2,7 +2,7 @@
* We use mainly the Blocknotes code, mixed with @ag-media/react-pdf-table
* to have a better Table support.
* See:
* https://github.com/TypeCellOS/BlockNote/blob/004c0bf720fe1415c497ad56449015c5f4dd7ba0/packages/xl-pdf-exporter/src/pdf/util/table/Table.tsx
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/src/pdf/util/table/Table.tsx
*
* We succeeded to manage the colspan, but rowspan is not supported yet.
*/
@@ -92,18 +92,27 @@ export const blockMappingTablePDF: DocsExporterPDF['mappings']['blockMapping']['
color:
cellProps.textColor === 'default'
? undefined
: options.colors[cellProps.textColor].text,
: options.colors?.[cellProps.textColor]?.text,
backgroundColor:
cellProps.backgroundColor === 'default'
? undefined
: options.colors[cellProps.backgroundColor].background,
: options.colors?.[cellProps.backgroundColor]
?.background,
textAlign: cellProps.textAlignment,
},
];
return (
<TD key={colIndex} style={arrayStyle}>
<Text style={styles.cell}>
<Text
style={[
styles.cell,
{
width: '100%',
textAlign: cellProps.textAlignment ?? 'left',
},
]}
>
{exporter.transformInlineContent(cell)}
</Text>
</TD>

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { DocsExporterODT } from '../types';
export const blockMappingUploadLoaderODT: DocsExporterODT['mappings']['blockMapping']['uploadLoader'] =
(block) => {
// Map uploadLoader to paragraph with information text
const information = block.props.information || '';
const type = block.props.type || 'loading';
const prefix = type === 'warning' ? '⚠️ ' : '⏳ ';
return React.createElement(
'text:p',
{ 'text:style-name': 'Text_20_body' },
`${prefix}${information}`,
);
};

View File

@@ -1,4 +1,5 @@
import { DOCXExporter } from '@blocknote/xl-docx-exporter';
import { ODTExporter } from '@blocknote/xl-odt-exporter';
import { PDFExporter } from '@blocknote/xl-pdf-exporter';
import {
Button,
@@ -23,12 +24,14 @@ import { Doc, useTrans } from '@/docs/doc-management';
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';
enum DocDownloadFormat {
PDF = 'pdf',
DOCX = 'docx',
ODT = 'odt',
}
interface ModalExportProps {
@@ -124,7 +127,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
: rawPdfDocument;
blobExport = await pdf(pdfDocument).toBlob();
} else {
} else if (format === DocDownloadFormat.DOCX) {
const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, {
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
});
@@ -133,6 +136,16 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
documentOptions: { title: documentTitle },
sectionOptions: {},
});
} else if (format === DocDownloadFormat.ODT) {
const exporter = new ODTExporter(editor.schema, odtDocsSchemaMappings, {
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
});
blobExport = await exporter.toODTDocument(exportDocument);
} else {
toast(t('The export failed'), VariantType.ERROR);
setIsExporting(false);
return;
}
downloadFile(blobExport, `${filename}.${format}`);
@@ -213,7 +226,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
className="--docs--modal-export-content"
>
<Text $variation="600" $size="sm" as="p">
{t('Download your document in a .docx or .pdf format.')}
{t('Download your document in a .docx, .odt or .pdf format.')}
</Text>
<Select
clearable={false}
@@ -231,6 +244,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
label={t('Format')}
options={[
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
{ label: t('ODT'), value: DocDownloadFormat.ODT },
{ label: t('PDF'), value: DocDownloadFormat.PDF },
]}
value={format}

View File

@@ -1,2 +1,3 @@
export * from './interlinkingLinkPDF';
export * from './interlinkingLinkDocx';
export * from './interlinkingLinkODT';

View File

@@ -0,0 +1,23 @@
import React from 'react';
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;
// Create ODT hyperlink using React.createElement to avoid TypeScript JSX namespace issues
// Uses the same structure as BlockNote's default link mapping
return React.createElement(
'text:a',
{
xlinkType: 'simple',
'text:style-name': 'Internet_20_link',
'office:target-frame-name': '_top',
xlinkShow: 'replace',
xlinkHref: url,
},
`📄${title}`,
);
};

View File

@@ -0,0 +1,33 @@
import { odtDefaultSchemaMappings } from '@blocknote/xl-odt-exporter';
import {
blockMappingCalloutODT,
blockMappingImageODT,
blockMappingUploadLoaderODT,
} from './blocks-mapping';
import { inlineContentMappingInterlinkingLinkODT } from './inline-content-mapping';
import { DocsExporterODT } from './types';
// Align default inline mappings to our editor inline schema without using `any`
const baseInlineMappings =
odtDefaultSchemaMappings.inlineContentMapping as unknown as DocsExporterODT['mappings']['inlineContentMapping'];
export const odtDocsSchemaMappings: DocsExporterODT['mappings'] = {
...odtDefaultSchemaMappings,
blockMapping: {
...odtDefaultSchemaMappings.blockMapping,
callout: blockMappingCalloutODT,
image: blockMappingImageODT,
// We're reusing the file block mapping for PDF blocks
// The types don't match exactly but the implementation is compatible
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdf: odtDefaultSchemaMappings.blockMapping.file as any,
uploadLoader: blockMappingUploadLoaderODT,
},
inlineContentMapping: {
...baseInlineMappings,
interlinkingSearchInline: () => null,
interlinkingLinkInline: inlineContentMappingInterlinkingLinkODT,
},
};

View File

@@ -51,3 +51,13 @@ export type DocsExporterDocx = Exporter<
IRunPropertiesOptions,
TextRun
>;
export type DocsExporterODT = Exporter<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema,
React.ReactNode,
React.ReactNode,
Record<string, string>,
React.ReactNode
>;

View File

@@ -5,6 +5,7 @@ import {
} from '@blocknote/core';
import { Canvg } from 'canvg';
import { IParagraphOptions, ShadingType } from 'docx';
import React from 'react';
export function downloadFile(blob: Blob, filename: string) {
const url = window.URL.createObjectURL(blob);
@@ -19,18 +20,21 @@ export function downloadFile(blob: Blob, filename: string) {
}
/**
* Converts an SVG string into a PNG image and returns it as a data URL.
* Converts an SVG string into a PNG image and returns it as a data URL with dimensions.
*
* This function creates a canvas, parses the SVG, calculates the appropriate height
* to preserve the aspect ratio, and renders the SVG onto the canvas using Canvg.
*
* @param {string} svgText - The raw SVG markup to convert.
* @param {number} width - The desired width of the output PNG (height is auto-calculated to preserve aspect ratio).
* @returns {Promise<string>} A Promise that resolves to a PNG image encoded as a base64 data URL.
* @returns {Promise<{ png: string; width: number; height: number }>} A Promise that resolves to an object containing the PNG data URL and its dimensions.
*
* @throws Will throw an error if the canvas context cannot be initialized.
*/
export async function convertSvgToPng(svgText: string, width: number) {
export async function convertSvgToPng(
svgText: string,
width: number,
): Promise<{ png: string; width: number; height: number }> {
// Create a canvas and render the SVG onto it
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', {
@@ -63,7 +67,11 @@ export async function convertSvgToPng(svgText: string, width: number) {
svg.resize(width, height, true);
await svg.render();
return canvas.toDataURL('image/png');
return {
png: canvas.toDataURL('image/png'),
width,
height: height || width,
};
}
export function docxBlockPropsToStyles(
@@ -98,3 +106,76 @@ export function docxBlockPropsToStyles(
})(),
};
}
// ODT helpers
type OdtExporterLike = {
options?: { colors?: typeof COLORS_DEFAULT };
registerStyle: (fn: (name: string) => React.ReactNode) => string;
};
function isOdtExporterLike(value: unknown): value is OdtExporterLike {
return (
!!value &&
typeof (value as { registerStyle?: unknown }).registerStyle === 'function'
);
}
export function odtRegisterParagraphStyleForBlock(
exporter: unknown,
props: Partial<DefaultProps>,
options?: { paddingCm?: number; parentStyleName?: string },
) {
if (!isOdtExporterLike(exporter)) {
throw new Error('Invalid ODT exporter: missing registerStyle');
}
const colors = exporter.options?.colors;
const bgColorHex =
props.backgroundColor && props.backgroundColor !== 'default' && colors
? colors[props.backgroundColor].background
: undefined;
const textColorHex =
props.textColor && props.textColor !== 'default' && colors
? colors[props.textColor].text
: undefined;
const foTextAlign =
!props.textAlignment || props.textAlignment === 'left'
? 'start'
: props.textAlignment === 'center'
? 'center'
: props.textAlignment === 'right'
? 'end'
: 'justify';
const paddingCm = options?.paddingCm ?? 0.42; // ~1rem (16px)
const parentStyleName = options?.parentStyleName;
// registerStyle is available on ODT exporter; call through with React elements
const styleName = exporter.registerStyle((name: string) =>
React.createElement(
'style:style',
{
'style:name': name,
'style:family': 'paragraph',
...(parentStyleName
? { 'style:parent-style-name': parentStyleName }
: {}),
},
React.createElement('style:paragraph-properties', {
'fo:text-align': foTextAlign,
'fo:padding': `${paddingCm}cm`,
...(bgColorHex ? { 'fo:background-color': bgColorHex } : {}),
}),
textColorHex
? React.createElement('style:text-properties', {
'fo:color': textColorHex,
})
: undefined,
),
);
return styleName;
}

View File

@@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, test, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
// Force mobile layout so the children count is rendered
vi.mock('@/stores', () => ({
useResponsiveStore: () => ({ isDesktop: false }),
}));
// Provide stable mocks for hooks used by the component
vi.mock('../../doc-management', async () => {
const actual = await vi.importActual<any>('../../doc-management');
return {
...actual,
useTrans: () => ({ transRole: vi.fn((r) => String(r)) }),
useIsCollaborativeEditable: () => ({ isEditable: true }),
};
});
vi.mock('@/core', () => ({
useConfig: () => ({ data: {} }),
}));
vi.mock('@/hook', () => ({
useDate: () => ({
relativeDate: () => 'yesterday',
calculateDaysLeft: () => 5,
}),
}));
import { DocHeaderInfo } from '../components/DocHeaderInfo';
describe('DocHeaderInfo', () => {
test('renders the number of sub-documents when numchild is provided (mobile layout)', () => {
const doc = {
numchild: 3,
updated_at: new Date().toISOString(),
} as any;
render(<DocHeaderInfo doc={doc} />, { wrapper: AppWrapper });
expect(screen.getByText(/Contains 3 sub-documents/i)).toBeInTheDocument();
});
});

View File

@@ -44,7 +44,7 @@ describe('DocToolBox - Licence', () => {
await userEvent.click(optionsButton);
expect(
await screen.findByText(
'Download your document in a .docx or .pdf format.',
'Download your document in a .docx, .odt or .pdf format.',
),
).toBeInTheDocument();
}, 10000);

View File

@@ -76,7 +76,7 @@ export const BoutonShare = ({
return (
<Button
color="tertiary-text"
color="primary-text"
onClick={open}
size="medium"
disabled={isDisabled}

View File

@@ -1,23 +1,20 @@
import { useTranslation } from 'react-i18next';
import { Box, HorizontalSeparator, Text } from '@/components';
import { useConfig } from '@/core';
import { Box, HorizontalSeparator } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
LinkReach,
Role,
getDocLinkReach,
useIsCollaborativeEditable,
useTrans,
} from '@/docs/doc-management';
import { useDate } from '@/hook';
import { useResponsiveStore } from '@/stores';
import { AlertNetwork } from './AlertNetwork';
import { AlertPublic } from './AlertPublic';
import { AlertRestore } from './AlertRestore';
import { BoutonShare } from './BoutonShare';
import { DocHeaderInfo } from './DocHeaderInfo';
import { DocTitle } from './DocTitle';
import { DocToolBox } from './DocToolBox';
@@ -29,27 +26,11 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { transRole } = useTrans();
const { isEditable } = useIsCollaborativeEditable(doc);
const docIsPublic = getDocLinkReach(doc) === LinkReach.PUBLIC;
const docIsAuth = getDocLinkReach(doc) === LinkReach.AUTHENTICATED;
const { relativeDate, calculateDaysLeft } = useDate();
const { data: config } = useConfig();
const isDeletedDoc = !!doc.deleted_at;
let dateToDisplay = t('Last update: {{update}}', {
update: relativeDate(doc.updated_at),
});
if (config?.TRASHBIN_CUTOFF_DAYS && doc.deleted_at) {
const daysLeft = calculateDaysLeft(
doc.deleted_at,
config.TRASHBIN_CUTOFF_DAYS,
);
dateToDisplay = `${t('Days remaining:')} ${daysLeft} ${t('days', { count: daysLeft })}`;
}
return (
<>
<Box
@@ -80,33 +61,8 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
>
<Box $gap={spacingsTokens['3xs']} $overflow="auto">
<DocTitle doc={doc} />
<Box $direction="row">
{isDesktop && (
<>
<Text
$variation="600"
$size="s"
$weight="bold"
$theme={isEditable ? 'greyscale' : 'warning'}
>
{transRole(
isEditable
? doc.user_role || doc.link_role
: Role.READER,
)}
&nbsp;·&nbsp;
</Text>
<Text $variation="600" $size="s">
{dateToDisplay}
</Text>
</>
)}
{!isDesktop && (
<Text $variation="400" $size="s">
{dateToDisplay}
</Text>
)}
<DocHeaderInfo doc={doc} />
</Box>
</Box>
{!isDeletedDoc && <DocToolBox doc={doc} />}

View File

@@ -0,0 +1,80 @@
import { t } from 'i18next';
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';
interface DocHeaderInfoProps {
doc: Doc;
}
export const DocHeaderInfo = ({ doc }: DocHeaderInfoProps) => {
const { isDesktop } = useResponsiveStore();
const { transRole } = useTrans();
const { isEditable } = useIsCollaborativeEditable(doc);
const { relativeDate, calculateDaysLeft } = useDate();
const { data: config } = useConfig();
const childrenCount = doc.numchild ?? 0;
const relativeOnly = relativeDate(doc.updated_at);
let dateToDisplay = t('Last update: {{update}}', {
update: relativeOnly,
});
if (config?.TRASHBIN_CUTOFF_DAYS && doc.deleted_at) {
const daysLeft = calculateDaysLeft(
doc.deleted_at,
config.TRASHBIN_CUTOFF_DAYS,
);
dateToDisplay = `${t('Days remaining:')} ${daysLeft} ${t('days', { count: daysLeft })}`;
}
const hasChildren = childrenCount > 0;
if (isDesktop) {
return (
<>
<Text
$variation="600"
$size="s"
$weight="bold"
$theme={isEditable ? 'greyscale' : 'warning'}
>
{transRole(isEditable ? doc.user_role || doc.link_role : Role.READER)}
&nbsp;·&nbsp;
</Text>
<Text $variation="600" $size="s">
{dateToDisplay}
</Text>
</>
);
}
return (
<>
<Text $variation="400" $size="s">
{hasChildren ? relativeOnly : dateToDisplay}
</Text>
{hasChildren && (
<Text $variation="400" $size="s">
&nbsp;&nbsp;
{t('Contains {{count}} sub-documents', {
count: childrenCount,
})}
</Text>
)}
</>
);
};

View File

@@ -55,8 +55,16 @@ const DocTitleEmojiPicker = ({ doc }: DocTitleProps) => {
const { colorsTokens } = useCunninghamTheme();
const { emoji } = getEmojiAndTitle(doc.title ?? '');
if (!emoji) {
return null;
}
return (
<Tooltip content={t('Document emoji')} aria-hidden={true} placement="top">
<Tooltip
content={t('Edit document emoji')}
aria-hidden={true}
placement="top"
>
<Box
$css={css`
padding: 4px;
@@ -70,11 +78,17 @@ const DocTitleEmojiPicker = ({ doc }: DocTitleProps) => {
`}
>
<DocIcon
buttonProps={{
$width: '32px',
$height: '32px',
$justify: 'space-between',
$align: 'center',
}}
withEmojiPicker={doc.abilities.partial_update}
docId={doc.id}
title={doc.title}
emoji={emoji}
$size="25px"
$size="23px"
defaultIcon={
<SimpleFileIcon
width="25px"
@@ -94,7 +108,6 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { spacingsTokens } = useCunninghamTheme();
const { isTopRoot } = useDocUtils(doc);
const { untitledDocument } = useTrans();
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title ?? '');
@@ -139,19 +152,9 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
className="--docs--doc-title"
$direction="row"
$align="center"
$gap={spacingsTokens['xs']}
$gap="4px"
$minHeight="40px"
>
{isTopRoot && (
<SimpleFileIcon
width="25px"
height="25px"
aria-hidden="true"
aria-label={t('Simple document icon')}
color={colorsTokens['primary-500']}
style={{ flexShrink: '0' }}
/>
)}
{!isTopRoot && <DocTitleEmojiPicker doc={doc} />}
<Tooltip content={t('Rename')} aria-hidden={true} placement="top">

View File

@@ -60,7 +60,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const selectHistoryModal = useModal();
const modalShare = useModal();
const { isSmallMobile, isDesktop } = useResponsiveStore();
const { isSmallMobile, isMobile } = useResponsiveStore();
const copyDocLink = useCopyDocLink(doc.id);
const { mutate: duplicateDoc } = useDuplicateDoc({
onSuccess: (data) => {
@@ -69,10 +69,10 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
});
const { isFeatureFlagActivated } = useAnalytics();
const removeFavoriteDoc = useDeleteFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
});
const makeFavoriteDoc = useCreateFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
});
useEffect(() => {
@@ -90,28 +90,20 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { updateDocEmoji } = useDocTitleUpdate();
const options: DropdownMenuOption[] = [
...(isSmallMobile
? [
{
label: t('Share'),
icon: 'group',
callback: modalShare.open,
},
{
label: t('Export'),
icon: 'download',
callback: () => {
setIsModalExportOpen(true);
},
show: !!ModalExport,
},
{
label: t('Copy link'),
icon: 'add_link',
callback: copyDocLink,
},
]
: []),
{
label: t('Share'),
icon: 'group',
callback: modalShare.open,
show: isSmallMobile,
},
{
label: t('Export'),
icon: 'download',
callback: () => {
setIsModalExportOpen(true);
},
show: !!ModalExport && isSmallMobile,
},
{
label: doc.is_favorite ? t('Unpin') : t('Pin'),
icon: 'push_pin',
@@ -124,17 +116,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
},
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
},
...(emoji && doc.abilities.partial_update && !isTopRoot
? [
{
label: t('Remove emoji'),
icon: 'emoji_emotions',
callback: () => {
updateDocEmoji(doc.id, doc.title ?? '', '');
},
},
]
: []),
{
label: t('Version history'),
icon: 'history',
@@ -142,7 +123,31 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
callback: () => {
selectHistoryModal.open();
},
show: isDesktop,
show: !isMobile,
showSeparator: isTopRoot ? true : false,
},
{
label: t('Remove emoji'),
icon: 'emoji_emotions',
callback: () => {
updateDocEmoji(doc.id, doc.title ?? '', '');
},
showSeparator: true,
show: !!emoji && doc.abilities.partial_update && !isTopRoot,
},
{
label: t('Add emoji'),
icon: 'emoji_emotions',
callback: () => {
updateDocEmoji(doc.id, doc.title ?? '', '📄');
},
showSeparator: true,
show: !emoji && doc.abilities.partial_update && !isTopRoot,
},
{
label: t('Copy link'),
icon: 'add_link',
callback: copyDocLink,
},
{
label: t('Copy as {{format}}', { format: 'Markdown' }),
@@ -158,6 +163,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
void copyCurrentEditorToClipboard('html');
},
show: isFeatureFlagActivated('CopyAsHTML'),
showSeparator: true,
},
{
label: t('Duplicate'),
@@ -170,6 +176,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
canSave: doc.abilities.partial_update,
});
},
showSeparator: true,
},
{
label: isChild ? t('Delete sub-document') : t('Delete document'),
@@ -224,25 +231,22 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
aria-label={t('Export the document')}
/>
)}
<DropdownMenu options={options} label={t('Open the document options')}>
<IconOptions
aria-hidden="true"
isHorizontal
$theme="primary"
$padding={{ all: 'xs' }}
$css={css`
border-radius: 4px;
&:hover {
background-color: ${colorsTokens['greyscale-100']};
}
${isSmallMobile
? css`
padding: 10px;
border: 1px solid ${colorsTokens['greyscale-300']};
`
: ''}
`}
/>
<DropdownMenu
options={options}
label={t('Open the document options')}
buttonCss={css`
padding: ${spacingsTokens['xs']};
&:hover {
background-color: ${colorsTokens['greyscale-100']};
}
${isSmallMobile
? css`
border: 1px solid ${colorsTokens['greyscale-300']};
`
: ''}
`}
>
<IconOptions aria-hidden="true" isHorizontal $theme="primary" />
</DropdownMenu>
</Box>

View File

@@ -1,2 +1,2 @@
export * from './DocHeader';
export * from './DocVersionHeader';
export * from './DocTitle';

View File

@@ -10,13 +10,16 @@ import {
getEmojiAndTitle,
} from '../utils';
// Mock Y.js
vi.mock('yjs', () => ({
Doc: vi.fn().mockImplementation(() => ({
getXmlFragment: vi.fn().mockReturnValue('mocked-xml-fragment'),
})),
applyUpdate: vi.fn(),
}));
vi.mock('yjs', () => {
class MockDoc {
getXmlFragment = vi.fn().mockReturnValue('mocked-xml-fragment');
}
return {
Doc: MockDoc,
applyUpdate: vi.fn(),
};
});
describe('doc-management utils', () => {
beforeEach(() => {
@@ -26,45 +29,30 @@ describe('doc-management utils', () => {
describe('base64ToYDoc', () => {
it('should convert base64 string to Y.Doc', () => {
const base64String = 'dGVzdA=='; // "test" in base64
const mockYDoc = { getXmlFragment: vi.fn() };
(Y.Doc as any).mockReturnValue(mockYDoc);
const result = base64ToYDoc(base64String);
expect(Y.Doc).toHaveBeenCalled();
expect(Y.applyUpdate).toHaveBeenCalledWith(mockYDoc, expect.any(Buffer));
expect(result).toBe(mockYDoc);
expect(result).toBeInstanceOf(Y.Doc);
expect(Y.applyUpdate).toHaveBeenCalledWith(result, expect.any(Buffer));
});
it('should handle empty base64 string', () => {
const base64String = '';
const mockYDoc = { getXmlFragment: vi.fn() };
(Y.Doc as any).mockReturnValue(mockYDoc);
const result = base64ToYDoc(base64String);
expect(Y.Doc).toHaveBeenCalled();
expect(Y.applyUpdate).toHaveBeenCalledWith(mockYDoc, expect.any(Buffer));
expect(result).toBe(mockYDoc);
expect(result).toBeInstanceOf(Y.Doc);
expect(Y.applyUpdate).toHaveBeenCalledWith(result, expect.any(Buffer));
});
});
describe('base64ToBlocknoteXmlFragment', () => {
it('should convert base64 to Blocknote XML fragment', () => {
const base64String = 'dGVzdA==';
const mockYDoc = {
getXmlFragment: vi.fn().mockReturnValue('mocked-xml-fragment'),
};
(Y.Doc as any).mockReturnValue(mockYDoc);
const result = base64ToBlocknoteXmlFragment(base64String);
expect(Y.Doc).toHaveBeenCalled();
expect(Y.applyUpdate).toHaveBeenCalledWith(mockYDoc, expect.any(Buffer));
expect(mockYDoc.getXmlFragment).toHaveBeenCalledWith('document-store');
expect(Y.applyUpdate).toHaveBeenCalled();
expect(result).toBe('mocked-xml-fragment');
});
});

View File

@@ -21,18 +21,18 @@ export const createFavoriteDoc = async ({ id }: CreateFavoriteDocParams) => {
interface CreateFavoriteDocProps {
onSuccess?: () => void;
listInvalideQueries?: string[];
listInvalidQueries?: string[];
}
export function useCreateFavoriteDoc({
onSuccess,
listInvalideQueries,
listInvalidQueries,
}: CreateFavoriteDocProps) {
const queryClient = useQueryClient();
return useMutation<void, APIError, CreateFavoriteDocParams>({
mutationFn: createFavoriteDoc,
onSuccess: () => {
listInvalideQueries?.forEach((queryKey) => {
listInvalidQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});

View File

@@ -21,18 +21,18 @@ export const deleteFavoriteDoc = async ({ id }: DeleteFavoriteDocParams) => {
interface DeleteFavoriteDocProps {
onSuccess?: () => void;
listInvalideQueries?: string[];
listInvalidQueries?: string[];
}
export function useDeleteFavoriteDoc({
onSuccess,
listInvalideQueries,
listInvalidQueries,
}: DeleteFavoriteDocProps) {
const queryClient = useQueryClient();
return useMutation<void, APIError, DeleteFavoriteDocParams>({
mutationFn: deleteFavoriteDoc,
onSuccess: () => {
listInvalideQueries?.forEach((queryKey) => {
listInvalidQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});

View File

@@ -60,7 +60,7 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
const { provider } = useProviderStore();
const { mutateAsync: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
});
return useMutation<DuplicateDocResponse, APIError, DuplicateDocParams>({

View File

@@ -34,7 +34,7 @@ export const updateDoc = async ({
};
type UseUpdateDoc = UseMutationOptions<Doc, APIError, Partial<Doc>> & {
listInvalideQueries?: string[];
listInvalidQueries?: string[];
};
export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
@@ -43,7 +43,7 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
mutationFn: updateDoc,
...queryConfig,
onSuccess: (data, variables, onMutateResult, context) => {
queryConfig?.listInvalideQueries?.forEach((queryKey) => {
queryConfig?.listInvalidQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});

View File

@@ -1,12 +1,13 @@
import { MouseEvent, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { BoxButton, Icon, TextType } from '@/components';
import { BoxButton, BoxButtonType, Text, TextType } from '@/components';
import { EmojiPicker, emojidata } from '@/docs/doc-editor/';
import { useDocTitleUpdate } from '../hooks/useDocTitleUpdate';
type DocIconProps = TextType & {
buttonProps?: BoxButtonType;
emoji?: string | null;
defaultIcon: React.ReactNode;
docId?: string;
@@ -16,6 +17,7 @@ type DocIconProps = TextType & {
};
export const DocIcon = ({
buttonProps,
emoji,
defaultIcon,
$size = 'sm',
@@ -81,13 +83,13 @@ export const DocIcon = ({
ref={iconRef}
onClick={toggleEmojiPicker}
color="tertiary-text"
{...buttonProps}
>
{!emoji ? (
defaultIcon
) : (
<Icon
<Text
{...textProps}
iconName={emoji}
$size={$size}
$variation={$variation}
$weight={$weight}
@@ -95,7 +97,7 @@ export const DocIcon = ({
data-testid="doc-emoji-icon"
>
{emoji}
</Icon>
</Text>
)}
</BoxButton>
{openEmojiPicker &&

View File

@@ -1,16 +1,19 @@
import {
Button,
ButtonElement,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useEffect, useRef } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
import { useConfig } from '@/core';
import { KEY_LIST_DOC_TRASHBIN } from '@/docs/docs-grid';
import { useKeyboardAction } from '@/hooks';
import { KEY_LIST_DOC } from '../api/useDocs';
import { useRemoveDoc } from '../api/useRemoveDoc';
@@ -34,6 +37,7 @@ export const ModalRemoveDoc = ({
const trashBinCutoffDays = config?.TRASHBIN_CUTOFF_DAYS || 30;
const { push } = useRouter();
const { hasChildren } = useDocUtils(doc);
const cancelButtonRef = useRef<ButtonElement>(null);
const {
mutate: removeDoc,
isError,
@@ -57,20 +61,47 @@ export const ModalRemoveDoc = ({
},
});
useEffect(() => {
const TIMEOUT_MODAL_MOUNTING = 100;
const timeoutId = setTimeout(() => {
const buttonElement = cancelButtonRef.current;
if (buttonElement) {
buttonElement.focus();
}
}, TIMEOUT_MODAL_MOUNTING);
return () => clearTimeout(timeoutId);
}, []);
const keyboardAction = useKeyboardAction();
const handleClose = () => {
onClose();
};
const handleDelete = () => {
removeDoc({ docId: doc.id });
};
const handleCloseKeyDown = keyboardAction(handleClose);
const handleDeleteKeyDown = keyboardAction(handleDelete);
return (
<Modal
isOpen
closeOnClickOutside
hideCloseButton
onClose={() => onClose()}
onClose={handleClose}
aria-describedby="modal-remove-doc-title"
rightActions={
<>
<Button
ref={cancelButtonRef}
aria-label={t('Cancel the deletion')}
color="secondary"
fullWidth
onClick={() => onClose()}
onClick={handleClose}
onKeyDown={handleCloseKeyDown}
>
{t('Cancel')}
</Button>
@@ -78,11 +109,8 @@ export const ModalRemoveDoc = ({
aria-label={t('Delete document')}
color="danger"
fullWidth
onClick={() =>
removeDoc({
docId: doc.id,
})
}
onClick={handleDelete}
onKeyDown={handleDeleteKeyDown}
>
{t('Delete')}
</Button>
@@ -108,7 +136,8 @@ export const ModalRemoveDoc = ({
</Text>
<ButtonCloseModal
aria-label={t('Close the delete modal')}
onClick={() => onClose()}
onClick={handleClose}
onKeyDown={handleCloseKeyDown}
/>
</Box>
}

View File

@@ -20,7 +20,7 @@ export const useDocTitleUpdate = (options?: UseDocUpdateOptions) => {
const treeContext = useTreeContext<Doc>();
const { mutate: updateDoc, ...mutationResult } = useUpdateDoc({
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
listInvalidQueries: [KEY_DOC, KEY_LIST_DOC],
onSuccess: (updatedDoc) => {
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${updatedDoc.id}`);

View File

@@ -132,5 +132,6 @@ export interface AccessRequest {
partial_update: boolean;
retrieve: boolean;
accept: boolean;
set_role_to: Role[];
};
}

View File

@@ -31,12 +31,12 @@ export const updateDocLink = async ({
interface UpdateDocLinkProps {
onSuccess?: (data: Doc) => void;
listInvalideQueries?: string[];
listInvalidQueries?: string[];
}
export function useUpdateDocLink({
onSuccess,
listInvalideQueries,
listInvalidQueries,
}: UpdateDocLinkProps = {}) {
const queryClient = useQueryClient();
const { toast } = useToastProvider();
@@ -45,7 +45,7 @@ export function useUpdateDocLink({
return useMutation<Doc, APIError, UpdateDocLinkParams>({
mutationFn: updateDocLink,
onSuccess: (data) => {
listInvalideQueries?.forEach((queryKey) => {
listInvalidQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});

View File

@@ -20,7 +20,7 @@ export const DocDesynchronized = ({ doc }: DocDesynchronizedProps) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { mutate: updateDocLink } = useUpdateDocLink({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
});
return (

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