Compare commits

..

40 Commits

Author SHA1 Message Date
Cyril
e47b02b6f2 ️(frontend) auto-focus on title when opening a doc or sub-doc
improves keyboard navigation and accessibility when content is displayed
2026-01-20 09:41:03 +01:00
Anthony LC
75f71368f4 🐛(frontend) fix emojipicker closing
In the tree view, if the emoji picker is opened
near the bottom of the viewport, it would
trigger an overflow that rerendered the treeview
and closed the picker immediately.
The root problem is the treeview that rerender
because of not stable props.
To fix this, we change 2 things:
- we use "fixed" position for the emoji picker
  so it won't affect the document flow
- we adjust the position calculation logic, if
  the picker does not have enough space below,
  we position it above the icon instead.
2026-01-19 17:12:45 +01:00
Anthony LC
21f5feab3e 🚚(frontend) move emoji picker
The emoji picker component is used in different
parts of the application, so it makes sense to
move it to a more general location.
2026-01-19 17:12:17 +01:00
Anthony LC
8ec89a8348 🚨(frontend) fix warning resizable panel
We had a warning in the console about the
resizable panel component.
This commit fixes that warning by ensuring that the
size is never inferior to the minimum size.
2026-01-19 17:12:17 +01:00
Anthony LC
3b80ac7b4e 🐛(frontend) add fallback for unsupported blocknote languages
We had a bug when user selected a language that is
not supported by BlockNote editor, the app
would crash.
If the language is not supported by BlockNote,
we now fallback Blocknote editor to English.
2026-01-19 16:50:20 +01:00
Cyril
68df717854 ️(frontend) fix subdoc opening and emoji pick focus
ensures subdoc opens and emoji picker focus on input

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-19 11:59:09 +01:00
Anthony LC
2f52dddc84 (frontend) integrate configurable Waffle
Integrate Waffle component based on LaGaufreV2
from @gouvfr-lasuite/ui-kit.
Waffle will be fully configurable via the app config,
allowing to be set through environment variables
and api-provided configuration.
2026-01-14 17:26:23 +01:00
Anthony LC
b1231cea7c 💄(frontend) update cunningham theme
Last upgrade of Cunningham had breaking changes
regarding theme tokens. This commit updates the
Cunningham theme to match the latest version.
2026-01-14 12:16:28 +01:00
Anto59290
f9f32db854 (e2e) fix e2e test for other browsers
In this test the comment is made using the "current" browser which can
be Chromium but can also be Firefox or Webkit.
This is why the test failed with other browsers.

Signed-off-by: Anto59290 <antonin59290@hotmail.com>
2026-01-14 10:03:48 +01:00
Manuel Raynaud
0d967aba48 📌(backend) pin celery to version<5.6.0
Since celery version 5.6.0 we have trouble with retrying tasks and it is
impactig the malware_detection workflow. We have to use version 5.5.3
while we found the issue.
2026-01-14 10:01:22 +01:00
Anthony LC
5ec58cef99 🔖(minor) release 4.4.0
Added:
- (backend) add documents/all endpoint with descendants
- (export) add PDF regression tests
- 📝(docs) Add language configuration documentation
- 🔒(helm) Set default security context
- (backend) use langfuse to monitor AI actions

Changed:
- (frontend) improve accessibility:
  - (frontend) make html export accessible to screen reader users
  - (frontend) add missing label and fix Axes errors to improve a11y

Fixed:
- (backend) reduce flakiness on backend test
- 🐛(frontend) fix clickable main content regression
- 🐛(backend) fix TRASHBIN_CUTOFF_DAYS type error
- 💄(frontend) fix icon position in callout block

Security:
- 🔒️(backend) validate more strictly url used by cors-proxy endpoint
- 🔒️(frontend) fix props vulnerability in Interlinking
2026-01-13 14:33:03 +01:00
AntoLC
1170bdbfc1 🌐(i18n) update translated strings
Update translated files with new translations
2026-01-13 14:33:03 +01:00
Anthony LC
e807237dbe 🔒️(frontend) fix props vulnerability in Interlinking
We were not properly sanitizing props passed to the
InterlinkingLinkInlineContent component, which could
lead to XSS attacks. This commit remove most of the
props and only keep the necessary ones.
2026-01-13 13:13:51 +01:00
Anto59290
fa6f3e8b7c 💄(frontend) fix icon position in callout block
Make sure the icon in the callout block is aligned to the top instead of
centered when we have multi-line content.

Signed-off-by: Anto59290 <antonin59290@hotmail.com>
2026-01-12 14:49:50 +01:00
Cyril
b1a18b2477 (frontend) add missing label to improve a11y and pass axe checks
enhances a11y by adding label to fix axe tool errors on missing attributes

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-12 09:06:19 +01:00
Anthony LC
7823303d03 (frontend) improve export regression test pdf
We improved the export regression test PDF to
better cover edge case emoji.
PDF Binary comparison is different depending on the
browser used, we will only run this test on Chromium
to avoid having to maintain multiple sets of PDF
fixtures.
2026-01-09 15:43:06 +01:00
Anthony LC
f84455728b 📌(dependencies) use @gouvfr-lasuite/cunningham-react
In order to work correctly we the ui-kit dependencies,
we need to use the CunninghamProvider from
@gouvfr-lasuite/cunningham-react.
2026-01-09 15:43:06 +01:00
renovate[bot]
5afc825109 ⬆️(dependencies) update js dependencies 2026-01-09 15:43:06 +01:00
Manuel Raynaud
55fe73d001 (backend) use langfuse to monitor AI actions
We want to monitor AI actions. For this we choose to use langfuse. As
this usage is optional, we load langfuse sdk only if settings are
configured. Also, the openai client from langfuse is a dropin
replacement of openai client, so we only have to change how openai is
imported.
2026-01-09 14:38:56 +00:00
Christopher Spelt
39b9c8b5a9 🐛(backend) fix TRASHBIN_CUTOFF_DAYS type error
Fixes `TRASHBIN_CUTOFF_DAYS` type as described in #1777.

Signed-off-by: ChristopherSpelt <christopherspelt@icloud.com>
2026-01-09 14:00:23 +00:00
Cyril
b56ebf19af ️(frontend) make html export accessible to screen reader users
adjusted structure and semantics to ensure proper sr interpretation

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-09 09:08:12 +01:00
Manuel Raynaud
03d4b2afbe ♻️(backend) stop allowing redirect in cors-proxy endpoint
The cors-proxy endpoint was allowing redirect when fetching the target
url. This can be usefull if an image url has changed but also dangerous
if an attacker wants to hide a SSRF behind a redirect.
2026-01-08 15:58:00 +01:00
Manuel Raynaud
2556823a69 ♻️(backend) stop returning a 415 on cors-proxy endpoint
When the content-type return by the targeted url is not an image, the
endpoint was returning a 415 status code. We don't want to provide this
info anymore avoid disclosing information an attacker can use.
2026-01-08 15:58:00 +01:00
Manuel Raynaud
f28da7c2c2 🔒️(backend) validate more strictly url used by cors-proxy endpoint
The cors-proxy endpoint allow to download images host externally without
being blocked by cors headers. The response is filter on the return
content-type to avoid disclosure and the usage of this endpoint as the
proxy used by attacker. We want to restrict the usage of this endpoint
by filtering on non legit ips used. This filter avoid exploitation of
Server Side Request Forgery (SSRF).
2026-01-08 15:58:00 +01:00
Pierre Ozoux
dd2d2862be 🔒(helm) set default security context
In order to be able to deploy this in a restricted k8s cluster, we set
this default security context.

We set it as default because it doesn't change the way the app runs.

So it is better to be more secured by default.

Signed-off-by: Pierre Ozoux Krebber <pierre@ozoux.net>
2026-01-08 14:53:16 +00:00
Manuel Raynaud
c2387fcb02 📌(backend) ping django<6.0.0
We want to wait before migrating to django 6. For now we require all
versions less than version 6 and we add a rule in renovate configuration
2026-01-08 15:01:42 +01:00
Manuel Raynaud
80fdc72182 🔥(backend) remove tests related to django-lasuite
When all the backend authentication has been moved in the django-lasuite
library, we kept the tests to ensure that the mirgration was successful
and we didn't miss something during the transition. Now this tests are
managed in the django-lasuite library and should be maintained in it,
not in docs.
2026-01-08 15:01:42 +01:00
Manuel Raynaud
3636168a77 (backend) fix test related to django-treebeard 4.8.0 upgrade
In one test related to the Document::restore function, one more query is
made. Probably a cache issue fixed in django-treebeard 4.8.0. When
updating the numchild parent, one more query is made to fetch in
database the parent document, this was not made before.
2026-01-08 15:01:42 +01:00
renovate[bot]
1034545b7c ⬆️(dependencies) update python dependencies 2026-01-08 15:01:41 +01:00
Anthony LC
8901c6ee33 📝(docs) Add language configuration documentation
Add comprehensive guide explaining how to override LANGUAGES settings
using the DJANGO_LANGUAGES environment variable. Documentation includes:

- Default language configuration
- Environment variable format and examples
- Configuration for development, production, and Docker Compose
- Complete list of 15 available languages with translation files
- Language code formatting guidelines
- Testing and troubleshooting sections
2026-01-08 12:55:49 +01:00
Antonin
f7d697d9bd (backend) fix flaky test in user search api
Make sure the full is never John for the first user in order to make
sure we always have only 2 users (as the search is performed on both the
email and the full name).
    
Fixes #1765
    
Signed-off-by: Anto59290 <antonin59290@hotmail.com>
2026-01-08 11:50:07 +00:00
Anthony LC
f9c9e444c9 🐛(export) fix heading 4 5 6 in PDF
Heading 4 5 6 in PDF were not correctly
mapped to their corresponding styles in the
exported document.
The new export is now different than the regression
pdf, we need to update the regression PDF to match
the new correct export.
2026-01-08 11:32:58 +01:00
Anthony LC
e1d2d9e5c8 (export) simplify export testing
The regression test is asserting most of the
blocks of the editor, we can remove some redundant
tests.
We improved as well the odt and docx tests by
overriding as well the document content like for pdf,
it will assert more blocks and have less code
to maintain.
2026-01-08 11:32:58 +01:00
Anthony LC
ab92fc43d6 (export) add PDF regression tests
To avoid regression issues in PDF export
functionality, this commit introduces end-to-end
tests that compare exported PDFs against
known good reference files.
We compare the PDF on most of the blocks
that the editor supports.
If during a Blocknote release or pull request
there are intentional changes, the reference
files would need to be updated accordingly.
It can be done by uncommenting the line
in the test that saves the newly generated
PDF to the assets folder.
2026-01-08 11:32:58 +01:00
Anthony LC
3a3ed0453b ️(export) improve svg width when undefined
We improved the svg width calculation when the
width attribute is undefined by trying to use
the style attribute before falling back to a
default value.
2026-01-08 11:32:27 +01:00
Christopher Spelt
43a1a76a2f (backend) add documents/all endpoint with descendants
External dashboards need to find the latest updated documents across
the entire hierarchy. Currently this requires many API calls to
/documents/ and /documents/{id}/children for each level.
   
This endpoint allows retrieving all accessible documents in a single
request, enabling dashboards to efficiently display recently changed
documents regardless of their position in the hierarchy.
    
Signed-off-by: ChristopherSpelt <christopherspelt@icloud.com>
2026-01-08 09:33:55 +00:00
Cyril
62213812ee 🐛(frontend) fix clickable main content regression
removes accidental clickable area introduced by the skip-to-content feature
2026-01-08 09:32:21 +01:00
Anthony LC
3d2b018927 🔖(minor) release 4.3.0
Added:
- (helm) redirecting system
- 📱(frontend) add comments for smaller device
- (project) add custom js support via config

Changed:
- 🥅(frontend) intercept 401 error on GET threads
- 🦺(frontend) check content type pdf on PdfBlock
- ✈️(frontend) pause Posthog when offline

Fixed:
- 🐛(frontend) fix tables deletion
- 🐛(frontend) fix children not display when first resize
2026-01-06 10:29:35 +01:00
Anthony LC
bb0502b49b 🚸(frontend) set cursor after create comments
We have some issues with mobiles and the formatting
toolbar reopening after adding a comment, so we
restore the cursor position.
By restoring the cursor position at the head of
the selection, it will automatically close the
formatting toolbar.
2026-01-06 10:29:34 +01:00
AntoLC
9893558c74 🌐(i18n) update translated strings
Update translated files with new translations
2026-01-05 15:28:31 +01:00
156 changed files with 8004 additions and 5642 deletions

View File

@@ -6,6 +6,52 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(frontend) integrate configurable Waffle #1795
### Fixed
- ✅(e2e) fix e2e test for other browsers #1799
- 🐛(frontend) add fallback for unsupported Blocknote languages #1810
- 🐛(frontend) fix emojipicker closing in tree #1808
### Changed
- ♿(frontend) improve accessibility:
- ♿️(frontend) fix subdoc opening and emoji pick focus #1745
- ♿️(frontend) Keyboard focus Fixes for docs Tree/Editor #1816
## [4.4.0] - 2026-01-13
### Added
- ✨(backend) add documents/all endpoint with descendants #1553
- ✅(export) add PDF regression tests #1762
- 📝(docs) Add language configuration documentation #1757
- 🔒(helm) Set default security context #1750
- ✨(backend) use langfuse to monitor AI actions #1776
### Changed
- ♿(frontend) improve accessibility:
- ♿(frontend) make html export accessible to screen reader users #1743
- ♿(frontend) add missing label and fix Axes errors to improve a11y #1693
### Fixed
- ✅(backend) reduce flakiness on backend test #1769
- 🐛(frontend) fix clickable main content regression #1773
- 🐛(backend) fix TRASHBIN_CUTOFF_DAYS type error #1778
- 💄(frontend) fix icon position in callout block #1779
### Security
- 🔒️(backend) validate more strictly url used by cors-proxy endpoint #1768
- 🔒️(frontend) fix props vulnerability in Interlinking #1792
## [4.3.0] - 2026-01-05
### Added
- ✨(helm) redirecting system #1697
@@ -17,14 +63,12 @@ and this project adheres to
- 🥅(frontend) intercept 401 error on GET threads #1754
- 🦺(frontend) check content type pdf on PdfBlock #1756
- ✈️(frontend) pause Posthog when offline #1755
- 📱(frontend) toolbar to the bottom when mobile #1774
### Fixed
- 🐛(frontend) fix tables deletion #1752
- 🐛(frontend) fix tables deletion #1739
- 🐛(frontend) fix children not display when first resize #1753
## [4.2.0] - 2025-12-17
### Added
@@ -49,7 +93,6 @@ and this project adheres to
- 🐛(frontend) Select text + Go back one page crash the app #1733
- 🐛(frontend) fix versioning conflict #1742
## [4.1.0] - 2025-12-09
### Added
@@ -963,7 +1006,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/v4.2.0...main
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.4.0...main
[v4.4.0]: https://github.com/suitenumerique/docs/releases/v4.4.0
[v4.3.0]: https://github.com/suitenumerique/docs/releases/v4.3.0
[v4.2.0]: https://github.com/suitenumerique/docs/releases/v4.2.0
[v4.1.0]: https://github.com/suitenumerique/docs/releases/v4.1.0
[v4.0.0]: https://github.com/suitenumerique/docs/releases/v4.0.0

BIN
docs/assets/waffle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -1,4 +1,6 @@
# Runtime Theming 🎨
# Customization Guide 🛠
## Runtime Theming 🎨
### How to Use
@@ -32,7 +34,7 @@ Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom
----
# Runtime JavaScript Injection 🚀
## Runtime JavaScript Injection 🚀
### How to Use
@@ -87,7 +89,7 @@ Then, set the `FRONTEND_JS_URL` environment variable to the URL of your custom J
----
# **Your Docs icon** 📝
## **Your Docs icon** 📝
You can add your own Docs icon in the header from the theme customization file.
@@ -105,7 +107,7 @@ This configuration is optional. If not set, the default icon will be used.
----
# **Footer Configuration** 📝
## **Footer Configuration** 📝
The footer is configurable from the theme customization file.
@@ -128,7 +130,7 @@ Below is a visual example of a configured footer ⬇️:
----
# **Custom Translations** 📝
## **Custom Translations** 📝
The translations can be partially overridden from the theme customization file.
@@ -140,4 +142,36 @@ 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
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
----
## **Waffle Configuration** 🧇
The Waffle (La Gaufre) is a widget that displays a grid of services.
![Waffle Configuration Example](./assets/waffle.png)
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Configuration
The Waffle can be configured in the theme customization file with the `waffle` key.
### Available Properties
See: [LaGaufreV2Props](https://github.com/suitenumerique/ui-kit/blob/main/src/components/la-gaufre/LaGaufreV2.tsx#L49)
### Complete Example
From the theme customization file: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
### Behavior
- If `data.services` is provided, the Waffle will display those services statically
- If no data is provided, services can be fetched dynamically from an API endpoint thanks to the `apiUrl` property

View File

@@ -64,6 +64,9 @@ These are the environment variables you can set for the `impress-backend` contai
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| FRONTEND_THEME | Frontend theme to use | |
| LANGUAGE_CODE | Default language | en-us |
| LANGFUSE_SECRET_KEY | The Langfuse secret key used by the sdk | None |
| LANGFUSE_PUBLIC_KEY | The Langfuse public key used by the sdk | None |
| LANGFUSE_BASE_URL | The Langfuse base url used by the sdk | None |
| LASUITE_MARKETING_BACKEND | Backend used when SIGNUP_NEW_USER_TO_MARKETING_EMAIL is True. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | lasuite.marketing.backends.dummy.DummyBackend |
| LASUITE_MARKETING_PARAMETERS | The parameters to configure LASUITE_MARKETING_BACKEND. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | {} |
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |

View File

@@ -0,0 +1,180 @@
# Language Configuration (2025-12)
This document explains how to configure and override the available languages in the Docs application.
## Default Languages
By default, the application supports the following languages (in priority order):
- English (en-us)
- French (fr-fr)
- German (de-de)
- Dutch (nl-nl)
- Spanish (es-es)
The default configuration is defined in `src/backend/impress/settings.py`:
```python
LANGUAGES = values.SingleNestedTupleValue(
(
("en-us", "English"),
("fr-fr", "Français"),
("de-de", "Deutsch"),
("nl-nl", "Nederlands"),
("es-es", "Español"),
)
)
```
## Overriding Languages
### Using Environment Variables
You can override the available languages by setting the `DJANGO_LANGUAGES` environment variable. This is the recommended approach for customizing language support without modifying the source code.
#### Format
The `DJANGO_LANGUAGES` variable expects a semicolon-separated list of language configurations, where each language is defined as `code,Display Name`:
```
DJANGO_LANGUAGES=code1,Name1;code2,Name2;code3,Name3
```
#### Example Configurations
**Example 1: English and French only**
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français
```
**Example 2: Add Italian and Chinese**
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch;it-it,Italiano;zh-cn,中文
```
**Example 3: Custom subset of languages**
```bash
DJANGO_LANGUAGES=fr-fr,Français;de-de,Deutsch;es-es,Español
```
### Configuration Files
#### Development Environment
For local development, you can set the `DJANGO_LANGUAGES` variable in your environment configuration file:
**File:** `env.d/development/common.local`
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch;it-it,Italiano;zh-cn,中文;
```
#### Production Environment
For production deployments, add the variable to your production environment configuration:
**File:** `env.d/production.dist/common`
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français
```
#### Docker Compose
When using Docker Compose, you can set the environment variable in your `compose.yml` or `compose.override.yml` file:
```yaml
services:
app:
environment:
- DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch
```
## Important Considerations
### Language Codes
- Use standard language codes (ISO 639-1 with optional region codes)
- Format: `language-region` (e.g., `en-us`, `fr-fr`, `de-de`)
- Use lowercase for language codes and region identifiers
### Priority Order
Languages are listed in priority order. The first language in the list is used as the fallback language throughout the application when a specific translation is not available.
### Translation Availability
Before adding a new language, ensure that:
1. Translation files exist for that language in the `src/backend/locale/` directory
2. The frontend application has corresponding translation files
3. All required messages have been translated
#### Available Languages
The following languages have translation files available in `src/backend/locale/`:
- `br_FR` - Breton (France)
- `cn_CN` - Chinese (China) - *Note: Use `zh-cn` in DJANGO_LANGUAGES*
- `de_DE` - German (Germany) - Use `de-de`
- `en_US` - English (United States) - Use `en-us`
- `es_ES` - Spanish (Spain) - Use `es-es`
- `fr_FR` - French (France) - Use `fr-fr`
- `it_IT` - Italian (Italy) - Use `it-it`
- `nl_NL` - Dutch (Netherlands) - Use `nl-nl`
- `pt_PT` - Portuguese (Portugal) - Use `pt-pt`
- `ru_RU` - Russian (Russia) - Use `ru-ru`
- `sl_SI` - Slovenian (Slovenia) - Use `sl-si`
- `sv_SE` - Swedish (Sweden) - Use `sv-se`
- `tr_TR` - Turkish (Turkey) - Use `tr-tr`
- `uk_UA` - Ukrainian (Ukraine) - Use `uk-ua`
- `zh_CN` - Chinese (China) - Use `zh-cn`
**Note:** When configuring `DJANGO_LANGUAGES`, use lowercase with hyphens (e.g., `pt-pt`, `ru-ru`) rather than the directory name format.
### Translation Management
We use [Crowdin](https://crowdin.com/) to manage translations for the Docs application. Crowdin allows our community to contribute translations and helps maintain consistency across all supported languages.
**Want to add a new language or improve existing translations?**
If you would like us to support a new language or want to contribute to translations, please get in touch with the project maintainers. We can add new languages to our Crowdin project and coordinate translation efforts with the community.
### Cookie and Session
The application stores the user's language preference in a cookie named `docs_language`. The cookie path is set to `/` by default.
## Testing Language Configuration
After changing the language configuration:
1. Restart the application services
2. Verify the language selector displays the correct languages
3. Test switching between different languages
4. Confirm that content is displayed in the selected language
## Troubleshooting
### Languages not appearing
- Verify the environment variable is correctly formatted (semicolon-separated, comma between code and name)
- Check that there are no trailing spaces in language codes or names
- Ensure the application was restarted after changing the configuration
### Missing translations
If you add a new language but see untranslated text:
1. Check if translation files exist in `src/backend/locale/<language_code>/LC_MESSAGES/`
2. Run Django's `makemessages` and `compilemessages` commands to generate/update translations
3. Verify frontend translation files are available
## Related Configuration
- `LANGUAGE_CODE`: Default language code (default: `en-us`)
- `LANGUAGE_COOKIE_NAME`: Cookie name for storing user language preference (default: `docs_language`)
- `LANGUAGE_COOKIE_PATH`: Cookie path (default: `/`)

View File

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

View File

@@ -3,8 +3,10 @@
# pylint: disable=too-many-lines
import base64
import ipaddress
import json
import logging
import socket
import uuid
from collections import defaultdict
from urllib.parse import unquote, urlencode, urlparse
@@ -388,6 +390,7 @@ class DocumentViewSet(
queryset = models.Document.objects.select_related("creator").all()
serializer_class = serializers.DocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer
all_serializer_class = serializers.ListDocumentSerializer
children_serializer_class = serializers.ListDocumentSerializer
descendants_serializer_class = serializers.ListDocumentSerializer
list_serializer_class = serializers.ListDocumentSerializer
@@ -858,6 +861,60 @@ class DocumentViewSet(
},
)
@drf.decorators.action(
detail=False,
methods=["get"],
)
def all(self, request, *args, **kwargs):
"""
Returns all documents (including descendants) that the user has access to.
Unlike the list endpoint which only returns top-level documents, this endpoint
returns all documents including children, grandchildren, etc.
"""
user = self.request.user
accessible_documents = self.get_queryset()
accessible_paths = list(accessible_documents.values_list("path", flat=True))
if not accessible_paths:
return self.get_response_for_queryset(self.queryset.none())
# Build query to include all descendants using path prefix matching
descendants_clause = db.Q()
for path in accessible_paths:
descendants_clause |= db.Q(path__startswith=path)
queryset = self.queryset.filter(
descendants_clause, ancestors_deleted_at__isnull=True
)
# Apply existing filters
filterset = ListDocumentFilter(
self.request.GET, queryset=queryset, request=self.request
)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
filter_data = filterset.form.cleaned_data
# Filter as early as possible on fields that are available on the model
for field in ["is_creator_me", "title"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])
queryset = queryset.annotate_user_roles(user)
# Annotate favorite status and filter if applicable as late as possible
queryset = queryset.annotate_is_favorite(user)
for field in ["is_favorite", "is_masked"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])
# Apply ordering only now that everything is filtered and annotated
queryset = filters.OrderingFilter().filter_queryset(
self.request, queryset, self
)
return self.get_response_for_queryset(queryset)
@drf.decorators.action(
detail=True,
methods=["get"],
@@ -1600,6 +1657,101 @@ class DocumentViewSet(
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
def _reject_invalid_ips(self, ips):
"""
Check if an IP address is safe from SSRF attacks.
Raises:
drf.exceptions.ValidationError: If the IP is unsafe
"""
for ip in ips:
# Block loopback addresses (check before private,
# as 127.0.0.1 might be considered private)
if ip.is_loopback:
raise drf.exceptions.ValidationError(
"Access to loopback addresses is not allowed"
)
# Block link-local addresses (169.254.0.0/16) - check before private
if ip.is_link_local:
raise drf.exceptions.ValidationError(
"Access to link-local addresses is not allowed"
)
# Block private IP ranges
if ip.is_private:
raise drf.exceptions.ValidationError(
"Access to private IP addresses is not allowed"
)
# Block multicast addresses
if ip.is_multicast:
raise drf.exceptions.ValidationError(
"Access to multicast addresses is not allowed"
)
# Block reserved addresses (including 0.0.0.0)
if ip.is_reserved:
raise drf.exceptions.ValidationError(
"Access to reserved IP addresses is not allowed"
)
def _validate_url_against_ssrf(self, url):
"""
Validate that a URL is safe from SSRF (Server-Side Request Forgery) attacks.
Blocks:
- localhost and its variations
- Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
- Link-local addresses (169.254.0.0/16)
- Loopback addresses
Raises:
drf.exceptions.ValidationError: If the URL is unsafe
"""
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
raise drf.exceptions.ValidationError("Invalid hostname")
# Resolve hostname to IP address(es)
# Check all resolved IPs to prevent DNS rebinding attacks
try:
# Try to parse as IP address first (if hostname is already an IP)
try:
ip = ipaddress.ip_address(hostname)
resolved_ips = [ip]
except ValueError:
# Resolve hostname to IP addresses (supports both IPv4 and IPv6)
resolved_ips = []
try:
# Get all address info (IPv4 and IPv6)
addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC)
for family, _, _, _, sockaddr in addr_info:
if family == socket.AF_INET:
# IPv4
ip = ipaddress.ip_address(sockaddr[0])
resolved_ips.append(ip)
elif family == socket.AF_INET6:
# IPv6
ip = ipaddress.ip_address(sockaddr[0])
resolved_ips.append(ip)
except (socket.gaierror, OSError) as e:
raise drf.exceptions.ValidationError(
f"Failed to resolve hostname: {str(e)}"
) from e
if not resolved_ips:
raise drf.exceptions.ValidationError(
"No IP addresses found for hostname"
) from None
except ValueError as e:
raise drf.exceptions.ValidationError(f"Invalid IP address: {str(e)}") from e
# Check all resolved IPs to ensure none are private/internal
self._reject_invalid_ips(resolved_ips)
@drf.decorators.action(
detail=True,
methods=["get"],
@@ -1633,6 +1785,16 @@ class DocumentViewSet(
status=drf.status.HTTP_400_BAD_REQUEST,
)
# Validate URL against SSRF attacks
try:
self._validate_url_against_ssrf(url)
except drf.exceptions.ValidationError as e:
logger.error("Potential SSRF attack detected: %s", e)
return drf.response.Response(
{"detail": "Invalid URL used."},
status=drf.status.HTTP_400_BAD_REQUEST,
)
try:
response = requests.get(
url,
@@ -1641,13 +1803,15 @@ class DocumentViewSet(
"User-Agent": request.headers.get("User-Agent", ""),
"Accept": request.headers.get("Accept", ""),
},
allow_redirects=False,
timeout=10,
)
response.raise_for_status()
content_type = response.headers.get("Content-Type", "")
if not content_type.startswith("image/"):
return drf.response.Response(
status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
{"detail": "Invalid URL used."}, status=status.HTTP_400_BAD_REQUEST
)
# Use StreamingHttpResponse with the response's iter_content to properly stream the data
@@ -1665,7 +1829,7 @@ class DocumentViewSet(
except requests.RequestException as e:
logger.exception(e)
return drf.response.Response(
{"error": f"Failed to fetch resource from {url}"},
{"detail": "Invalid URL used."},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -3,10 +3,14 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from openai import OpenAI
from core import enums
if settings.LANGFUSE_PUBLIC_KEY:
from langfuse.openai import OpenAI
else:
from openai import OpenAI
AI_ACTIONS = {
"prompt": (
"Answer the prompt using markdown formatting for structure and emphasis. "

View File

@@ -5,7 +5,6 @@ import re
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
import pytest
import responses
@@ -323,85 +322,6 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert models.User.objects.count() == 1
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_json_response():
"""Test get_userinfo method with a JSON response."""
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
json={
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
},
status=200,
)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "John"
assert result["last_name"] == "Doe"
assert result["email"] == "john.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_token_response(monkeypatch, settings):
"""Test get_userinfo method with a token response."""
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
body="fake.jwt.token",
status=200,
content_type="application/jwt",
)
def mock_verify_token(self, token): # pylint: disable=unused-argument
return {
"first_name": "Jane",
"last_name": "Doe",
"email": "jane.doe@example.com",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "Jane"
assert result["last_name"] == "Doe"
assert result["email"] == "jane.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_invalid_response(settings):
"""
Test get_userinfo method with an invalid JWT response that
causes verify_token to raise an error.
"""
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
body="fake.jwt.token",
status=200,
content_type="application/jwt",
)
oidc_backend = OIDCAuthenticationBackend()
with pytest.raises(
SuspiciousOperation,
match="User info response was not valid JWT",
):
oidc_backend.get_userinfo("fake_access_token", None, None)
def test_authentication_getter_existing_disabled_user_via_sub(
django_assert_num_queries, monkeypatch
):

View File

@@ -0,0 +1,427 @@
"""
Tests for Documents API endpoint in impress's core app: all
The 'all' endpoint returns ALL documents (including descendants) that the user has access to.
This is different from the 'list' endpoint which only returns top-level documents.
"""
from datetime import timedelta
from unittest import mock
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_all_anonymous(reach, role):
"""
Anonymous users should not be able to list any documents via the all endpoint
whatever the link reach and link role.
"""
parent = factories.DocumentFactory(link_reach=reach, link_role=role)
factories.DocumentFactory(parent=parent, link_reach=reach, link_role=role)
response = APIClient().get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 0
def test_api_documents_all_authenticated_with_children():
"""
Authenticated users should see all documents including children,
even though children don't have DocumentAccess records.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create a document tree: parent -> child -> grandchild
parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=parent, user=user, role="owner")
child = factories.DocumentFactory(parent=parent)
grandchild = factories.DocumentFactory(parent=child)
# Verify setup
assert models.DocumentAccess.objects.filter(document=parent).count() == 1
assert models.DocumentAccess.objects.filter(document=child).count() == 0
assert models.DocumentAccess.objects.filter(document=grandchild).count() == 0
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# All three documents should be returned (parent + child + grandchild)
assert len(results) == 3
results_ids = {result["id"] for result in results}
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
depths = {result["depth"] for result in results}
assert depths == {1, 2, 3}
def test_api_documents_all_authenticated_multiple_trees():
"""
Users should see all accessible documents from multiple document trees.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Tree 1: User has access
tree1_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=tree1_parent, user=user)
tree1_child = factories.DocumentFactory(parent=tree1_parent)
# Tree 2: User has access
tree2_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=tree2_parent, user=user)
tree2_child1 = factories.DocumentFactory(parent=tree2_parent)
tree2_child2 = factories.DocumentFactory(parent=tree2_parent)
# Tree 3: User does NOT have access
tree3_parent = factories.DocumentFactory()
factories.DocumentFactory(parent=tree3_parent)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should return 5 documents (tree1: 2, tree2: 3, tree3: 0)
assert len(results) == 5
results_ids = {result["id"] for result in results}
expected_ids = {
str(tree1_parent.id),
str(tree1_child.id),
str(tree2_parent.id),
str(tree2_child1.id),
str(tree2_child2.id),
}
assert results_ids == expected_ids
def test_api_documents_all_authenticated_explicit_access_to_parent_and_child():
"""
When a user has explicit DocumentAccess to both parent AND child,
both should appear in the 'all' endpoint results (unlike 'list' which deduplicates).
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Parent with explicit access
parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=parent, user=user)
# Child also has explicit access (e.g., shared separately)
child = factories.DocumentFactory(parent=parent)
factories.UserDocumentAccessFactory(document=child, user=user)
# Grandchild has no explicit access
grandchild = factories.DocumentFactory(parent=child)
# Verify setup
assert models.DocumentAccess.objects.filter(document=parent).count() == 1
assert models.DocumentAccess.objects.filter(document=child).count() == 1
assert models.DocumentAccess.objects.filter(document=grandchild).count() == 0
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# All three should appear
assert len(results) == 3
results_ids = {result["id"] for result in results}
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
# Each document should appear exactly once (no duplicates)
results_ids_list = [result["id"] for result in results]
assert len(results_ids_list) == len(set(results_ids_list)) # No duplicates
def test_api_documents_all_authenticated_via_team(mock_user_teams):
"""
Users should see all documents (including descendants) for documents accessed via teams.
"""
mock_user_teams.return_value = ["team1", "team2"]
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Document tree via team1
parent1 = factories.DocumentFactory()
factories.TeamDocumentAccessFactory(document=parent1, team="team1")
child1 = factories.DocumentFactory(parent=parent1)
# Document tree via team2
parent2 = factories.DocumentFactory()
factories.TeamDocumentAccessFactory(document=parent2, team="team2")
child2 = factories.DocumentFactory(parent=parent2)
# Document tree via unknown team
parent3 = factories.DocumentFactory()
factories.TeamDocumentAccessFactory(document=parent3, team="team3")
factories.DocumentFactory(parent=parent3)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should return 4 documents (team1: 2, team2: 2, team3: 0)
assert len(results) == 4
results_ids = {result["id"] for result in results}
expected_ids = {
str(parent1.id),
str(child1.id),
str(parent2.id),
str(child2.id),
}
assert results_ids == expected_ids
def test_api_documents_all_authenticated_soft_deleted():
"""
Soft-deleted documents and their descendants should not be included.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Active tree
active_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=active_parent, user=user)
active_child = factories.DocumentFactory(parent=active_parent)
# Soft-deleted tree
deleted_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=deleted_parent, user=user)
_deleted_child = factories.DocumentFactory(parent=deleted_parent)
deleted_parent.soft_delete()
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should only return active documents
assert len(results) == 2
results_ids = {result["id"] for result in results}
assert results_ids == {str(active_parent.id), str(active_child.id)}
def test_api_documents_all_authenticated_permanently_deleted():
"""
Permanently deleted documents should not be included.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Active tree
active_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=active_parent, user=user)
active_child = factories.DocumentFactory(parent=active_parent)
# Permanently deleted tree (deleted > 30 days ago)
deleted_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=deleted_parent, user=user)
_deleted_child = factories.DocumentFactory(parent=deleted_parent)
fourty_days_ago = timezone.now() - timedelta(days=40)
with mock.patch("django.utils.timezone.now", return_value=fourty_days_ago):
deleted_parent.soft_delete()
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should only return active documents
assert len(results) == 2
results_ids = {result["id"] for result in results}
assert results_ids == {str(active_parent.id), str(active_child.id)}
def test_api_documents_all_authenticated_link_reach_restricted():
"""
Documents with link_reach=restricted accessed via LinkTrace should not appear
in the all endpoint results.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Document with direct access (should appear)
parent_with_access = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=parent_with_access, user=user)
child_with_access = factories.DocumentFactory(parent=parent_with_access)
# Document with only LinkTrace and restricted reach (should NOT appear)
parent_restricted = factories.DocumentFactory(
link_reach="restricted", link_traces=[user]
)
factories.DocumentFactory(parent=parent_restricted)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Only documents with direct access should appear
assert len(results) == 2
results_ids = {result["id"] for result in results}
assert results_ids == {str(parent_with_access.id), str(child_with_access.id)}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_all_authenticated_link_reach_public_or_authenticated(reach):
"""
Documents with link_reach=public or authenticated accessed via LinkTrace
should appear with all their descendants.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Document accessed via LinkTrace with non-restricted reach
parent = factories.DocumentFactory(link_reach=reach, link_traces=[user])
child = factories.DocumentFactory(parent=parent)
grandchild = factories.DocumentFactory(parent=child)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# All descendants should be included
assert len(results) == 3
results_ids = {result["id"] for result in results}
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
def test_api_documents_all_format():
"""Validate the format of documents as returned by the all endpoint."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
access = factories.UserDocumentAccessFactory(document=document, user=user)
child = factories.DocumentFactory(parent=document)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
content = response.json()
results = content.pop("results")
# Check pagination structure
assert content == {
"count": 2,
"next": None,
"previous": None,
}
# Verify parent document format
parent_result = [r for r in results if r["id"] == str(document.id)][0]
assert parent_result == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"numchild": 1,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
}
# Verify child document format
child_result = [r for r in results if r["id"] == str(child.id)][0]
assert child_result["depth"] == 2
assert child_result["user_role"] == access.role # Inherited from parent
assert child_result["nb_accesses_direct"] == 0 # No direct access on child
def test_api_documents_all_distinct():
"""
A document should only appear once even if the user has multiple access paths to it.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Document with multiple accesses for the same user
document = factories.DocumentFactory(users=[user, other_user])
child = factories.DocumentFactory(parent=document)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should return 2 documents (parent + child), each appearing once
assert len(results) == 2
results_ids = [result["id"] for result in results]
assert results_ids.count(str(document.id)) == 1
assert results_ids.count(str(child.id)) == 1
def test_api_documents_all_comparison_with_list():
"""
The 'all' endpoint should return more documents than 'list' when there are children.
'list' returns only top-level documents, 'all' returns all descendants.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create a document tree
parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=parent, user=user)
child = factories.DocumentFactory(parent=parent)
grandchild = factories.DocumentFactory(parent=child)
# Call list endpoint
list_response = client.get("/api/v1.0/documents/")
list_results = list_response.json()["results"]
# Call all endpoint
all_response = client.get("/api/v1.0/documents/all/")
all_results = all_response.json()["results"]
# list should return only parent
assert len(list_results) == 1
assert list_results[0]["id"] == str(parent.id)
# all should return parent + child + grandchild
assert len(all_results) == 3
all_ids = {result["id"] for result in all_results}
assert all_ids == {str(parent.id), str(child.id), str(grandchild.id)}

View File

@@ -1,5 +1,8 @@
"""Test on the CORS proxy API for documents."""
import socket
import unittest.mock
import pytest
import responses
from requests.exceptions import RequestException
@@ -10,11 +13,17 @@ from core import factories
pytestmark = pytest.mark.django_db
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_valid_url():
def test_api_docs_cors_proxy_valid_url(mock_getaddrinfo):
"""Test the CORS proxy API for documents with a valid URL."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
@@ -56,11 +65,17 @@ def test_api_docs_cors_proxy_without_url_query_string():
assert response.json() == {"detail": "Missing 'url' query parameter"}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_anonymous_document_not_public():
def test_api_docs_cors_proxy_anonymous_document_not_public(mock_getaddrinfo):
"""Test the CORS proxy API for documents with an anonymous user and a non-public document."""
document = factories.DocumentFactory(link_reach="authenticated")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
@@ -73,14 +88,22 @@ def test_api_docs_cors_proxy_anonymous_document_not_public():
}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc(
mock_getaddrinfo,
):
"""
Test the CORS proxy API for documents with an authenticated user accessing a protected
document.
"""
document = factories.DocumentFactory(link_reach="authenticated")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
user = factories.UserFactory()
client = APIClient()
@@ -115,14 +138,22 @@ def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
assert response.streaming_content
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc(
mock_getaddrinfo,
):
"""
Test the CORS proxy API for documents with an authenticated user not accessing a restricted
document.
"""
document = factories.DocumentFactory(link_reach="restricted")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
user = factories.UserFactory()
client = APIClient()
@@ -138,18 +169,72 @@ def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_unsupported_media_type():
def test_api_docs_cors_proxy_unsupported_media_type(mock_getaddrinfo):
"""Test the CORS proxy API for documents with an unsupported media type."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=b"", status=200, content_type="text/html")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 415
assert response.status_code == 400
assert response.json() == {"detail": "Invalid URL used."}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_redirect(mock_getaddrinfo):
"""Test the CORS proxy API for documents with a redirect."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(
url_to_fetch,
body=b"",
status=302,
headers={"Location": "https://external-url.com/other/assets/index.html"},
)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid URL used."}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_url_not_returning_200(mock_getaddrinfo):
"""Test the CORS proxy API for documents with a URL that does not return 200."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=b"", status=404)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid URL used."}
@pytest.mark.parametrize(
@@ -173,11 +258,17 @@ def test_api_docs_cors_proxy_invalid_url(url_to_fetch):
assert response.json() == ["Enter a valid URL."]
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_request_failed():
def test_api_docs_cors_proxy_request_failed(mock_getaddrinfo):
"""Test the CORS proxy API for documents with a request failed."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=RequestException("Connection refused"))
@@ -185,6 +276,164 @@ def test_api_docs_cors_proxy_request_failed():
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == {
"error": "Failed to fetch resource from https://external-url.com/assets/index.html"
}
assert response.json() == {"detail": "Invalid URL used."}
@pytest.mark.parametrize(
"url_to_fetch",
[
"http://localhost/image.png",
"https://localhost/image.png",
"http://127.0.0.1/image.png",
"https://127.0.0.1/image.png",
"http://0.0.0.0/image.png",
"https://0.0.0.0/image.png",
"http://[::1]/image.png",
"https://[::1]/image.png",
"http://[0:0:0:0:0:0:0:1]/image.png",
"https://[0:0:0:0:0:0:0:1]/image.png",
],
)
def test_api_docs_cors_proxy_blocks_localhost(url_to_fetch):
"""Test that the CORS proxy API blocks localhost variations."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
@pytest.mark.parametrize(
"url_to_fetch",
[
"http://10.0.0.1/image.png",
"https://10.0.0.1/image.png",
"http://172.16.0.1/image.png",
"https://172.16.0.1/image.png",
"http://192.168.1.1/image.png",
"https://192.168.1.1/image.png",
"http://10.255.255.255/image.png",
"https://10.255.255.255/image.png",
"http://172.31.255.255/image.png",
"https://172.31.255.255/image.png",
"http://192.168.255.255/image.png",
"https://192.168.255.255/image.png",
],
)
def test_api_docs_cors_proxy_blocks_private_ips(url_to_fetch):
"""Test that the CORS proxy API blocks private IP addresses."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
@pytest.mark.parametrize(
"url_to_fetch",
[
"http://169.254.1.1/image.png",
"https://169.254.1.1/image.png",
"http://169.254.255.255/image.png",
"https://169.254.255.255/image.png",
],
)
def test_api_docs_cors_proxy_blocks_link_local(url_to_fetch):
"""Test that the CORS proxy API blocks link-local addresses."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_blocks_dns_rebinding_to_private_ip(mock_getaddrinfo):
"""Test that the CORS proxy API blocks DNS rebinding attacks to private IPs."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a private IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.1", 0))
]
client = APIClient()
url_to_fetch = "https://malicious-domain.com/image.png"
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_blocks_dns_rebinding_to_localhost(mock_getaddrinfo):
"""Test that the CORS proxy API blocks DNS rebinding attacks to localhost."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return localhost
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0))
]
client = APIClient()
url_to_fetch = "https://malicious-domain.com/image.png"
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
def test_api_docs_cors_proxy_handles_dns_resolution_failure(mock_getaddrinfo):
"""Test that the CORS proxy API handles DNS resolution failures gracefully."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to fail
mock_getaddrinfo.side_effect = socket.gaierror("Name or service not known")
client = APIClient()
url_to_fetch = "https://nonexistent-domain-12345.com/image.png"
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
def test_api_docs_cors_proxy_blocks_multiple_resolved_ips_if_any_private(
mock_getaddrinfo,
):
"""Test that the CORS proxy API blocks if any resolved IP is private."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return both public and private IPs
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0)),
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.1", 0)),
]
client = APIClient()
url_to_fetch = "https://example.com/image.png"
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()

View File

@@ -311,7 +311,7 @@ def test_api_users_list_query_short_queries():
"""
Queries shorter than 5 characters should return an empty result set.
"""
user = factories.UserFactory(email="paul@example.com")
user = factories.UserFactory(email="paul@example.com", full_name="Paul")
client = APIClient()
client.force_login(user)

View File

@@ -1393,7 +1393,7 @@ def test_models_documents_restore_complex(django_assert_num_queries):
assert child2.ancestors_deleted_at == document.deleted_at
# Restore the item
with django_assert_num_queries(13):
with django_assert_num_queries(14):
document.restore()
document.refresh_from_db()
child1.refresh_from_db()

View File

@@ -453,7 +453,7 @@ class Base(Configuration):
"REDOC_DIST": "SIDECAR",
}
TRASHBIN_CUTOFF_DAYS = values.Value(
TRASHBIN_CUTOFF_DAYS = values.IntegerValue(
30, environ_name="TRASHBIN_CUTOFF_DAYS", environ_prefix=None
)
@@ -699,6 +699,16 @@ class Base(Configuration):
"day": 200,
}
LANGFUSE_SECRET_KEY = SecretFileValue(
None, environ_name="LANGFUSE_SECRET_KEY", environ_prefix=None
)
LANGFUSE_PUBLIC_KEY = values.Value(
None, environ_name="LANGFUSE_PUBLIC_KEY", environ_prefix=None
)
LANGFUSE_BASE_URL = values.Value(
None, environ_name="LANGFUSE_BASE_URL", environ_prefix=None
)
# Y provider microservice
Y_PROVIDER_API_KEY = SecretFileValue(
environ_name="Y_PROVIDER_API_KEY",

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
@@ -79,11 +79,15 @@ msgstr "Doare korf"
msgid "Format"
msgstr "Stumm"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "eilenn {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "implijer"
msgid "users"
msgstr "implijerien"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "titl"
@@ -256,188 +260,188 @@ msgstr "Restr"
msgid "Documents"
msgstr "Restroù"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Restr hep titl"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} en deus rannet ur restr ganeoc'h!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} en deus pedet ac'hanoc'h gant ar rol \"{role}\" war ar restr da-heul:"
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} en deus rannet ur restr ganeoc'h: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Roud liamm ar restr/an implijer"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Roudoù liamm ar restr/an implijer"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "Ur roud liamm a zo dija evit an restr/an implijer."
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Restr muiañ-karet"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Restroù muiañ-karet"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Ar restr-mañ a zo ur restr muiañ karet gant an implijer-mañ."
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Liamm restr/implijer"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Liammoù restr/implijer"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "An implijer-mañ a zo dija er restr-mañ."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "An implijer pe ar skipailh a rank bezañ termenet, ket an daou avat."
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "Goulenn tizhout ar restr"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "Goulennoù tizhout ar restr"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr "An implijer en deus goulennet tizhout ar restr-mañ."
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} en defe c'hoant da dizhout ar restr-mañ!"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} en defe c'hoant da dizhout ar restr da-heul:"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} en defe c'hoant da dizhout ar restr: {title}"
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "deskrivadur"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "kod"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "publik"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "M'eo foran ar patrom-mañ hag implijus gant n'eus forzh piv."
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Patrom"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Patromoù"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Liamm patrom/implijer"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Liammoù patrom/implijer"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "An implijer-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "postel"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Pedadenn d'ur restr"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Pedadennoù d'ur restr"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -79,11 +79,15 @@ msgstr "Typ"
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "Kopie von {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "Benutzer"
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "Titel"
@@ -256,188 +260,188 @@ msgstr "Dokument"
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "Für dieses Dokument/ diesen Benutzer ist bereits eine Linkverfolgung vorhanden."
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -79,11 +79,15 @@ msgstr ""
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr ""
@@ -256,188 +260,188 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr ""
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr ""
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -79,11 +79,15 @@ msgstr "Tipo de Cuerpo"
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "copia de {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "usuario"
msgid "users"
msgstr "usuarios"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "título"
@@ -256,188 +260,188 @@ msgstr "Documento"
msgid "Documents"
msgstr "Documentos"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Documento sin título"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "¡{name} ha compartido un documento contigo!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "Te ha invitado {name} al siguiente documento con el rol \"{role}\" :"
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha compartido un documento contigo: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Traza del enlace de documento/usuario"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Trazas del enlace de documento/usuario"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "Ya existe una traza de enlace para este documento/usuario."
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Documento favorito"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Documentos favoritos"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Este documento ya ha sido marcado como favorito por el usuario."
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Relación documento/usuario"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Relaciones documento/usuario"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Este usuario ya forma parte del documento."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Este equipo ya forma parte del documento."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "Debe establecerse un usuario o un equipo, no ambos."
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "Solicitud de acceso"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "Solicitud de accesos"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr "Este usuario ya ha solicitado acceso a este documento."
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "¡{name} desea acceder a un documento!"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} desea acceso al siguiente documento:"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} está pidiendo acceso al documento: {title}"
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "descripción"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "código"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "público"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Si esta plantilla es pública para que cualquiera la utilice."
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Plantilla"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Plantillas"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Relación plantilla/usuario"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Relaciones plantilla/usuario"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Este usuario ya forma parte de la plantilla."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Este equipo ya se encuentra en esta plantilla."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "dirección de correo electrónico"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Invitación al documento"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Invitaciones a documentos"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Este correo electrónico está asociado a un usuario registrado."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -79,11 +79,15 @@ msgstr "Type de corps"
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "copie de {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr "Noyau d'application Impress"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "utilisateur"
msgid "users"
msgstr "utilisateurs"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "titre"
@@ -256,188 +260,188 @@ msgstr "Document"
msgid "Documents"
msgstr "Documents"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Document sans titre"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant :"
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} a partagé un document avec vous : {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Trace du lien document/utilisateur"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Traces du lien document/utilisateur"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "Une trace de lien existe déjà pour ce document/utilisateur."
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Document favori"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Documents favoris"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Ce document est déjà un favori de cet utilisateur."
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Relation document/utilisateur"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Relations document/utilisateur"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Cet utilisateur est déjà dans ce document."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Cette équipe est déjà dans ce document."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "L'utilisateur ou l'équipe doivent être définis, pas les deux."
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr "Cet utilisateur a déjà demandé l'accès à ce document."
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} souhaiterait accéder au document suivant !"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} souhaiterait accéder au document suivant :"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} demande l'accès au document : {title}"
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr "Conversation"
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr "Conversations"
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr "Anonyme"
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr "Commentaire"
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr "Commentaires"
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr "Cet émoji a déjà été réagi à ce commentaire."
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr "Réaction"
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr "Réactions"
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "description"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "public"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Si ce modèle est public, utilisable par n'importe qui."
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Modèle"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Modèles"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Relation modèle/utilisateur"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Relations modèle/utilisateur"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Cet utilisateur est déjà dans ce modèle."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Cette équipe est déjà modèle."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "adresse e-mail"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Invitation à un document"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Invitations à un document"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -79,11 +79,15 @@ msgstr ""
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "copia di {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "utente"
msgid "users"
msgstr "utenti"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "titolo"
@@ -256,188 +260,188 @@ msgstr "Documento"
msgid "Documents"
msgstr "Documenti"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Documento senza titolo"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ha condiviso un documento con te!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} ti ha invitato con il ruolo \"{role}\" nel seguente documento:"
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha condiviso un documento con te: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Documento preferito"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Documenti preferiti"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Questo utente è già presente in questo documento."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Questo team è già presente in questo documento."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "descrizione"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "pubblico"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Indica se questo modello è pubblico per chiunque."
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Modello"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Modelli"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Questo utente è già in questo modello."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Questo team è già in questo modello."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "indirizzo e-mail"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Invito al documento"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Inviti al documento"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Questa email è già associata a un utente registrato."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -79,11 +79,15 @@ msgstr "Text type"
msgid "Format"
msgstr "Formaat"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "kopie van {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr "Docs kern applicatie"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "gebruiker"
msgid "users"
msgstr "gebruikers"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "titel"
@@ -256,188 +260,188 @@ msgstr "Document"
msgid "Documents"
msgstr "Documenten"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Naamloos Document"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} heeft een document met u gedeeld!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} heeft u uitgenodigd met de rol \"{role}\" op het volgende document:"
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} heeft een document met u gedeeld: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "Een link bestaat al voor dit document/deze gebruiker."
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Document favoriet"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Document favorieten"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dit document is al in gebruik als favoriet door dezelfde gebruiker."
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Document/gebruiker relatie"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Document/gebruiker relaties"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "De gebruiker bestaat al in dit document."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Dit team bestaat al in dit document."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "Een gebruiker of team moet gekozen worden, maar niet beide."
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "Document verzoekt om toegang"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "Document verzoekt om toegangen"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr "Deze gebruiker heeft al om toegang tot dit document gevraagd."
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} verzoekt toegang tot een document!"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} verzoekt toegang tot het volgende document:"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} verzoekt toegang tot het document: {title}"
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr "Kanaal"
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr "Kanalen"
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr "Anoniem"
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr "Reactie"
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr "Reacties"
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr "Deze emoji is al op deze opmerking gereageerd."
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr "Reactie"
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr "Reacties"
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "omschrijving"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "publiek"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Of dit sjabloon door iedereen publiekelijk te gebruiken is."
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Sjabloon"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Sjabloon"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Sjabloon/gebruiker relatie"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Sjabloon/gebruiker relaties"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "De gebruiker bestaat al in dit sjabloon."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Het team bestaat al in dit sjabloon."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "e-mailadres"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Document uitnodiging"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Document uitnodigingen"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
@@ -79,11 +79,15 @@ msgstr "Tipo de corpo"
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "cópia de {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr ""
@@ -256,188 +260,188 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr ""
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr ""
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -79,11 +79,15 @@ msgstr "Тип сообщения"
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "копия {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr "Ядро приложения Impress"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "пользователь"
msgid "users"
msgstr "пользователи"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "заголовок"
@@ -256,188 +260,188 @@ msgstr "Документ"
msgid "Documents"
msgstr "Документы"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Безымянный документ"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} делится с вами документом!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} делится с вами документом: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Трассировка связи документ/пользователь"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Трассировка связей документ/пользователь"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Избранный документ"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Избранные документы"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Этот документ уже помечен как избранный для этого пользователя."
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Отношение документ/пользователь"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Отношения документ/пользователь"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Этот пользователь уже имеет доступ к этому документу."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Эта команда уже имеет доступ к этому документу."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "Документ запрашивает доступ"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "Документ запрашивает доступы"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr "Этот пользователь уже запросил доступ к этому документу."
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хочет получить доступ к документу!"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} хочет получить доступ к следующему документу:"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запрашивает доступ к документу: {title}"
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr "Обсуждение"
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr "Обсуждения"
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr "Аноним"
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr "Комментарий"
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr "Комментарии"
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr "Этот эмодзи уже использован в этом комментарии."
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr "Реакция"
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr "Реакции"
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "описание"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "доступно всем"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Этот шаблон доступен всем пользователям."
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Шаблоны"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Отношение шаблон/пользователь"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Отношения шаблон/пользователь"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Этот пользователь уже указан в этом шаблоне."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Эта команда уже указана в этом шаблоне."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "адрес электронной почты"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Приглашение для документа"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Приглашения для документов"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Этот адрес уже связан с зарегистрированным пользователем."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
@@ -79,11 +79,15 @@ msgstr "Vrsta telesa"
msgid "Format"
msgstr "Oblika"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "uporabnik"
msgid "users"
msgstr "uporabniki"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "naslov"
@@ -256,188 +260,188 @@ msgstr "Dokument"
msgid "Documents"
msgstr "Dokumenti"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Dokument brez naslova"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} je delil dokument z vami!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} vas je povabil z vlogo \"{role}\" na naslednjem dokumentu:"
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} je delil dokument z vami: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Dokument/sled povezave uporabnika"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Sledi povezav dokumenta/uporabnika"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Priljubljeni dokument"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Priljubljeni dokumenti"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Ta dokument je že ciljno usmerjen s priljubljenim primerkom relacije za istega uporabnika."
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Odnos dokument/uporabnik"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Odnosi dokument/uporabnik"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Ta uporabnik je že v tem dokumentu."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Ta ekipa je že v tem dokumentu."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "Nastaviti je treba bodisi uporabnika ali ekipo, a ne obojega."
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "opis"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "koda"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "javno"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Ali je ta predloga javna za uporabo."
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Predloga"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Predloge"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Odnos predloga/uporabnik"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Odnosi med predlogo in uporabnikom"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Ta uporabnik je že v tej predlogi."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Ta ekipa je že v tej predlogi."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "elektronski naslov"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Vabilo na dokument"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Vabila na dokument"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
@@ -79,11 +79,15 @@ msgstr ""
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr ""
@@ -256,188 +260,188 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr ""
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr ""
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "e-postadress"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Bjud in dokument"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Inbjudningar dokument"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Denna e-postadress är redan associerad med en registrerad användare."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
@@ -79,11 +79,15 @@ msgstr ""
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr ""
@@ -256,188 +260,188 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr ""
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr ""
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
@@ -79,11 +79,15 @@ msgstr "Тип вмісту"
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "копія {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr "Ядро додатку Impress"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "користувач"
msgid "users"
msgstr "користувачі"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "заголовок"
@@ -256,188 +260,188 @@ msgstr "Документ"
msgid "Documents"
msgstr "Документи"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Документ без назви"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ділиться з вами документом!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ділиться з вами документом: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Трасування посилання Документ/користувач"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Трасування посилань Документ/користувач"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Обраний документ"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Обрані документи"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Цей документ вже вказаний як обраний для одного користувача."
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Цей користувач вже має доступ до цього документу."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Ця команда вже має доступ до цього документа."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "Вкажіть користувача або команду, а не обох."
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "Запит доступу до документа"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "Запит доступу для документа"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr "Цей користувач вже попросив доступ до цього документа."
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хоче отримати доступ до документа!"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} бажає отримати доступ до наступного документа:"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запитує доступ до документа: {title}"
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr "Обговорення"
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr "Обговорення"
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr "Анонім"
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr "Коментар"
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr "Коментарі"
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr "Цим емодзі вже відреагували на цей коментар."
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr "Реакція"
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr "Реакції"
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "опис"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "публічне"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Чи є цей шаблон публічним для будь-кого користувача."
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Шаблони"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Цей користувач вже має доступ до цього шаблону."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Ця команда вже має доступ до цього шаблону."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "електронна адреса"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Запрошення до редагування документа"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Запрошення до редагування документів"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
@@ -79,11 +79,15 @@ msgstr "正文类型"
msgid "Format"
msgstr "格式"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "{title} 的副本"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "用户"
msgid "users"
msgstr "个用户"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "标题"
@@ -256,188 +260,188 @@ msgstr "文档"
msgid "Documents"
msgstr "个文档"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "未命名文档"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} 与您共享了一个文档!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} 邀请您以“{role}”角色访问以下文档:"
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} 与您共享了一个文档:{title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "文档/用户链接跟踪"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "个文档/用户链接跟踪"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "此文档/用户的链接跟踪已存在。"
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "文档收藏"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "文档收藏夹"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "该文档已被同一用户的收藏关系实例关联。"
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "文档/用户关系"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "文档/用户关系集"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "该用户已在此文档中。"
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "该团队已在此文档中。"
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "必须设置用户或团队之一,不能同时设置两者。"
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "文档需要访问权限"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "文档需要访问权限"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr "用户已申请该文档的访问权限。"
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} 申请访问文档!"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} 申请访问以下文档:"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name}申请文档:{title}的访问权限"
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "说明"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "代码"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "公开"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "该模板是否公开供任何人使用。"
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "模板"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "模板"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "模板/用户关系"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "模板/用户关系集"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "该用户已在此模板中。"
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "该团队已在此模板中。"
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "电子邮件地址"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "文档邀请"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "文档邀请"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "此电子邮件已经与现有注册用户关联。"

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "4.2.0"
version = "4.4.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,13 +25,13 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.14.2",
"boto3==1.40.74",
"beautifulsoup4==4.14.3",
"boto3==1.42.17",
"Brotli==1.2.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.1.0",
"django-countries==8.2.0",
"django-csp==4.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.22",
@@ -39,8 +39,8 @@ dependencies = [
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.9",
"django-treebeard==4.7.1",
"django<6.0.0",
"django-treebeard==4.8.0",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
@@ -48,18 +48,19 @@ dependencies = [
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.25.1",
"langfuse==3.11.2",
"lxml==6.0.2",
"markdown==3.10",
"mozilla-django-oidc==4.0.1",
"mozilla-django-oidc==5.0.2",
"nested-multipart-parser==1.6.0",
"openai==2.8.0",
"psycopg[binary]==3.2.12",
"pycrdt==0.12.43",
"openai==2.14.0",
"psycopg[binary]==3.3.2",
"pycrdt==0.12.44",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.5",
"sentry-sdk==2.44.0",
"sentry-sdk==2.48.0",
"whitenoise==6.11.0",
]
@@ -73,20 +74,20 @@ dependencies = [
dev = [
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.10.1",
"drf-spectacular-sidecar==2025.12.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"ipython==9.8.0",
"pyfakefs==6.0.0",
"pylint-django==2.6.1",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==9.0.1",
"pytest==9.0.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.14.5",
"ruff==0.14.10",
"types-requests==2.32.4.20250913",
]

File diff suppressed because one or more lines are too long

View File

@@ -58,7 +58,7 @@ test.describe('Doc Comments', () => {
await page.getByRole('button', { name: '👍' }).click();
await expect(
thread.getByRole('img', { name: 'E2E Chromium' }).first(),
thread.getByRole('img', { name: `E2E ${browserName}` }).first(),
).toBeVisible();
await expect(thread.getByText('This is a comment').first()).toBeVisible();
await expect(thread.getByText(`E2E ${browserName}`).first()).toBeVisible();
@@ -394,6 +394,8 @@ test.describe('Doc Comments mobile', () => {
await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click();
await expect(thread.getByText('This is a comment').first()).toBeHidden();
// Check toolbar is closed after adding a comment
await expect(page.getByRole('button', { name: 'Paragraph' })).toBeHidden();
await editor.first().click();
await editor.getByText('Hello').click();

View File

@@ -73,7 +73,7 @@ test.describe('Doc Editor', () => {
await page.keyboard.press('Escape');
await page.locator('.bn-block-outer').last().click();
await page.locator('.ProseMirror').focus();
await page.keyboard.press('Enter');
@@ -604,7 +604,7 @@ test.describe('Doc Editor', () => {
await verifyDocName(page, randomDoc);
const editor = await openSuggestionMenu({ page });
const { editor } = await openSuggestionMenu({ page });
await page.getByText('Embedded file').click();
await page.getByText('Upload file').click();

View File

@@ -1,18 +1,19 @@
import fs from 'fs';
import path from 'path';
import { expect, test } from '@playwright/test';
import { Download, Page, expect, test } from '@playwright/test';
import cs from 'convert-stream';
import JSZip from 'jszip';
import { PDFParse } from 'pdf-parse';
import {
BrowserName,
TestLanguage,
createDoc,
verifyDocName,
waitForLanguageSwitch,
} from './utils-common';
import { openSuggestionMenu, writeInEditor } from './utils-editor';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -46,81 +47,14 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
});
test('it exports the doc with pdf line break', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'doc-editor-line-break',
browserName,
1,
);
await verifyDocName(page, randomDoc);
const editor = await writeInEditor({ page, text: 'Hello' });
await page.keyboard.press('Enter');
await openSuggestionMenu({ page });
await page.getByText('Page Break').click();
await expect(
editor.locator('div[data-content-type="pageBreak"]'),
).toBeVisible();
await writeInEditor({ page, text: 'World' });
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfInfo = await pdfParse.getInfo();
const pdfText = await pdfParse.getText();
expect(pdfInfo.total).toBe(2);
expect(pdfText.pages).toStrictEqual([
{ text: 'Hello', num: 1 },
{ text: 'World', num: 2 },
]);
expect(pdfInfo?.info.Title).toBe(randomDoc);
});
/**
* We override the document content to ensure that the exported DOCX
* contains various elements for testing.
* We don't check the content of the DOCX here, just that the export works
* and the file is correctly named.
*/
test('it exports the doc to docx', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
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();
const randomDoc = await overrideDocContent({ page, browserName });
await page
.getByRole('button', {
@@ -143,29 +77,14 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
});
/**
* We override the document content to ensure that the exported ODT
* contains various elements for testing.
* We don't check the content of the ODT here, just that the export works
* and the file is correctly named.
*/
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();
const randomDoc = await overrideDocContent({ page, browserName });
await page
.getByRole('button', {
@@ -358,108 +277,6 @@ test.describe('Doc Export', () => {
expect(pdfText.text).toContain('Hello World');
});
test('it exports the doc with quotes', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'export-quotes', browserName, 1);
const editor = page.locator('.ProseMirror.bn-editor');
// Trigger slash menu to show menu
await editor.click();
await editor.fill('/');
await page.getByText('Quote or excerpt').click();
await expect(
editor.locator('.bn-block-content[data-content-type="quote"]'),
).toBeVisible();
await editor
.locator('.bn-block-content[data-content-type="quote"]')
.fill('Hello World');
await expect(editor.getByText('Hello World')).toHaveCSS(
'font-style',
'italic',
);
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
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 ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'doc-multi-columns',
browserName,
1,
);
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Three Columns', { exact: true }).click();
await page.locator('.bn-block-column').first().fill('Column 1');
await page.locator('.bn-block-column').nth(1).fill('Column 2');
await page.locator('.bn-block-column').last().fill('Column 3');
expect(await page.locator('.bn-block-column').count()).toBe(3);
await expect(
page.locator('.bn-block-column[data-node-type="column"]').first(),
).toHaveText('Column 1');
await expect(
page.locator('.bn-block-column[data-node-type="column"]').nth(1),
).toHaveText('Column 2');
await expect(
page.locator('.bn-block-column[data-node-type="column"]').last(),
).toHaveText('Column 3');
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
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,
@@ -506,53 +323,18 @@ test.describe('Doc Export', () => {
expect(pdfString).toContain('/Lang (fr)');
});
test('it exports the doc with interlinking', async ({
test('it exports the doc to PDF and checks regressions', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'export-interlinking',
browserName,
1,
);
// PDF Binary comparison is different depending on the browser used
// We only run this test on Chromium to avoid having to maintain
// multiple sets of PDF fixtures
if (browserName !== 'chromium') {
test.skip();
}
await verifyDocName(page, randomDoc);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'export-interlink-child',
);
await verifyDocName(page, docChild);
const editor = await openSuggestionMenu({ page });
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 interlink = editor
.locator('.--docs--interlinking-link-inline-content')
.first();
await expect(interlink).toContainText(randomDoc);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${docChild}.pdf`);
});
const randomDoc = await overrideDocContent({ page, browserName });
await page
.getByRole('button', {
@@ -560,77 +342,159 @@ test.describe('Doc Export', () => {
})
.click();
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${docChild}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.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);
const editor = await openSuggestionMenu({ page });
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 interlink = editor
.locator('.--docs--interlinking-link-inline-content')
.first();
await expect(interlink).toContainText(randomDoc);
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-open-modal-download-button'),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${docChild}.odt`);
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page.getByTestId('doc-export-download-button').click();
await page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${docChild}.odt`);
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
// If we need to update the PDF regression fixture, uncomment the line below
//await savePDFToAssetFolder(download);
// Assert the generated PDF matches "assets/doc-export-regressions.pdf"
await comparePDFWithAssetFolder(download);
});
});
export const savePDFToAssetFolder = async (download: Download) => {
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfPath = path.join(__dirname, 'assets', `doc-export-regressions.pdf`);
fs.writeFileSync(pdfPath, pdfBuffer);
};
export const comparePDFWithAssetFolder = async (download: Download) => {
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
// Load reference PDF for comparison
const referencePdfPath = path.join(
__dirname,
'assets',
'doc-export-regressions.pdf',
);
const referencePdfBuffer = fs.readFileSync(referencePdfPath);
// Parse both PDFs
const generatedPdf = new PDFParse({ data: pdfBuffer });
const referencePdf = new PDFParse({ data: referencePdfBuffer });
const [generatedInfo, referenceInfo] = await Promise.all([
generatedPdf.getInfo(),
referencePdf.getInfo(),
]);
const [generatedScreenshot, referenceScreenshot] = await Promise.all([
generatedPdf.getScreenshot(),
referencePdf.getScreenshot(),
]);
generatedScreenshot.pages[0].data;
const [generatedText, referenceText] = await Promise.all([
generatedPdf.getText(),
referencePdf.getText(),
]);
// Compare page count
expect(generatedInfo.total).toBe(referenceInfo.total);
// Compare text content
expect(generatedText.text).toBe(referenceText.text);
// Compare screenshots page by page
for (let i = 0; i < generatedScreenshot.pages.length; i++) {
const genPage = generatedScreenshot.pages[i];
const refPage = referenceScreenshot.pages[i];
expect(genPage.width).toBe(refPage.width);
expect(genPage.height).toBe(refPage.height);
expect(genPage.data).toStrictEqual(refPage.data);
}
};
/**
* Override the document content API response to use a test content
* This test content contains many blocks to facilitate testing
* @param page
*/
export const overrideDocContent = async ({
page,
browserName,
}: {
page: Page;
browserName: BrowserName;
}) => {
// Override content prop with assets/base-content-test-pdf.txt
await page.route(/\**\/documents\/\**/, async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=') &&
!request.url().includes('versions') &&
!request.url().includes('accesses') &&
!request.url().includes('invitations')
) {
const response = await route.fetch();
const json = await response.json();
json.content = fs.readFileSync(
path.join(__dirname, 'assets/base-content-test-pdf.txt'),
'utf-8',
);
void route.fulfill({
response,
body: JSON.stringify(json),
});
} else {
await route.continue();
}
});
const [randomDoc] = await createDoc(
page,
'doc-export-override-content',
browserName,
1,
);
await verifyDocName(page, randomDoc);
// Add Image SVG
await page.keyboard.press('Enter');
const { suggestionMenu } = await openSuggestionMenu({ page });
await suggestionMenu.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[src$=".svg"]')
.first();
await expect(image).toBeVisible();
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
// Add Image PNG
await openSuggestionMenu({ page });
await suggestionMenu.getByText('Resizable image with caption').click();
const fileChooserPNGPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooserPNG = await fileChooserPNGPromise;
await fileChooserPNG.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const imagePng = page
.locator('.--docs--editor-container img.bn-visual-media[src$=".png"]')
.first();
await expect(imagePng).toBeVisible();
await page.waitForTimeout(1000);
return randomDoc;
};

View File

@@ -21,7 +21,7 @@ test.describe('Inherited share accesses', () => {
`doc-share-member-row-user.test@${browserName}.test`,
);
await expect(user).toBeVisible();
await expect(user.getByText('E2E Chromium')).toBeVisible();
await expect(user.getByText(`E2E ${browserName}`)).toBeVisible();
await expect(user.getByText('Owner')).toBeVisible();
await page

View File

@@ -4,7 +4,6 @@ import {
createDoc,
expectLoginPage,
keyCloakSignIn,
randomName,
updateDocTitle,
verifyDocName,
} from './utils-common';
@@ -20,50 +19,6 @@ test.describe('Doc Tree', () => {
await page.goto('/');
});
test('create new sub pages', async ({ page, browserName }) => {
const [titleParent] = await createDoc(
page,
'doc-tree-content',
browserName,
1,
);
await verifyDocName(page, titleParent);
const addButton = page.getByTestId('new-doc-button');
const docTree = page.getByTestId('doc-tree');
await expect(addButton).toBeVisible();
// Wait for and intercept the POST request to create a new page
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
await clickOnAddRootSubPage(page);
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const subPageJson = await response.json();
await expect(docTree).toBeVisible();
const subPageItem = docTree
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
.first();
await expect(subPageItem).toBeVisible();
await subPageItem.click();
await verifyDocName(page, '');
const input = page.getByRole('textbox', { name: 'Document title' });
await input.click();
const [randomDocName] = randomName('doc-tree-test', browserName, 1);
await input.fill(randomDocName);
await input.press('Enter');
await expect(subPageItem.getByText(randomDocName)).toBeVisible();
await page.reload();
await expect(subPageItem.getByText(randomDocName)).toBeVisible();
});
test('check the reorder of sub pages', async ({ page, browserName }) => {
await createDoc(page, 'doc-tree-content', browserName, 1);
const addButton = page.getByTestId('new-doc-button');

View File

@@ -42,8 +42,8 @@ test.describe('Doc Version', () => {
// Write more
await writeInEditor({ page, text: 'It will create a version' });
await openSuggestionMenu({ page });
await page.getByText('Add a callout block').click();
const { suggestionMenu } = await openSuggestionMenu({ page });
await suggestionMenu.getByText('Add a callout block').click();
const calloutBlock = page
.locator('div[data-content-type="callout"]')

View File

@@ -59,45 +59,90 @@ test.describe('Header', () => {
).toBeVisible();
await expect(header.getByText('English')).toBeVisible();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',
}),
).toBeVisible();
});
test('checks La Gauffre interaction', async ({ page }) => {
test('checks a custom waffle', async ({ page }) => {
await overrideConfig(page, {
FRONTEND_THEME: 'dsfr',
theme_customization: {
waffle: {
data: {
services: [
{
name: 'Docs E2E Custom 1',
url: 'https://docs.numerique.gouv.fr/',
maturity: 'stable',
logo: 'https://lasuite.numerique.gouv.fr/assets/products/docs.svg',
},
{
name: 'Docs E2E Custom 2',
url: 'https://docs.numerique.gouv.fr/',
maturity: 'stable',
logo: 'https://lasuite.numerique.gouv.fr/assets/products/docs.svg',
},
],
},
showMoreLimit: 9,
},
},
});
await page.goto('/');
const header = page.locator('header').first();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',
}),
header.getByRole('button', { name: 'Digital LaSuite services' }),
).toBeVisible();
/**
* La gaufre load a js file from a remote server,
* The Waffle loads a js file from a remote server,
* it takes some time to load the file and have the interaction available
*/
await page.waitForTimeout(1500);
await header
.getByRole('button', { name: 'Digital LaSuite services' })
.click();
await expect(
page.getByRole('link', { name: 'Docs E2E Custom 1' }),
).toBeVisible();
await expect(
page.getByRole('link', { name: 'Docs E2E Custom 2' }),
).toBeVisible();
});
test('checks the waffle dsfr', async ({ page }) => {
await overrideConfig(page, {
theme_customization: {
waffle: {
apiUrl: 'https://lasuite.numerique.gouv.fr/api/services',
showMoreLimit: 9,
},
},
});
await page.goto('/');
const header = page.locator('header').first();
await expect(
header.getByRole('button', { name: 'Digital LaSuite services' }),
).toBeVisible();
/**
* The Waffle loads a js file from a remote server,
* it takes some time to load the file and have the interaction available
*/
await page.waitForTimeout(1500);
await header
.getByRole('button', {
name: 'Les services de La Suite numérique',
name: 'Digital LaSuite services',
})
.click();
await expect(
page.getByRole('link', { name: 'France Transfert' }),
).toBeVisible();
await expect(page.getByRole('link', { name: 'Tchap' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Grist' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Visio' })).toBeVisible();
});
});
@@ -124,11 +169,6 @@ test.describe('Header mobile', () => {
await expect(header.getByLabel('Open the header menu')).toBeVisible();
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',
}),
).toBeVisible();
});
});

View File

@@ -113,9 +113,6 @@ test.describe('Home page', () => {
});
await expect(languageButton).toBeVisible();
await expect(
header.getByRole('button', { name: 'Les services de La Suite numé' }),
).toBeVisible();
await expect(
header.getByRole('img', { name: 'Gouvernement Logo' }),
).toBeVisible();

View File

@@ -1,6 +1,11 @@
import { expect, test } from '@playwright/test';
import { TestLanguage, createDoc, waitForLanguageSwitch } from './utils-common';
import {
TestLanguage,
createDoc,
overrideConfig,
waitForLanguageSwitch,
} from './utils-common';
import { openSuggestionMenu } from './utils-editor';
test.describe('Language', () => {
@@ -107,10 +112,21 @@ test.describe('Language', () => {
page,
browserName,
}) => {
await overrideConfig(page, {
LANGUAGES: [
['en-us', 'English'],
['fr-fr', 'Français'],
['sv-se', 'Svenska'],
],
LANGUAGE_CODE: 'en-us',
});
await createDoc(page, 'doc-toolbar', browserName, 1);
const editor = await openSuggestionMenu({ page });
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
const { editor, suggestionMenu } = await openSuggestionMenu({ page });
await expect(
suggestionMenu.getByText('Headings', { exact: true }),
).toBeVisible();
await editor.click(); // close the menu
@@ -121,6 +137,17 @@ test.describe('Language', () => {
// Trigger slash menu to show french menu
await openSuggestionMenu({ page });
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
await expect(
suggestionMenu.getByText('Titres', { exact: true }),
).toBeVisible();
/**
* Swedish is not yet supported in the BlockNote locales, so it should fallback to English
*/
await waitForLanguageSwitch(page, TestLanguage.Swedish);
await openSuggestionMenu({ page });
await expect(
suggestionMenu.getByText('Headings', { exact: true }),
).toBeVisible();
});
});

View File

@@ -224,7 +224,9 @@ export const updateDocTitle = async (page: Page, title: string) => {
await expect(input).toHaveText('');
await expect(input).toBeVisible();
await input.click();
await input.fill(title);
await input.fill(title, {
force: true,
});
await input.click();
await input.blur();
await verifyDocName(page, title);
@@ -328,6 +330,10 @@ export const TestLanguage = {
label: 'Deutsch',
expectedLocale: ['de-de'],
},
Swedish: {
label: 'Svenska',
expectedLocale: ['sv-se'],
},
} as const;
type TestLanguageKey = keyof typeof TestLanguage;

View File

@@ -7,11 +7,11 @@ export const getEditor = async ({ page }: { page: Page }) => {
};
export const openSuggestionMenu = async ({ page }: { page: Page }) => {
const editor = await getEditor({ page });
await editor.click();
await writeInEditor({ page, text: '/' });
const editor = await writeInEditor({ page, text: '/' });
return editor;
const suggestionMenu = page.locator('.bn-suggestion-menu');
return { editor, suggestionMenu };
};
export const writeInEditor = async ({
@@ -22,6 +22,11 @@ export const writeInEditor = async ({
text: string;
}) => {
const editor = await getEditor({ page });
await editor.locator('.bn-block-outer .bn-inline-content').last().fill(text);
await editor
.locator('.bn-block-outer:last-child')
.last()
.locator('.bn-inline-content:last-child')
.last()
.fill(text);
return editor;
};

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "4.2.0",
"version": "4.4.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",

View File

@@ -1,58 +1,53 @@
import { cunninghamConfig as tokens } from '@gouvfr-lasuite/ui-kit';
import { defaultTokens } from '@openfun/cunningham-react';
import merge from 'lodash/merge';
import {
dsfrGlobals,
getUIKitThemesFromGlobals,
whiteLabelGlobals,
} from '@gouvfr-lasuite/ui-kit';
// Uikit does not provide the full list of tokens.
// To be able to override correctly, we need to merge with the default tokens.
let mergedColors = merge(
defaultTokens.globals.colors,
tokens.themes.default.globals.colors,
);
mergedColors = {
...mergedColors,
'logo-1': '#2845C1',
};
tokens.themes.default.globals = {
...tokens.themes.default.globals,
...{
colors: mergedColors,
font: {
...tokens.themes.default.globals.font,
families: {
base: 'sans-serif',
accent: 'sans-serif',
const themeWhiteLabelLight = getUIKitThemesFromGlobals(whiteLabelGlobals, {
prefix: 'default',
variants: ['light'],
overrides: {
globals: {
spacing: {
'0': '0rem',
none: '0rem',
auto: 'auto',
bx: '2.2rem',
full: '100%',
'3xs': '0.25rem',
'2xs': '0.375rem',
},
},
components: {
logo: {
src: '',
alt: '',
widthHeader: '',
widthFooter: '',
},
'home-proconnect': false,
icon: {
src: '/assets/icon-docs.svg',
width: '32px',
height: 'auto',
},
favicon: {
'png-light': '/assets/favicon-light.png',
'png-dark': '/assets/favicon-dark.png',
},
},
},
});
const themeDefault = {
default: themeWhiteLabelLight['default-light'],
};
tokens.themes.default.components = {
...tokens.themes.default.components,
...{
logo: {
src: '',
alt: '',
widthHeader: '',
widthFooter: '',
},
'la-gaufre': false,
'home-proconnect': false,
icon: {
src: '/assets/icon-docs.svg',
width: '32px',
height: 'auto',
},
favicon: {
'png-light': '/assets/favicon-light.png',
'png-dark': '/assets/favicon-dark.png',
},
},
};
const dsfrTheme = {
dsfr: {
const themesDSFRLight = getUIKitThemesFromGlobals(dsfrGlobals, {
prefix: 'dsfr',
variants: ['light'],
overrides: {
globals: {
font: {
families: {
@@ -68,7 +63,6 @@ const dsfrTheme = {
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
'la-gaufre': true,
'home-proconnect': true,
icon: {
src: '/assets/icon-docs-dsfr.svg',
@@ -82,317 +76,16 @@ const dsfrTheme = {
},
},
},
};
});
const genericTheme = {
generic: {
globals: {
colors: {
'brand-050': '#EEF1FA',
'brand-100': '#DDE2F5',
'brand-150': '#CED3F1',
'brand-200': '#BEC5F0',
'brand-250': '#AFB5F1',
'brand-300': '#A0A5F6',
'brand-350': '#8F94FD',
'brand-400': '#8184FC',
'brand-450': '#7576EE',
'brand-500': '#6969DF',
'brand-550': '#5E5CD0',
'brand-600': '#534FC2',
'brand-650': '#4844AD',
'brand-700': '#3E3B98',
'brand-750': '#36347D',
'brand-800': '#2D2F5F',
'brand-850': '#262848',
'brand-900': '#1C1E32',
'brand-950': '#11131F',
'gray-000': '#FFFFFF',
'gray-025': '#F8F8F9',
'gray-050': '#F0F0F3',
'gray-100': '#E2E2EA',
'gray-150': '#D3D4E0',
'gray-200': '#C5C6D5',
'gray-250': '#B7B7CB',
'gray-300': '#A9A9BF',
'gray-350': '#9C9CB2',
'gray-400': '#8F8FA4',
'gray-450': '#828297',
'gray-500': '#75758A',
'gray-550': '#69697D',
'gray-600': '#5D5D70',
'gray-650': '#515164',
'gray-700': '#454558',
'gray-750': '#3A3A4C',
'gray-800': '#2F303D',
'gray-850': '#25252F',
'gray-900': '#1B1B23',
'gray-950': '#111114',
'gray-1000': '#000000',
'info-050': '#EAF2F9',
'info-100': '#D5E4F3',
'info-150': '#BFD7F0',
'info-200': '#A7CAEE',
'info-250': '#8DBDEF',
'info-300': '#6EB0F2',
'info-350': '#50A2F5',
'info-400': '#3593F4',
'info-450': '#1185ED',
'info-500': '#0077DE',
'info-550': '#0069CF',
'info-600': '#005BC0',
'info-650': '#0D4EAA',
'info-700': '#124394',
'info-750': '#163878',
'info-800': '#192F5A',
'info-850': '#192541',
'info-900': '#141B2D',
'info-950': '#0C111C',
'success-050': '#E8F1EA',
'success-100': '#CFE4D4',
'success-150': '#BAD9C1',
'success-200': '#A2CFAD',
'success-250': '#86C597',
'success-300': '#6CBA83',
'success-350': '#4FB070',
'success-400': '#40A363',
'success-450': '#309556',
'success-500': '#1E884A',
'success-550': '#027B3E',
'success-600': '#016D31',
'success-650': '#006024',
'success-700': '#005317',
'success-750': '#0D4511',
'success-800': '#11380E',
'success-850': '#132A11',
'success-900': '#101E0F',
'success-950': '#091209',
'warning-050': '#F8F0E9',
'warning-100': '#F1E0D3',
'warning-150': '#ECD0BC',
'warning-200': '#E8C0A4',
'warning-250': '#E8AE8A',
'warning-300': '#EB9970',
'warning-350': '#E98456',
'warning-400': '#E57036',
'warning-450': '#DA5E18',
'warning-500': '#CB5000',
'warning-550': '#BC4200',
'warning-600': '#AD3300',
'warning-650': '#9E2300',
'warning-700': '#882011',
'warning-750': '#731E16',
'warning-800': '#58201A',
'warning-850': '#401D18',
'warning-900': '#2E1714',
'warning-950': '#1D0F0D',
'error-050': '#F9EFEC',
'error-100': '#F4DFD9',
'error-150': '#F0CEC6',
'error-200': '#EEBCB2',
'error-250': '#EEA99D',
'error-300': '#EF9486',
'error-350': '#F37C6E',
'error-400': '#F65F53',
'error-450': '#F0463D',
'error-500': '#E82322',
'error-550': '#D7010E',
'error-600': '#C00100',
'error-650': '#AA0000',
'error-700': '#910C06',
'error-750': '#731E16',
'error-800': '#58201A',
'error-850': '#401D18',
'error-900': '#2E1714',
'error-950': '#1D0F0D',
'red-050': '#FAEFEE',
'red-100': '#F4DEDD',
'red-150': '#F1CDCB',
'red-200': '#EFBBBA',
'red-250': '#EEA8A8',
'red-300': '#F09394',
'red-350': '#F37B7E',
'red-400': '#EF6569',
'red-450': '#E94A55',
'red-500': '#DA3B49',
'red-550': '#CA2A3C',
'red-600': '#BB1330',
'red-650': '#A90021',
'red-700': '#910A13',
'red-750': '#731E16',
'red-800': '#58201A',
'red-850': '#411D18',
'red-900': '#2E1714',
'red-950': '#1D0F0D',
'orange-050': '#F8F0E9',
'orange-100': '#F1E0D3',
'orange-150': '#ECD0BD',
'orange-200': '#EABFA6',
'orange-250': '#EBAC90',
'orange-300': '#EC9772',
'orange-350': '#E5845A',
'orange-400': '#D6774D',
'orange-450': '#C86A40',
'orange-500': '#B95D33',
'orange-550': '#AB5025',
'orange-600': '#9D4315',
'orange-650': '#8F3600',
'orange-700': '#812900',
'orange-750': '#6C2511',
'orange-800': '#572017',
'orange-850': '#401D18',
'orange-900': '#2E1714',
'orange-950': '#1D0F0D',
'brown-050': '#F6F0E8',
'brown-100': '#F1E0D3',
'brown-150': '#EBD0BA',
'brown-200': '#E2C0A6',
'brown-250': '#D4B398',
'brown-300': '#C6A58B',
'brown-350': '#B8987E',
'brown-400': '#AA8B71',
'brown-450': '#9D7E65',
'brown-500': '#8F7158',
'brown-550': '#82654C',
'brown-600': '#765841',
'brown-650': '#694C35',
'brown-700': '#5D412A',
'brown-750': '#51361E',
'brown-800': '#452A13',
'brown-850': '#392008',
'brown-900': '#29180A',
'brown-950': '#1B0F08',
'yellow-050': '#F3F0E7',
'yellow-100': '#E9E2CF',
'yellow-150': '#E1D4B7',
'yellow-200': '#D9C599',
'yellow-250': '#D2B677',
'yellow-300': '#CAA756',
'yellow-350': '#C2972E',
'yellow-400': '#B98900',
'yellow-450': '#AB7B00',
'yellow-500': '#9D6E00',
'yellow-550': '#916100',
'yellow-600': '#855400',
'yellow-650': '#784700',
'yellow-700': '#6C3A00',
'yellow-750': '#5F2E00',
'yellow-800': '#512302',
'yellow-850': '#3E1D10',
'yellow-900': '#2D1711',
'yellow-950': '#1D0F0D',
'green-050': '#E6F1E9',
'green-100': '#CFE4D5',
'green-150': '#B8D8C1',
'green-200': '#A0CFAE',
'green-250': '#84C59A',
'green-300': '#65BA86',
'green-350': '#45B173',
'green-400': '#23A562',
'green-450': '#029755',
'green-500': '#008948',
'green-550': '#017B3B',
'green-600': '#006E2E',
'green-650': '#006022',
'green-700': '#005314',
'green-750': '#0D4510',
'green-800': '#11380E',
'green-850': '#132A11',
'green-900': '#101E0F',
'green-950': '#091209',
'blue1-050': '#EBF1F9',
'blue1-100': '#D6E4F4',
'blue1-150': '#C1D7F0',
'blue1-200': '#AACAEF',
'blue1-250': '#8FBCEF',
'blue1-300': '#7CAFEB',
'blue1-350': '#68A1E4',
'blue1-400': '#5B94D6',
'blue1-450': '#4E86C7',
'blue1-500': '#4279B9',
'blue1-550': '#356CAC',
'blue1-600': '#28609E',
'blue1-650': '#1B5390',
'blue1-700': '#0B4783',
'blue1-750': '#0F3C6E',
'blue1-800': '#133059',
'blue1-850': '#152641',
'blue1-900': '#121C2D',
'blue1-950': '#0B111C',
'blue2-050': '#E7F3F4',
'blue2-100': '#CEE7E9',
'blue2-150': '#B2DCE0',
'blue2-200': '#91D1D7',
'blue2-250': '#68C7D0',
'blue2-300': '#43BBC5',
'blue2-350': '#00AFBA',
'blue2-400': '#01A0AA',
'blue2-450': '#00929D',
'blue2-500': '#00848F',
'blue2-550': '#007682',
'blue2-600': '#016874',
'blue2-650': '#005B67',
'blue2-700': '#004E5A',
'blue2-750': '#00424E',
'blue2-800': '#003642',
'blue2-850': '#002A38',
'blue2-900': '#061E28',
'blue2-950': '#071219',
'purple-050': '#F7F0F6',
'purple-100': '#EEE0EE',
'purple-150': '#E7D1E7',
'purple-200': '#DBBFE4',
'purple-250': '#D3AEE2',
'purple-300': '#CB99E1',
'purple-350': '#C188D9',
'purple-400': '#B47BCB',
'purple-450': '#A66EBD',
'purple-500': '#9961AF',
'purple-550': '#8B55A1',
'purple-600': '#7E4894',
'purple-650': '#723C87',
'purple-700': '#633376',
'purple-750': '#552A65',
'purple-800': '#452551',
'purple-850': '#35213D',
'purple-900': '#261A2C',
'purple-950': '#17111C',
'pink-050': '#F8EFF4',
'pink-100': '#F0DFEA',
'pink-150': '#EACEDF',
'pink-200': '#E9BBD1',
'pink-250': '#E9A7C2',
'pink-300': '#E095B4',
'pink-350': '#D685A8',
'pink-400': '#C7799B',
'pink-450': '#B86C8D',
'pink-500': '#AA5F80',
'pink-550': '#9C5374',
'pink-600': '#8E4767',
'pink-650': '#813B5B',
'pink-700': '#732E4F',
'pink-750': '#632643',
'pink-800': '#521F38',
'pink-850': '#3E1C2B',
'pink-900': '#2D171F',
'pink-950': '#1C0E12',
},
font: {
families: {
base: 'Inter, Roboto Flex Variable, sans-serif',
accent: 'Inter, Roboto Flex Variable, sans-serif',
},
},
},
},
const themesDSFR = {
dsfr: themesDSFRLight['dsfr-light'],
};
const docsTokens = {
...tokens,
themes: {
...tokens.themes,
...dsfrTheme,
...genericTheme,
...themeDefault,
...themesDSFR,
},
};

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "4.2.0",
"version": "4.4.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -34,15 +34,15 @@
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/cunningham-react": "4.1.0",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.18.4",
"@gouvfr-lasuite/ui-kit": "0.18.6",
"@hocuspocus/provider": "3.4.3",
"@mantine/core": "8.3.10",
"@mantine/hooks": "8.3.10",
"@openfun/cunningham-react": "4.0.0",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.30.0",
"@tanstack/react-query": "5.90.12",
"@sentry/nextjs": "10.32.1",
"@tanstack/react-query": "5.90.16",
"@tiptap/extensions": "*",
"canvg": "4.0.3",
"clsx": "2.1.1",
@@ -52,32 +52,33 @@
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.6.0",
"i18next": "25.7.2",
"i18next": "25.7.3",
"i18next-browser-languagedetector": "8.2.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.7.2",
"next": "15.5.9",
"posthog-js": "1.306.1",
"posthog-js": "1.312.0",
"react": "*",
"react-aria-components": "1.13.0",
"react-aria-components": "1.14.0",
"react-dom": "*",
"react-i18next": "16.5.0",
"react-i18next": "16.5.1",
"react-intersection-observer": "10.0.0",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.1.19",
"use-debounce": "10.0.6",
"y-protocols": "1.0.6",
"uuid": "13.0.0",
"y-protocols": "1.0.7",
"yjs": "*",
"zustand": "5.0.9"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.91.1",
"@tanstack/react-query-devtools": "5.91.2",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.0",
"@testing-library/react": "16.3.1",
"@testing-library/user-event": "14.6.1",
"@types/lodash": "4.17.21",
"@types/luxon": "3.7.1",
@@ -90,16 +91,16 @@
"dotenv": "17.2.3",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "27.3.0",
"jsdom": "27.4.0",
"node-fetch": "2.7.0",
"prettier": "3.7.4",
"stylelint": "16.26.1",
"stylelint-config-standard": "39.0.1",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vite-tsconfig-paths": "6.0.1",
"vitest": "4.0.15",
"webpack": "5.103.0",
"vite-tsconfig-paths": "6.0.3",
"vitest": "4.0.16",
"webpack": "5.104.1",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"

View File

@@ -5,11 +5,14 @@ import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
export const PICKER_HEIGHT = 500;
interface EmojiPickerProps {
emojiData: EmojiMartData;
onClickOutside: () => void;
onEmojiSelect: ({ native }: { native: string }) => void;
withOverlay?: boolean;
onEscape?: () => void;
}
export const EmojiPicker = ({
@@ -17,14 +20,26 @@ export const EmojiPicker = ({
onClickOutside,
onEmojiSelect,
withOverlay = false,
onEscape,
}: EmojiPickerProps) => {
const { i18n } = useTranslation();
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
if (onEscape) {
onEscape();
} else {
onClickOutside();
}
}
};
const pickerContent = (
<Box $position="absolute" $zIndex={1000} $margin="2rem 0 0 0">
<Box $position="absolute" $zIndex={1000} onKeyDownCapture={handleKeyDown}>
<Picker
data={emojiData}
locale={i18n.resolvedLanguage}
autoFocus
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
previewPosition="none"

View File

@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { PropsWithChildren } from 'react';
import { useTranslation } from 'react-i18next';
import { InView } from 'react-intersection-observer';

View File

@@ -1,4 +1,4 @@
import { Loader } from '@openfun/cunningham-react';
import { Loader } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, BoxProps } from './Box';

View File

@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -1,4 +1,4 @@
import { Alert, VariantType } from '@openfun/cunningham-react';
import { Alert, VariantType } from '@gouvfr-lasuite/cunningham-react';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';

View File

@@ -3,6 +3,8 @@ export * from './BoxButton';
export * from './Card';
export * from './DropButton';
export * from './dropdown-menu/DropdownMenu';
export * from './Emoji/EmojiPicker';
export { default as emojidata } from './Emoji/initEmojiCallout';
export * from './quick-search';
export * from './Icon';
export * from './InfiniteScroll';

View File

@@ -1,4 +1,4 @@
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -1,4 +1,4 @@
import { Button, type ButtonProps } from '@openfun/cunningham-react';
import { Button, type ButtonProps } from '@gouvfr-lasuite/cunningham-react';
import React from 'react';
import { Icon } from '@/components';

View File

@@ -1,4 +1,4 @@
import { Modal, ModalSize } from '@openfun/cunningham-react';
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { ComponentPropsWithRef, PropsWithChildren } from 'react';
import { createGlobalStyle } from 'styled-components';

View File

@@ -1,4 +1,4 @@
import { Loader } from '@openfun/cunningham-react';
import { Loader } from '@gouvfr-lasuite/cunningham-react';
import { Command } from 'cmdk';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -1,4 +1,4 @@
import { CunninghamProvider } from '@openfun/cunningham-react';
import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react';
import {
MutationCache,
QueryClient,
@@ -52,17 +52,14 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
const { theme } = useCunninghamTheme();
const { replace } = useRouter();
const { initializeResizeListener, initializeInputDetection } =
useResponsiveStore();
const initializeResizeListener = useResponsiveStore(
(state) => state.initializeResizeListener,
);
useEffect(() => {
return initializeResizeListener();
}, [initializeResizeListener]);
useEffect(() => {
return initializeInputDetection();
}, [initializeInputDetection]);
/**
* Update the global router replace function
* This allows us to use the router replace function globally

View File

@@ -1,4 +1,4 @@
import { Loader } from '@openfun/cunningham-react';
import { Loader } from '@gouvfr-lasuite/cunningham-react';
import Head from 'next/head';
import Script from 'next/script';
import { PropsWithChildren, useEffect, useRef } from 'react';

View File

@@ -4,13 +4,14 @@ import { Resource } from 'i18next';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Theme } from '@/cunningham/';
import { FooterType } from '@/features/footer';
import { HeaderType } from '@/features/header';
import { HeaderType, WaffleType } from '@/features/header';
import { PostHogConf } from '@/services';
interface ThemeCustomization {
footer?: FooterType;
translations?: Resource;
header?: HeaderType;
waffle?: WaffleType;
}
export interface ConfigResponse {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,7 @@ const getMergedTokens = (theme: Theme) => {
return merge({}, tokens.themes['default'], tokens.themes[theme]);
};
const DEFAULT_THEME: Theme = 'generic';
const DEFAULT_THEME: Theme = 'default';
const defaultTokens = getMergedTokens(DEFAULT_THEME);
const initialState: ThemeStore = {

View File

@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';

View File

@@ -93,7 +93,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
const { i18n } = useTranslation();
const lang = i18n.resolvedLanguage;
let lang = i18n.resolvedLanguage;
if (!lang || !(lang in locales)) {
lang = 'en';
}
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
@@ -259,7 +262,6 @@ export const BlockNoteReader = ({
const { user } = useAuth();
const { setEditor } = useEditorStore();
const { threadStore } = useComments(docId, false, user);
const { t } = useTranslation();
const editor = useCreateBlockNote(
{
collaboration: {
@@ -305,7 +307,6 @@ export const BlockNoteReader = ({
editor={editor}
editable={false}
theme="light"
aria-label={t('Document viewer')}
formattingToolbar={false}
slashMenu={false}
comments={false}

View File

@@ -9,7 +9,7 @@ import {
Loader,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
} from '@gouvfr-lasuite/cunningham-react';
import { PropsWithChildren, ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -1,26 +1,15 @@
import { FormattingToolbarExtension } from '@blocknote/core/extensions';
import {
ExperimentalMobileFormattingToolbarController,
FormattingToolbar,
FormattingToolbarController,
blockTypeSelectItems,
getFormattingToolbarItems,
useBlockNoteEditor,
useDictionary,
useExtensionState,
} from '@blocknote/react';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { useConfig } from '@/core/config/api';
import { useResponsiveStore } from '@/stores';
import {
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema,
} from '../../types';
import { CommentToolbarButton } from '../comments/CommentToolbarButton';
import { getCalloutFormattingToolbarItems } from '../custom-blocks';
@@ -35,7 +24,6 @@ export const BlockNoteToolbar = () => {
const [onConfirm, setOnConfirm] = useState<() => void | Promise<void>>();
const { t } = useTranslation();
const { data: conf } = useConfig();
const { isTablet, isInputTouch } = useResponsiveStore();
const toolbarItems = useMemo(() => {
let toolbarItems = getFormattingToolbarItems([
@@ -96,13 +84,7 @@ export const BlockNoteToolbar = () => {
return (
<>
{isInputTouch && isTablet ? (
<MobileFormattingToolbarController
formattingToolbar={formattingToolbar}
/>
) : (
<FormattingToolbarController formattingToolbar={formattingToolbar} />
)}
<FormattingToolbarController formattingToolbar={formattingToolbar} />
{confirmOpen && (
<ModalConfirmDownloadUnsafe
onClose={() => setIsConfirmOpen(false)}
@@ -112,38 +94,3 @@ export const BlockNoteToolbar = () => {
</>
);
};
const MobileFormattingToolbarController = ({
formattingToolbar,
}: {
formattingToolbar: () => React.ReactNode;
}) => {
const editor = useBlockNoteEditor<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
>();
const show = useExtensionState(FormattingToolbarExtension, {
editor,
});
if (!show) {
return null;
}
return (
<Box
$position="absolute"
$css={`
& > div {
left: 50%;
transform: translate(0px, 0px) scale(1) translateX(-50%)!important;
}
`}
>
<ExperimentalMobileFormattingToolbarController
formattingToolbar={formattingToolbar}
/>
</Box>
);
};

View File

@@ -74,9 +74,7 @@ export function MarkdownButton() {
};
const show = useMemo(() => {
return (
selectedBlocks.filter((block) => block.content !== undefined).length !== 0
);
return !!selectedBlocks.find((block) => block.content !== undefined);
}, [selectedBlocks]);
if (!show || !editor.isEditable || !Components) {

View File

@@ -1,4 +1,4 @@
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';

View File

@@ -5,6 +5,7 @@ import { Box, Loading } from '@/components';
import { DocHeader } from '@/docs/doc-header/';
import {
Doc,
useDocFocusManagement,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
@@ -82,7 +83,9 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
const readOnly =
!doc.abilities.partial_update || !isEditable || isLoading || isDeletedDoc;
const { setIsSkeletonVisible } = useSkeletonStore();
const isProviderReady = isReady && provider;
const isProviderReady = Boolean(isReady && provider);
useDocFocusManagement(doc.id, isProviderReady);
useEffect(() => {
if (isProviderReady) {

View File

@@ -212,6 +212,17 @@ export class DocsThreadStore extends ThreadStore {
.setMark?.('comment', { orphan: false, threadId })
.run?.();
/**
* We have some issues with mobiles and the formatting toolbar reopening
* after adding a comment, so we restore the cursor position here.
* By restoring the cursor position at the head of the selection,
* it will automatically close the formatting toolbar.
*/
const cursorPos = editor._tiptapEditor?.state.selection.head;
if (cursorPos !== undefined) {
editor._tiptapEditor?.commands.setTextSelection(cursorPos);
}
return Promise.resolve();
};
@@ -241,6 +252,7 @@ export class DocsThreadStore extends ThreadStore {
this.upsertClientThreadData(threadData);
this.notifySubscribers();
this.ping(threadData.id);
return threadData;
};

View File

@@ -12,12 +12,9 @@ import { TFunction } from 'i18next';
import React, { useEffect, useState } from 'react';
import { createGlobalStyle, css } from 'styled-components';
import { Box, BoxButton, Icon } from '@/components';
import { Box, BoxButton, EmojiPicker, Icon, emojidata } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
import { EmojiPicker } from '../EmojiPicker';
import emojidata from './initEmojiCallout';
const CalloutBlockStyle = createGlobalStyle`
.bn-block-content[data-content-type="callout"][data-background-color] {
@@ -97,15 +94,15 @@ const CalloutComponent = ({
`}
>
<CalloutBlockStyle />
<Box $position="relative">
<Box
$position="relative"
$css={css`
align-self: start;
`}
>
<BoxButton
contentEditable={false}
onClick={toggleEmojiPicker}
onKeyDown={(e) => {
if (e.key === 'Escape' && openEmojiPicker) {
setOpenEmojiPicker(false);
}
}}
$css={css`
font-size: 1.125rem;
cursor: ${isEditable ? 'pointer' : 'default'};

View File

@@ -59,7 +59,11 @@ interface PdfBlockComponentProps {
>;
}
const PdfBlockComponent = ({ editor, block }: PdfBlockComponentProps) => {
const PdfBlockComponent = ({
editor,
block,
contentRef,
}: PdfBlockComponentProps) => {
const pdfUrl = block.props.url;
const { i18n, t } = useTranslation();
const lang = i18n.resolvedLanguage;
@@ -110,34 +114,27 @@ const PdfBlockComponent = ({ editor, block }: PdfBlockComponentProps) => {
void validatePDFContent();
}, [pdfUrl]);
if (isPDFContentLoading) {
return <Loading />;
}
if (!isPDFContentLoading && isPDFContent !== null && !isPDFContent) {
return (
<Box
$align="center"
$justify="center"
$color="#666"
$background="#f5f5f5"
$border="1px solid #ddd"
$height="300px"
$css={css`
text-align: center;
`}
$width="100%"
contentEditable={false}
onClick={() => editor.setTextCursorPosition(block)}
>
{t('Invalid or missing PDF file.')}
</Box>
);
}
return (
<>
<Box ref={contentRef} className="bn-file-block-content-wrapper">
<PDFBlockStyle />
{isPDFContentLoading && <Loading />}
{!isPDFContentLoading && isPDFContent !== null && !isPDFContent && (
<Box
$align="center"
$justify="center"
$color="#666"
$background="#f5f5f5"
$border="1px solid #ddd"
$height="300px"
$css={css`
text-align: center;
`}
contentEditable={false}
onClick={() => editor.setTextCursorPosition(block)}
>
{t('Invalid or missing PDF file.')}
</Box>
)}
<ResizableFileBlockWrapper
buttonIcon={
<Icon iconName="upload" $size="24px" $css="line-height: normal;" />
@@ -161,7 +158,7 @@ const PdfBlockComponent = ({ editor, block }: PdfBlockComponentProps) => {
/>
)}
</ResizableFileBlockWrapper>
</>
</Box>
);
};

View File

@@ -1,5 +1,4 @@
export * from './AccessibleImageBlock';
export * from './CalloutBlock';
export { default as emojidata } from './initEmojiCallout';
export * from './PdfBlock';
export * from './UploadLoaderBlock';

View File

@@ -1,21 +1,23 @@
/* eslint-disable react-hooks/rules-of-hooks */
import {
PartialCustomInlineContentFromConfig,
StyleSchema,
} from '@blocknote/core';
import { createReactInlineContentSpec } from '@blocknote/react';
import * as Sentry from '@sentry/nextjs';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { css } from 'styled-components';
import { validate as uuidValidate } from 'uuid';
import { BoxButton, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg';
import { getEmojiAndTitle, useDoc } from '@/docs/doc-management';
import { getEmojiAndTitle, useDoc } from '@/docs/doc-management/';
export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
{
type: 'interlinkingLinkInline',
propSchema: {
url: {
default: '',
},
docId: {
default: '',
},
@@ -27,46 +29,97 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
},
{
render: ({ editor, inlineContent, updateInlineContent }) => {
const { data: doc } = useDoc({ id: inlineContent.props.docId });
const isEditable = editor.isEditable;
if (!inlineContent.props.docId) {
return null;
}
/**
* Update the content title if the referenced doc title changes
* Should not happen
*/
useEffect(() => {
if (
isEditable &&
doc?.title &&
doc.title !== inlineContent.props.title
) {
updateInlineContent({
type: 'interlinkingLinkInline',
props: {
...inlineContent.props,
title: doc.title,
},
});
}
if (!uuidValidate(inlineContent.props.docId)) {
Sentry.captureException(
new Error(`Invalid docId: ${inlineContent.props.docId}`),
{
extra: { info: 'InterlinkingLinkInlineContent' },
},
);
/**
* ⚠️ When doing collaborative editing, doc?.title might be out of sync
* causing an infinite loop of updates.
* To prevent this, we only run this effect when doc?.title changes,
* not when inlineContent.props.title changes.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doc?.title, isEditable]);
updateInlineContent({
type: 'interlinkingLinkInline',
props: {
docId: '',
title: '',
},
});
return <LinkSelected {...inlineContent.props} />;
return null;
}
return (
<LinkSelected
docId={inlineContent.props.docId}
title={inlineContent.props.title}
isEditable={editor.isEditable}
updateInlineContent={updateInlineContent}
/>
);
},
},
);
interface LinkSelectedProps {
url: string;
docId: string;
title: string;
isEditable: boolean;
updateInlineContent: (
update: PartialCustomInlineContentFromConfig<
{
readonly type: 'interlinkingLinkInline';
readonly propSchema: {
readonly docId: {
readonly default: '';
};
readonly title: {
readonly default: '';
};
};
readonly content: 'none';
},
StyleSchema
>,
) => void;
}
const LinkSelected = ({ url, title }: LinkSelectedProps) => {
export const LinkSelected = ({
docId,
title,
isEditable,
updateInlineContent,
}: LinkSelectedProps) => {
const { data: doc } = useDoc({ id: docId });
/**
* Update the content title if the referenced doc title changes
*/
useEffect(() => {
if (isEditable && doc?.title && doc.title !== title) {
updateInlineContent({
type: 'interlinkingLinkInline',
props: {
docId,
title: doc.title,
},
});
}
/**
* ⚠️ When doing collaborative editing, doc?.title might be out of sync
* causing an infinite loop of updates.
* To prevent this, we only run this effect when doc?.title changes,
* not when inlineContent.props.title changes.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doc?.title, docId, isEditable]);
const { colorsTokens } = useCunninghamTheme();
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title);
@@ -74,7 +127,7 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
void router.push(url);
void router.push(`/docs/${docId}/`);
};
return (

View File

@@ -247,7 +247,6 @@ export const SearchPage = ({
{
type: 'interlinkingLinkInline',
props: {
url: `/docs/${doc.id}`,
docId: doc.id,
title: doc.title || untitledDocument,
},

View File

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

View File

@@ -180,9 +180,6 @@ export const cssEditor = css`
& .bn-editor {
padding-right: 36px;
}
& .bn-toolbar {
max-width: 100vw;
}
}
@media screen and (width <= 560px) {

View File

@@ -1,4 +1,4 @@
import { deriveMediaFilename } from '../utils';
import { deriveMediaFilename } from '../utils_html';
describe('deriveMediaFilename', () => {
test('uses last URL segment when src is a valid URL', () => {

View File

@@ -184,6 +184,75 @@ s {
margin: 0;
}
/* Remove bullet points from checkbox lists */
ul.checklist,
ul:has(li input[type='checkbox']) {
list-style: none;
padding-left: 0;
margin-left: 0;
}
ul.checklist li,
ul:has(li input[type='checkbox']) li {
list-style: none;
display: flex;
align-items: center;
gap: 8px;
}
ul.checklist li input[type='checkbox'],
ul:has(li input[type='checkbox']) li input[type='checkbox'] {
margin: 0;
width: 16px;
height: 16px;
cursor: pointer;
flex-shrink: 0;
}
ul.checklist li p,
ul:has(li input[type='checkbox']) li p {
margin: 0;
flex: 1;
}
/* Native HTML Lists - remove default margins */
ol,
ul {
margin: 0;
padding-left: 24px;
}
ol {
list-style-type: decimal;
}
ul {
list-style-type: disc;
}
/* Nested lists */
ul ul {
list-style-type: circle;
}
/* Keep decimal numbering for nested ol (remove this if you want letters) */
ol ol {
list-style-type: decimal;
}
li {
margin: 0;
padding: 0;
line-height: 24px;
}
li p {
margin: 0;
display: inline;
}
/* Quotes */
blockquote,
.bn-block-content[data-content-type='quote'] blockquote {

View File

@@ -1,3 +1,7 @@
/**
* Derivated from Blockquote PDF mapping
* @see: https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx
*/
import { Text } from '@react-pdf/renderer';
import { DocsExporterPDF } from '../types';
@@ -30,6 +34,15 @@ export const blockMappingHeadingPDF: DocsExporterPDF['mappings']['blockMapping']
const fontSizeEM =
block.props.level === 1 ? 2 : block.props.level === 2 ? 1.5 : 1.17;
const levelFontSizeEM = {
1: 2,
2: 1.5,
3: 1.17,
4: 1,
5: 0.83,
6: 0.67,
}[block.props.level as 1 | 2 | 3 | 4 | 5 | 6];
// Extract plain text for bookmark title
const bookmarkTitle =
extractTextFromBlockContent(block.content) || 'Untitled';
@@ -42,7 +55,7 @@ export const blockMappingHeadingPDF: DocsExporterPDF['mappings']['blockMapping']
title: bookmarkTitle,
}}
style={{
fontSize: fontSizeEM * FONT_SIZE * PIXELS_PER_POINT,
fontSize: levelFontSizeEM * FONT_SIZE * PIXELS_PER_POINT,
fontWeight: 700,
marginTop: `${fontSizeEM * MERGE_RATIO}px`,
marginBottom: `${fontSizeEM * MERGE_RATIO}px`,

View File

@@ -21,9 +21,6 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping']['
if (blob.type.includes('svg')) {
const svgText = await blob.text();
const FALLBACK_SIZE = 536;
previewWidth = previewWidth || FALLBACK_SIZE;
const result = await convertSvgToPng(svgText, previewWidth);
pngConverted = result.png;
dimensions = { width: result.width, height: result.height };

View File

@@ -9,7 +9,7 @@ import {
Select,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
} from '@gouvfr-lasuite/cunningham-react';
import { DocumentProps, pdf } from '@react-pdf/renderer';
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
import i18next from 'i18next';
@@ -29,11 +29,12 @@ import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { docxDocsSchemaMappings } from '../mappingDocx';
import { odtDocsSchemaMappings } from '../mappingODT';
import { pdfDocsSchemaMappings } from '../mappingPDF';
import { downloadFile } from '../utils';
import {
addMediaFilesToZip,
downloadFile,
generateHtmlDocument,
} from '../utils';
improveHtmlAccessibility,
} from '../utils_html';
enum DocDownloadFormat {
HTML = 'html',
@@ -161,10 +162,12 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const zip = new JSZip();
improveHtmlAccessibility(parsedDocument, documentTitle);
await addMediaFilesToZip(parsedDocument, zip, mediaUrl);
const lang = i18next.language || fallbackLng;
const editorHtmlWithLocalMedia = parsedDocument.body.innerHTML;
const body = parsedDocument.body;
const editorHtmlWithLocalMedia = body ? body.innerHTML : '';
const htmlContent = generateHtmlDocument(
documentTitle,

View File

@@ -5,6 +5,7 @@
*/
export * from './api';
export * from './utils';
export * from './utils_html';
import * as ModalExport from './components/ModalExport';

View File

@@ -1,16 +1,24 @@
import { ExternalHyperlink, TextRun } from 'docx';
import { getEmojiAndTitle } from '@/docs/doc-management';
import { DocsExporterDocx } from '../types';
export const inlineContentMappingInterlinkingLinkDocx: DocsExporterDocx['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
(inline) => {
if (!inline.props.docId) {
return new TextRun('');
}
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title);
return new ExternalHyperlink({
children: [
new TextRun({
text: `📄${inline.props.title}`,
text: `${emoji || '📄'}${titleWithoutEmoji}`,
bold: true,
}),
],
link: window.location.origin + inline.props.url,
link: window.location.origin + `/docs/${inline.props.docId}/`,
});
};

View File

@@ -1,11 +1,17 @@
import React from 'react';
import { getEmojiAndTitle } from '@/docs/doc-management';
import { DocsExporterODT } from '../types';
export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
(inline) => {
const url = window.location.origin + inline.props.url;
const title = inline.props.title;
if (!inline.props.docId) {
return null;
}
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title);
const url = window.location.origin + `/docs/${inline.props.docId}/`;
// Create ODT hyperlink using React.createElement to avoid TypeScript JSX namespace issues
// Uses the same structure as BlockNote's default link mapping
@@ -18,6 +24,6 @@ export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings'
xlinkShow: 'replace',
xlinkHref: url,
},
`📄${title}`,
`${emoji || '📄'}${titleWithoutEmoji}`,
);
};

View File

@@ -1,21 +1,29 @@
import { Image, Link, Text } from '@react-pdf/renderer';
import { getEmojiAndTitle } from '@/docs/doc-management';
import DocSelectedIcon from '../assets/doc-selected.png';
import { DocsExporterPDF } from '../types';
export const inlineContentMappingInterlinkingLinkPDF: DocsExporterPDF['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
(inline) => {
if (!inline.props.docId) {
return <></>;
}
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title);
return (
<Link
src={window.location.origin + inline.props.url}
src={window.location.origin + `/docs/${inline.props.docId}/`}
style={{
textDecoration: 'none',
color: 'black',
}}
>
{' '}
<Image src={DocSelectedIcon.src} />{' '}
<Text>{inline.props.title}</Text>{' '}
{emoji || <Image src={DocSelectedIcon.src} />}{' '}
<Text>{titleWithoutEmoji}</Text>{' '}
</Link>
);
};

View File

@@ -5,11 +5,8 @@ import {
} from '@blocknote/core';
import { Canvg } from 'canvg';
import { IParagraphOptions, ShadingType } from 'docx';
import JSZip from 'jszip';
import React from 'react';
import { exportResolveFileUrl } from './api';
export function downloadFile(blob: Blob, filename: string) {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -36,7 +33,7 @@ export function downloadFile(blob: Blob, filename: string) {
*/
export async function convertSvgToPng(
svgText: string,
width: number,
width?: number,
): Promise<{ png: string; width: number; height: number }> {
// Create a canvas and render the SVG onto it
const canvas = document.createElement('canvas');
@@ -54,26 +51,36 @@ export async function convertSvgToPng(
const svgElement = svgDoc.documentElement;
// Get viewBox or fallback to width/height attributes
let height;
let calculatedHeight: number | undefined;
const svgWidth = svgElement.getAttribute?.('width');
const svgHeight = svgElement.getAttribute?.('height');
const viewBox = svgElement.getAttribute('viewBox')?.split(' ').map(Number);
const originalWidth = svgWidth ? parseInt(svgWidth) : viewBox?.[2];
const originalHeight = svgHeight ? parseInt(svgHeight) : viewBox?.[3];
if (originalWidth && originalHeight) {
const aspectRatio = originalHeight / originalWidth;
height = Math.round(width * aspectRatio);
}
const svg = Canvg.fromString(ctx, svgText);
svg.resize(width, height, true);
const FALLBACK_WIDTH = 536;
// Resize if width provided, preserving aspect ratio
if (originalWidth && originalHeight && width) {
const aspectRatio = originalHeight / originalWidth;
calculatedHeight = Math.round(width * aspectRatio);
svg.resize(width, calculatedHeight, true);
} else if (!width && !originalWidth) {
svg.resize(FALLBACK_WIDTH, undefined, true);
}
await svg.render();
const returnWidth = width || originalWidth || FALLBACK_WIDTH;
const returnHeight = calculatedHeight || returnWidth;
return {
png: canvas.toDataURL('image/png'),
width,
height: height || width,
width: returnWidth,
height: returnHeight,
};
}
@@ -182,172 +189,3 @@ export function odtRegisterParagraphStyleForBlock(
return styleName;
}
// Escape user-provided text before injecting it into the exported HTML document.
export const escapeHtml = (value: string): string =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
interface MediaFilenameParams {
src: string;
index: number;
blob: Blob;
}
/**
* Derives a stable, readable filename for media exported in the HTML ZIP.
*
* Rules:
* - Default base name is "media-{index+1}".
* - For non data: URLs, we reuse the last path segment when possible (e.g. 1-photo.png).
* - If the base name has no extension, we try to infer one from the blob MIME type.
*/
export const deriveMediaFilename = ({
src,
index,
blob,
}: MediaFilenameParams): string => {
// Default base name
let baseName = `media-${index + 1}`;
// Try to reuse the last path segment for non data URLs.
if (!src.startsWith('data:')) {
try {
const url = new URL(src, window.location.origin);
const lastSegment = url.pathname.split('/').pop();
if (lastSegment) {
baseName = `${index + 1}-${lastSegment}`;
}
} catch {
// Ignore invalid URLs, keep default baseName.
}
}
let filename = baseName;
// Ensure the filename has an extension consistent with the blob MIME type.
const mimeType = blob.type;
if (mimeType && !baseName.includes('.')) {
const slashIndex = mimeType.indexOf('/');
const rawSubtype =
slashIndex !== -1 && slashIndex < mimeType.length - 1
? mimeType.slice(slashIndex + 1)
: '';
let extension = '';
const subtype = rawSubtype.toLowerCase();
if (subtype.includes('svg')) {
extension = 'svg';
} else if (subtype.includes('jpeg') || subtype.includes('pjpeg')) {
extension = 'jpg';
} else if (subtype.includes('png')) {
extension = 'png';
} else if (subtype.includes('gif')) {
extension = 'gif';
} else if (subtype.includes('webp')) {
extension = 'webp';
} else if (subtype.includes('pdf')) {
extension = 'pdf';
} else if (subtype) {
extension = subtype.split('+')[0];
}
if (extension) {
filename = `${baseName}.${extension}`;
}
}
return filename;
};
/**
* Generates a complete HTML document structure for export.
*
* @param documentTitle - The title of the document (will be escaped)
* @param editorHtmlWithLocalMedia - The HTML content from the editor
* @param lang - The language code for the document (e.g., 'fr', 'en')
* @returns A complete HTML5 document string
*/
export const generateHtmlDocument = (
documentTitle: string,
editorHtmlWithLocalMedia: string,
lang: string,
): string => {
return `<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(documentTitle)}</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main role="main">
${editorHtmlWithLocalMedia}
</main>
</body>
</html>`;
};
export const addMediaFilesToZip = async (
parsedDocument: Document,
zip: JSZip,
mediaUrl: string,
) => {
const mediaFiles: { filename: string; blob: Blob }[] = [];
const mediaElements = Array.from(
parsedDocument.querySelectorAll<
HTMLImageElement | HTMLVideoElement | HTMLAudioElement | HTMLSourceElement
>('img, video, audio, source'),
);
await Promise.all(
mediaElements.map(async (element, index) => {
const src = element.getAttribute('src');
if (!src) {
return;
}
// data: URLs are already embedded and work offline; no need to create separate files.
if (src.startsWith('data:')) {
return;
}
// Only download same-origin resources (internal media like /media/...).
// External URLs keep their original src and are not included in the ZIP
let url: URL | null = null;
try {
url = new URL(src, mediaUrl);
} catch {
url = null;
}
if (!url || url.origin !== mediaUrl) {
return;
}
const fetched = await exportResolveFileUrl(url.href);
if (!(fetched instanceof Blob)) {
return;
}
const filename = deriveMediaFilename({
src: url.href,
index,
blob: fetched,
});
element.setAttribute('src', filename);
mediaFiles.push({ filename, blob: fetched });
}),
);
mediaFiles.forEach(({ filename, blob }) => {
zip.file(filename, blob);
});
};

View File

@@ -0,0 +1,434 @@
import JSZip from 'jszip';
import { exportResolveFileUrl } from './api';
// Escape user-provided text before injecting it into the exported HTML document.
export const escapeHtml = (value: string): string =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
/**
* Derives a stable, readable filename for media exported in the HTML ZIP.
*
* Rules:
* - Default base name is "media-{index+1}".
* - For non data: URLs, we reuse the last path segment when possible (e.g. 1-photo.png).
* - If the base name has no extension, we try to infer one from the blob MIME type.
*/
interface MediaFilenameParams {
src: string;
index: number;
blob: Blob;
}
export const deriveMediaFilename = ({
src,
index,
blob,
}: MediaFilenameParams): string => {
// Default base name
let baseName = `media-${index + 1}`;
// Try to reuse the last path segment for non data URLs.
if (!src.startsWith('data:')) {
try {
const url = new URL(src, window.location.origin);
const lastSegment = url.pathname.split('/').pop();
if (lastSegment) {
baseName = `${index + 1}-${lastSegment}`;
}
} catch {
// Ignore invalid URLs, keep default baseName.
}
}
let filename = baseName;
// Ensure the filename has an extension consistent with the blob MIME type.
const mimeType = blob.type;
if (mimeType && !baseName.includes('.')) {
const slashIndex = mimeType.indexOf('/');
const rawSubtype =
slashIndex !== -1 && slashIndex < mimeType.length - 1
? mimeType.slice(slashIndex + 1)
: '';
let extension = '';
const subtype = rawSubtype.toLowerCase();
if (subtype.includes('svg')) {
extension = 'svg';
} else if (subtype.includes('jpeg') || subtype.includes('pjpeg')) {
extension = 'jpg';
} else if (subtype.includes('png')) {
extension = 'png';
} else if (subtype.includes('gif')) {
extension = 'gif';
} else if (subtype.includes('webp')) {
extension = 'webp';
} else if (subtype.includes('pdf')) {
extension = 'pdf';
} else if (subtype) {
extension = subtype.split('+')[0];
}
if (extension) {
filename = `${baseName}.${extension}`;
}
}
return filename;
};
/**
* Generates a complete HTML document structure for export.
*
* @param documentTitle - The title of the document (will be escaped)
* @param editorHtmlWithLocalMedia - The HTML content from the editor
* @param lang - The language code for the document (e.g., 'fr', 'en')
* @returns A complete HTML5 document string
*/
export const generateHtmlDocument = (
documentTitle: string,
editorHtmlWithLocalMedia: string,
lang: string,
): string => {
return `<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(documentTitle)}</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main role="main">
${editorHtmlWithLocalMedia}
</main>
</body>
</html>`;
};
/**
* Enrich the HTML produced by the editor with semantic tags and basic a11y defaults.
*
* Notes:
* - We work directly on the parsed Document so modifications are reflected before we zip files.
* - We keep the editor inner structure but upgrade the key block types to native elements.
*/
export const improveHtmlAccessibility = (
parsedDocument: Document,
documentTitle: string,
) => {
const body = parsedDocument.body;
if (!body) {
return;
}
// 1) Headings: convert heading blocks to h1-h6 based on data-level
const headingBlocks = Array.from(
body.querySelectorAll<HTMLElement>("[data-content-type='heading']"),
);
headingBlocks.forEach((block) => {
const rawLevel = Number(block.getAttribute('data-level')) || 1;
const level = Math.min(Math.max(rawLevel, 1), 6);
const heading = parsedDocument.createElement(`h${level}`);
heading.innerHTML = block.innerHTML;
block.replaceWith(heading);
});
// 2) Lists: convert to semantic OL/UL/LI elements for accessibility
const listItemSelector =
"[data-content-type='bulletListItem'], [data-content-type='numberedListItem']";
// Helper function to get nesting level by counting block-group ancestors
const getNestingLevel = (blockOuter: HTMLElement): number => {
let level = 0;
let parent = blockOuter.parentElement;
while (parent) {
if (parent.classList.contains('bn-block-group')) {
level++;
}
parent = parent.parentElement;
}
return level;
};
// Find all block-outer elements in document order
const allBlockOuters = Array.from(
body.querySelectorAll<HTMLElement>('.bn-block-outer'),
);
// Collect list items with their info before modifying DOM
interface ListItemInfo {
blockOuter: HTMLElement;
listItem: HTMLElement;
contentType: string;
level: number;
}
const listItemsInfo: ListItemInfo[] = [];
allBlockOuters.forEach((blockOuter) => {
const listItem = blockOuter.querySelector<HTMLElement>(listItemSelector);
if (listItem) {
const contentType = listItem.getAttribute('data-content-type');
if (contentType) {
const level = getNestingLevel(blockOuter);
listItemsInfo.push({
blockOuter,
listItem,
contentType,
level,
});
}
}
});
// Stack to track lists at each nesting level
const listStack: Array<{ list: HTMLElement; type: string; level: number }> =
[];
listItemsInfo.forEach((info, idx) => {
const { blockOuter, listItem, contentType, level } = info;
const isBullet = contentType === 'bulletListItem';
const listTag = isBullet ? 'ul' : 'ol';
// Check if previous item continues the same list (same type and level)
const previousInfo = idx > 0 ? listItemsInfo[idx - 1] : null;
const continuesPreviousList =
previousInfo &&
previousInfo.contentType === contentType &&
previousInfo.level === level;
// Find or create the appropriate list
let targetList: HTMLElement | null = null;
if (continuesPreviousList) {
// Continue with the list at this level from stack
const listAtLevel = listStack.find((item) => item.level === level);
targetList = listAtLevel?.list || null;
}
// If no list found, create a new one
if (!targetList) {
targetList = parsedDocument.createElement(listTag);
// Remove lists from stack that are at same or deeper level
while (
listStack.length > 0 &&
listStack[listStack.length - 1].level >= level
) {
listStack.pop();
}
// If we have a parent list, nest this list inside its last li
if (
listStack.length > 0 &&
listStack[listStack.length - 1].level < level
) {
const parentList = listStack[listStack.length - 1].list;
const lastLi = parentList.querySelector('li:last-child');
if (lastLi) {
lastLi.appendChild(targetList);
} else {
// No li yet, create one and add the nested list
const li = parsedDocument.createElement('li');
parentList.appendChild(li);
li.appendChild(targetList);
}
} else {
// Top-level list
blockOuter.parentElement?.insertBefore(targetList, blockOuter);
}
// Add to stack
listStack.push({ list: targetList, type: contentType, level });
}
// Create list item and add content
const li = parsedDocument.createElement('li');
li.innerHTML = listItem.innerHTML;
targetList.appendChild(li);
// Remove original block-outer
blockOuter.remove();
});
// 3) Quotes -> <blockquote>
const quoteBlocks = Array.from(
body.querySelectorAll<HTMLElement>("[data-content-type='quote']"),
);
quoteBlocks.forEach((block) => {
const quote = parsedDocument.createElement('blockquote');
quote.innerHTML = block.innerHTML;
block.replaceWith(quote);
});
// 4) Callouts -> <aside role="note">
const calloutBlocks = Array.from(
body.querySelectorAll<HTMLElement>("[data-content-type='callout']"),
);
calloutBlocks.forEach((block) => {
const aside = parsedDocument.createElement('aside');
aside.setAttribute('role', 'note');
aside.innerHTML = block.innerHTML;
block.replaceWith(aside);
});
// 5) Checklists -> list + checkbox semantics
const checkListItems = Array.from(
body.querySelectorAll<HTMLElement>("[data-content-type='checkListItem']"),
);
checkListItems.forEach((item) => {
const parent = item.parentElement;
if (!parent) {
return;
}
let previousSibling = item.previousElementSibling;
let listContainer: HTMLElement | null = null;
if (previousSibling?.tagName.toLowerCase() === 'ul') {
listContainer = previousSibling as HTMLElement;
} else {
listContainer = parsedDocument.createElement('ul');
listContainer.setAttribute('role', 'list');
listContainer.classList.add('checklist');
parent.insertBefore(listContainer, item);
}
const li = parsedDocument.createElement('li');
li.innerHTML = item.innerHTML;
// Ensure checkbox has an accessible state; fall back to aria-checked if missing.
const checkbox = li.querySelector<HTMLInputElement>(
"input[type='checkbox']",
);
if (checkbox && !checkbox.hasAttribute('aria-checked')) {
checkbox.setAttribute(
'aria-checked',
checkbox.checked ? 'true' : 'false',
);
}
listContainer.appendChild(li);
parent.removeChild(item);
});
// 6) Code blocks -> <pre><code>
const codeBlocks = Array.from(
body.querySelectorAll<HTMLElement>("[data-content-type='codeBlock']"),
);
codeBlocks.forEach((block) => {
const pre = parsedDocument.createElement('pre');
const code = parsedDocument.createElement('code');
// Preserve existing classes/attributes so the exported CSS (dark theme) still applies.
pre.className = block.className || '';
pre.setAttribute('data-content-type', 'codeBlock');
// Copy other data attributes from the original block to the new <pre>.
Array.from(block.attributes).forEach((attr) => {
if (attr.name.startsWith('data-') && attr.name !== 'data-content-type') {
pre.setAttribute(attr.name, attr.value);
}
});
// Move content inside <code>.
code.innerHTML = block.innerHTML;
pre.appendChild(code);
block.replaceWith(pre);
});
// 7) Ensure images have alt text (empty when not provided)
body.querySelectorAll<HTMLImageElement>('img').forEach((img) => {
if (!img.hasAttribute('alt')) {
img.setAttribute('alt', '');
}
});
// 8) Wrap content in an article with a title landmark if none exists
const existingH1 = body.querySelector('h1');
if (!existingH1) {
const titleHeading = parsedDocument.createElement('h1');
titleHeading.id = 'doc-title';
titleHeading.textContent = documentTitle;
body.insertBefore(titleHeading, body.firstChild);
}
// If there is no article, group the body content inside one for better semantics.
const hasArticle = body.querySelector('article');
if (!hasArticle) {
const article = parsedDocument.createElement('article');
article.setAttribute('role', 'document');
article.setAttribute('aria-labelledby', 'doc-title');
while (body.firstChild) {
article.appendChild(body.firstChild);
}
body.appendChild(article);
}
};
export const addMediaFilesToZip = async (
parsedDocument: Document,
zip: JSZip,
mediaUrl: string,
) => {
const mediaFiles: { filename: string; blob: Blob }[] = [];
const mediaElements = Array.from(
parsedDocument.querySelectorAll<
HTMLImageElement | HTMLVideoElement | HTMLAudioElement | HTMLSourceElement
>('img, video, audio, source'),
);
await Promise.all(
mediaElements.map(async (element, index) => {
const src = element.getAttribute('src');
if (!src) {
return;
}
// data: URLs are already embedded and work offline; no need to create separate files.
if (src.startsWith('data:')) {
return;
}
// Only download same-origin resources (internal media like /media/...).
// External URLs keep their original src and are not included in the ZIP
let url: URL | null = null;
try {
url = new URL(src, mediaUrl);
} catch {
url = null;
}
if (!url || url.origin !== mediaUrl) {
return;
}
const fetched = await exportResolveFileUrl(url.href);
if (!(fetched instanceof Blob)) {
return;
}
const filename = deriveMediaFilename({
src: url.href,
index,
blob: fetched,
});
element.setAttribute('src', filename);
mediaFiles.push({ filename, blob: fetched });
}),
);
mediaFiles.forEach(({ filename, blob }) => {
zip.file(filename, blob);
});
};

View File

@@ -1,4 +1,4 @@
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { t } from 'i18next';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -1,9 +1,9 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import {
Button,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
} from '@gouvfr-lasuite/cunningham-react';
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { useTranslation } from 'react-i18next';
import { Box, Card, Icon } from '@/components';

View File

@@ -1,5 +1,5 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Button } from '@openfun/cunningham-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';

View File

@@ -1,4 +1,4 @@
import { Tooltip } from '@openfun/cunningham-react';
import { Tooltip } from '@gouvfr-lasuite/cunningham-react';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';

View File

@@ -1,5 +1,5 @@
import { Button, useModal } from '@gouvfr-lasuite/cunningham-react';
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Button, useModal } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';

View File

@@ -1,4 +1,7 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import {
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { useEditorStore } from '../../doc-editor';

View File

@@ -1,4 +1,7 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import {
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import {
UseMutationOptions,
useMutation,

View File

@@ -1,10 +1,27 @@
import { MouseEvent, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { BoxButton, BoxButtonType, Text, TextType } from '@/components';
import { EmojiPicker, emojidata } from '@/docs/doc-editor/';
import {
Box,
BoxButton,
BoxButtonType,
EmojiPicker,
PICKER_HEIGHT,
Text,
TextType,
emojidata,
} from '@/components';
import { useDocTitleUpdate } from '../hooks/useDocTitleUpdate';
import { cssSelectors } from '../utils';
const getClosestTreeItem = (element: HTMLElement | null) =>
element?.closest<HTMLElement>(cssSelectors.DOC_TREE_ROW) ??
element?.closest<HTMLElement>(cssSelectors.DOC_TREE_NODE) ??
element?.closest<HTMLElement>('[role="treeitem"]') ??
null;
type DocIconProps = TextType & {
buttonProps?: BoxButtonType;
@@ -30,6 +47,7 @@ export const DocIcon = ({
...textProps
}: DocIconProps) => {
const { updateDocEmoji } = useDocTitleUpdate();
const { t } = useTranslation();
const iconRef = useRef<HTMLDivElement>(null);
@@ -43,6 +61,14 @@ export const DocIcon = ({
return defaultIcon;
}
const emojiLabel = withEmojiPicker
? emoji
? t('Edit document emoji')
: t('Add emoji')
: emoji
? t('Document emoji')
: undefined;
const toggleEmojiPicker = (e: MouseEvent) => {
if (withEmojiPicker) {
e.stopPropagation();
@@ -50,9 +76,24 @@ export const DocIcon = ({
if (!openEmojiPicker && iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
const pickerHeight = PICKER_HEIGHT;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
// Position picker above if not enough space below and enough space above
const shouldPositionAbove =
spaceBelow < pickerHeight && spaceAbove >= pickerHeight;
// Offset to align the picker properly
const ROW_OFFSET_TOP = 55;
const ROW_OFFSET_BOTTOM = 10;
setPickerPosition({
top: rect.bottom + window.scrollY + 8,
left: rect.left + window.scrollX,
top: shouldPositionAbove
? rect.top - pickerHeight + ROW_OFFSET_TOP
: rect.bottom + ROW_OFFSET_BOTTOM,
left: rect.left,
});
}
@@ -76,6 +117,30 @@ export const DocIcon = ({
setOpenEmojiPicker(false);
};
const handleEscape = () => {
setOpenEmojiPicker(false);
window.requestAnimationFrame(() => {
const localTreeItem = getClosestTreeItem(iconRef.current);
const docTree = document.querySelector<HTMLElement>(
cssSelectors.DOC_TREE,
);
const docTreeItem =
localTreeItem ||
docTree?.querySelector<HTMLElement>(
cssSelectors.DOC_TREE_FOCUSED_NODE,
) ||
docTree?.querySelector<HTMLElement>(
cssSelectors.DOC_TREE_SELECTED_ROW,
) ||
docTree?.querySelector<HTMLElement>(
cssSelectors.DOC_TREE_SELECTED_NODE,
) ||
document.querySelector<HTMLElement>(cssSelectors.DOC_TREE_ROOT);
docTreeItem?.focus();
});
};
return (
<>
<BoxButton
@@ -83,6 +148,8 @@ export const DocIcon = ({
ref={iconRef}
onClick={toggleEmojiPicker}
color="tertiary-text"
aria-label={emojiLabel}
title={emojiLabel}
{...buttonProps}
>
{!emoji ? (
@@ -102,21 +169,22 @@ export const DocIcon = ({
</BoxButton>
{openEmojiPicker &&
createPortal(
<div
style={{
position: 'absolute',
top: pickerPosition.top,
left: pickerPosition.left,
zIndex: 1000,
}}
<Box
$position="fixed"
$css={css`
top: ${pickerPosition.top}px;
left: ${pickerPosition.left}px;
z-index: 1000;
`}
>
<EmojiPicker
emojiData={emojidata}
onEmojiSelect={handleEmojiSelect}
onClickOutside={handleClickOutside}
withOverlay={true}
onEscape={handleEscape}
/>
</div>,
</Box>,
document.body,
)}
</>

View File

@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import Head from 'next/head';
import Image from 'next/image';
import { useEffect } from 'react';

View File

@@ -5,7 +5,7 @@ import {
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
} from '@gouvfr-lasuite/cunningham-react';
import { useRouter } from 'next/router';
import { useEffect, useRef } from 'react';
import { Trans, useTranslation } from 'react-i18next';

View File

@@ -1,6 +1,7 @@
export * from './useCollaboration';
export * from './useCopyDocLink';
export * from './useCreateChildDocTree';
export * from './useDocFocusManagement';
export * from './useDocTitleUpdate';
export * from './useDocUtils';
export * from './useIsCollaborativeEditable';

View File

@@ -0,0 +1,91 @@
import { useEffect } from 'react';
import { cssSelectors } from '@/docs/doc-management/utils';
const isWithin = (el: Element | null, selector: string) =>
!!el?.closest(selector);
export const useDocFocusManagement = (docId?: string, isReady = true) => {
// 1) Auto-focus title when opening a doc
useEffect(() => {
if (!docId || !isReady || typeof window === 'undefined') {
return;
}
const frameId = window.requestAnimationFrame(() => {
const titleElement = document.querySelector<HTMLElement>(
cssSelectors.DOC_TITLE,
);
if (!titleElement) {
return;
}
// Avoid stealing focus if user is already in the doc tree or editor.
const activeEl = document.activeElement;
const active = activeEl instanceof Element ? activeEl : null;
const isInDocUI =
isWithin(active, cssSelectors.DOC_EDITOR_FOCUS) ||
isWithin(active, cssSelectors.DOC_TREE);
const isBodyFocused = activeEl === document.body;
if (isBodyFocused && !isInDocUI && activeEl !== titleElement) {
titleElement.focus();
}
});
return () => window.cancelAnimationFrame(frameId);
}, [docId, isReady]);
// 2) Escape from editor/title -> focus back the selected tree item (or root)
useEffect(() => {
if (!docId || !isReady || typeof window === 'undefined') {
return;
}
const handleFocusShortcut = (event: KeyboardEvent) => {
if (event.key !== 'F6' || event.defaultPrevented) {
return;
}
const target = event.target instanceof Element ? event.target : null;
const activeEl = document.activeElement;
const active = activeEl instanceof Element ? activeEl : null;
const isDocFocus =
isWithin(target, cssSelectors.DOC_EDITOR_FOCUS) ||
isWithin(active, cssSelectors.DOC_EDITOR_FOCUS) ||
isWithin(target, cssSelectors.DOC_TITLE) ||
isWithin(active, cssSelectors.DOC_TITLE);
if (!isDocFocus) {
return;
}
const docTree = document.querySelector<HTMLElement>(
cssSelectors.DOC_TREE,
);
const docTreeItem =
docTree?.querySelector<HTMLElement>(
cssSelectors.DOC_TREE_SELECTED_ROW,
) ||
docTree?.querySelector<HTMLElement>(
cssSelectors.DOC_TREE_SELECTED_NODE,
) ||
document.querySelector<HTMLElement>(cssSelectors.DOC_TREE_ROOT);
if (!docTreeItem) {
return;
}
docTreeItem.focus();
event.preventDefault();
event.stopPropagation();
};
document.addEventListener('keydown', handleFocusShortcut, true);
return () =>
document.removeEventListener('keydown', handleFocusShortcut, true);
}, [docId, isReady]);
};

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