Compare commits

...

54 Commits

Author SHA1 Message Date
Fabre Florian c6ed7a79f8 WIP (backend) use markdown as input data for indexation
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-18 06:52:31 +01:00
Fabre Florian f2106dd880 (backend) adapt to Find new search pagination
Use nb_results instead of page/page_size argument for /search API.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 16:16:37 +01:00
Fabre Florian 3652732852 🩹(backend) fix empty indexation batch
As we filter the empty documents from the batch during indexing some batches
can be empty and cause an error. Now they are ignored.
Add --batch-size argument to the index command.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:38:20 +01:00
Fabre Florian 5427f18ec0 (backend) use batches in indexing task
Reduce the number of Find API calls by grouping all the latest changes
for indexation : send all the documents updated or deleted since the
triggering of the task.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:38:01 +01:00
Fabre Florian a83f140510 📝(backend) add fulltext search documentation
Add documentation for env & Find+Docs configuration in dev mode

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:38:01 +01:00
Fabre Florian 78e2bc690e 🔧(compose) disable indexer in default configuration
Set SEARCH_INDEXER_CLASS=None as default configuration for dev.
Rename docker network 'lasuite-net' as 'lasuite' to match with Drive
configuration.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:38:01 +01:00
Fabre Florian 4974b5cf31 (backend) keep ordering from fulltext search in results
Keep ordering by score from Find API on search/ results and
fallback search still uses "-update_at" ordering as default

Refactor pagination to work with a list instead of a queryset

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:38:00 +01:00
Fabre Florian 337129cb48 (backend) throttle indexation tasks instead of debounce (simplier)
Replace indexer_debounce_lock|release functions by indexer_throttle_acquire()
Instead of mutex-like mechanism, simply set a flag in cache for an amount of
time that prevents any other task creation.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:38:00 +01:00
Fabre Florian 33f1555377 (backend) some refactor of indexer classes & modules
Rename FindDocumentIndexer as SearchIndexer
Rename FindDocumentSerializer as SearchDocumentSerializer
Rename package core.tasks.find as core.task.search
Remove logs on http errors in SearchIndexer
Factorise some code in search API view.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:37:59 +01:00
Fabre Florian bc895512b4 🔧(backend) setup Docs app dockers to work with Find
Add nginx with 'nginx' alias to the 'lasuite-net' network (keycloak calls)
Add celery-dev to the 'lasuite-net' network (Find API calls in jobs)
Set app-dev alias as 'impress' in the 'lasuite-net' network
Add indexer configuration in common settings

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:37:59 +01:00
Fabre Florian f9c0a2b3e5 🔧(backend) tool for valid fernet key used in OIDC token storage
Add bin/fernetkey that generates a key for the OIDC_STORE_REFRESH_TOKEN_KEY
setting.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:37:59 +01:00
Fabre Florian ad92bf614d (backend) Index deleted documents
Add SEARCH_INDEXER_COUNTDOWN as configurable setting.
Make the search backend creation simplier (only 'get_document_indexer' now).
Allow indexation of deleted documents.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:37:58 +01:00
Fabre Florian 57a6f73d9b (backend) Index partially empty documents
Only documents without title and content are ignored by indexer.
2025-11-14 13:37:58 +01:00
Fabre Florian 9525b97286 (backend) add fallback search & default ordering
Filter deleted documents from visited ones.
Set default ordering to the Find API search call (-updated_at)
BaseDocumentIndexer.search now returns a list of document ids instead of models.
Do not call the indexer in signals when SEARCH_INDEXER_CLASS is not defined
or properly configured.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:37:58 +01:00
Fabre Florian f49902a2b2 (backend) refactor indexation signals and fix circular import issues
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:37:57 +01:00
Fabre Florian ff08b3e977 (backend) improve search indexer service configuration
New SEARCH_INDEXER_CLASS setting to define the indexer service class.
Raise ImpoperlyConfigured errors instead of RuntimeError in index service.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:37:57 +01:00
Fabre Florian 1b36f6b729 (backend) add document search view
New API view that calls the indexed documents search view
(resource server) of app "Find".

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:37:56 +01:00
Fabre Florian 47fd1dfd11 (backend) add unit test for the 'index' command
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:37:56 +01:00
Fabre Florian 0145a0575f 🔧(compose) Add some ignore for docker-compose local overrides
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:37:56 +01:00
Samuel Paccoud - DINUM 1a9011f5fa (backend) add async triggers to enable document indexation with find
On document content or permission changes, start a celery job that will call the
indexation API of the app "Find".

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-14 13:37:55 +01:00
Samuel Paccoud - DINUM cb77f6803b (backend) add document search indexer
Add indexer that loops across documents in the database, formats them
as json objects and indexes them in the remote "Find" mico-service.
2025-11-14 13:37:55 +01:00
Samuel Paccoud - DINUM 254a5418da (backend) add dummy content to demo documents
We need to content in our demo documents so that we can test
indexing.
2025-11-14 13:37:55 +01:00
Samuel Paccoud - DINUM fd49f28449 🔧(compose) configure external network for communication with search
Search in Docs relies on an external project like "La Suite Find".
We need to declare a common external network in order to connect to
the search app and index our documents.
2025-11-14 13:37:54 +01:00
Cyril 82a0c1a770 (frontend) add unit test for mobile rendering in docheaderinfo
ensures numchild count is displayed correctly on mobile interface

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

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

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

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

(frontend) add image and interlinking support for odt export

Added image mapping with SVG conversion and clickable document links.

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

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

covers odt document export and cross-section interlinking use cases

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

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

create odtRegisterParagraphStyleForBlock and apply background/padding styles

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

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

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

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

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

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

Fixed:
- 🐛(frontend) fix duplicate document entries in grid
- 🐛(backend) fix trashbin list
- (frontend) improve accessibility:
  - (frontend) remove empty alt on logo due to Axe
  a11y error
- 🐛(backend) fix s3 version_id validation
- 🐛(frontend) retry check media status after page reload
- 🐛(frontend) fix Interlinking memory leak
- 🐛(frontend) button new doc UI fix
- 🐛(frontend) interlinking UI fix
2025-11-10 11:35:03 +01:00
AntoLC 6445c05e29 🌐(i18n) update translated strings
Update translated files with new translations
2025-11-10 11:35:03 +01:00
renovate[bot] b9b25eb1f6 ⬆️(dependencies) update django to v5.2.8 [SECURITY] 2025-11-10 11:19:53 +01:00
renovate[bot] de157b4f52 ⬆️(dependencies) update Brotli to v1.2.0 [SECURITY] 2025-11-10 11:19:53 +01:00
Anthony LC e5581e52f7 ♻️(frontend) better handling css doc states
We simplify the way we handle different doc
states (deleted / readonly) in the CSS, we avoid
props drilling and are more component focused.
2025-11-07 15:31:05 +01:00
Anthony LC b91840c819 🩹(frontend) set correctly query data when 401
When receiving a 401 error, we should set the
auth query data to null, not to an object
with user: null and authenticated: false.
This ensures that components relying on the
auth state can correctly interpret the
unauthenticated status.
2025-11-07 09:54:10 +01:00
Anthony LC a9b77fb9a7 💄(frontend) visual improvements around the Icon
With time some visual inconsistencies have crept
into the DropButton and Icon component.
This commit aims to harmonize the appearance
with the design system.
2025-11-07 09:25:48 +01:00
Anthony LC 66f83db0e5 (frontend) add "Add Emoji" button to doc options menu
- Add "Add Emoji" button to doc options menu
- Remove default emoji when none selected
- Improve doc options styling
2025-11-07 09:24:58 +01:00
Anthony LC f9ff578c6b 🥅(frontend) improve error handling during upload
Catch and log errors when replacing blocks during
file upload.
2025-11-06 13:21:36 +01:00
Anthony LC 1372438f8e 🐛(frontend) fix memory leak in Interlinking
When doing collaborative editing, doc?.title might
be out of sync for other users when updated by
another user.
This causes the useEffect to run repeatedly,
causing an infinite loop of updates.
We now trigger the effect only when doc?.title changes,
not when the customInlineContent changes.
2025-11-06 13:21:36 +01:00
Anthony LC c5d5d3dec4 ️(frontend) improve unsubscribe logic when unmounting components
We can now unsubscribe on the editor events,
improving performance and preventing memory leaks.
2025-11-06 13:21:35 +01:00
Anthony LC ad16c0843c (test) adapt tests with updated dependencies
- update e2e tests to match changed function signatures
- remove unused pdf-parse type definitions
- fix type error in hocuspocusWS tests
2025-11-06 12:43:34 +01:00
renovate[bot] 78a6307656 ⬆️(dependencies) update js dependencies 2025-11-05 16:17:04 +01:00
Anthony LC d7d468f51f ✏️(frontend) fix typo listInvalidQueries
In many places the property name "listInvalidQueries"
was misspelled.
2025-11-05 12:23:35 +01:00
Anthony LC eb71028f6b 🚸(frontend) separate viewers from editors
We are now totally separating the viewers with
the editors. We will not load the provider
when we are in viewer mode, meaning the
viewers will not be aware of other users and
will not show their cursors anymore.
We still get the document updates in real-time.
2025-11-05 12:23:35 +01:00
Anthony LC 39c22b074d 🚚(frontend) better separation concern doc-versioning features
We move some components related to doc versioning
into the doc-versioning feature folder to have a
better separation of concerns.
We don't need a provider for the doc versioning components
since they will receive the doc data directly via
a request.
2025-11-05 12:09:20 +01:00
Anthony LC d5c3f248a5 🐛(frontend) fix flaky test
Depend the month the test could fail if the current
month has 30 or 31 days, so change 30 to 35 to be sure
that it will always be at least 1 month ago.
2025-11-04 10:28:48 +01:00
Anthony LC 91217b3c4f 🐛(frontend) retry check media status after page reload
Previous refactoring removed the retry logic for
checking media status after a page reload.
This commit reintroduces that functionality
to ensure uploads are properly processed even after
a page reload. We improve the test coverage
to validate this behavior.
2025-11-04 10:28:48 +01:00
Manuel Raynaud ab271bc90d (backend) fix test_backend authentication test suite
The last version of django-lasuite introduces a modification on the
query made. We have to update the tests because there is one more sql
query made.
2025-10-30 15:30:12 +01:00
Manuel Raynaud 82e1783317 📌(backend) pint pylint to version < 4.0.0
django-pylint is not compatible with pylint 4. We have to pin the pylint
version while the compatibility is not effective.
See https://github.com/pylint-dev/pylint-django/issues/467
2025-10-30 15:30:12 +01:00
renovate[bot] aa2b9ed5f2 ⬆️(dependencies) update python dependencies 2025-10-30 15:30:12 +01:00
Manuel Raynaud 1c96d645ba 🐛(backend) fix s3 version_id validation
The regex used on the version_detail endpoint path is not fully
compatible with the S3 spec. In the S3 specs, Version IDs are Unicode,
UTF-8 encoded, URL-ready, opaque strings that are no more than 1,024
bytes long. We don't accept all unicode characters but enough to be
compliant.
2025-10-30 15:29:11 +01:00
131 changed files with 5856 additions and 2619 deletions
+4
View File
@@ -43,6 +43,10 @@ venv.bak/
env.d/development/*.local
env.d/terraform
# Docker
compose.override.yml
docker/auth/*.local
# npm
node_modules
+32 -4
View File
@@ -8,21 +8,45 @@ and this project adheres to
### Added
- ✨(frontend) enable ODT export for documents #1524
- ✨(frontend) improve mobile UX by showing subdocs count #1540
### Fixed
- ♿(frontend) improve accessibility:
- ♿(frontend) improve ARIA in doc grid and editor for a11y #1519
- ♿(frontend) improve accessibility and styling of summary table #1528
- ♿(frontend) add focus trap and enter key support to remove doc modal #1531
- 🐛(docx) fix image overflow by limiting width to 600px during export #1525
- 🐛(frontend) preserve @ character when esc is pressed after typing it #1512
- 🐛(frontend) fix pdf embed to use full width #1526
## [3.9.0] - 2025-11-10
### Added
- ✨(frontend) create skeleton component for DocEditor #1491
- ✨(frontend) add an EmojiPicker in the document tree and title #1381
- ✨(frontend) ajustable left panel #1456
### Changed
- ♻️(frontend) adapt custom blocks to new implementation #1375
- ♻️(backend) increase user short_name field length
- ♻️(backend) increase user short_name field length #1510
- 🚸(frontend) separate viewers from editors #1509
### Fixed
- 🐛(frontend) fix duplicate document entries in grid #1479
- 🐛(frontend) show full nested doc names with ajustable bar #1456
- 🐛(backend) fix trashbin list
- 🐛(backend) fix trashbin list #1520
- ♿(frontend) improve accessibility:
- ♿(frontend) remove empty alt on logo due to Axe a11y error #1516
- 🐛(backend) fix s3 version_id validation #1543
- 🐛(frontend) retry check media status after page reload #1555
- 🐛(frontend) fix Interlinking memory leak #1560
- 🐛(frontend) button new doc UI fix #1557
- 🐛(frontend) interlinking UI fix #1557
## [3.8.2] - 2025-10-17
@@ -73,6 +97,9 @@ and this project adheres to
- ♿ update labels and shared document icon accessibility #1442
- 🍱(frontend) Fonts GDPR compliants #1453
- ♻️(service-worker) improve SW registration and update handling #1473
- ✨(backend) add async indexation of documents on save (or access save) #1276
- ✨(backend) add debounce mechanism to limit indexation jobs #1276
- ✨(api) add API route to search for indexed documents in Find #1276
### Fixed
@@ -835,7 +862,8 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.8.2...main
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.9.0...main
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1
[v3.8.0]: https://github.com/suitenumerique/docs/releases/v3.8.0
+4
View File
@@ -247,6 +247,10 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
index: ## index all documents to remote search
@$(MANAGE) index
.PHONY: index
# Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources
lint: \
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# shellcheck source=bin/_config.sh
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run app-dev python -c 'from cryptography.fernet import Fernet;import sys; sys.stdout.write("\n" + Fernet.generate_key().decode() + "\n");'
+18
View File
@@ -72,6 +72,11 @@ services:
- env.d/development/postgresql.local
ports:
- "8071:8000"
networks:
default: {}
lasuite:
aliases:
- impress
volumes:
- ./src/backend:/app
- ./data/static:/data/static
@@ -92,6 +97,9 @@ services:
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "DEBUG"]
environment:
- DJANGO_CONFIGURATION=Development
networks:
- default
- lasuite
env_file:
- env.d/development/common
- env.d/development/common.local
@@ -107,6 +115,11 @@ services:
image: nginx:1.25
ports:
- "8083:8083"
networks:
default: {}
lasuite:
aliases:
- nginx
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
@@ -217,3 +230,8 @@ services:
kc_postgresql:
condition: service_healthy
restart: true
networks:
lasuite:
name: lasuite-network
driver: bridge
+1
View File
@@ -12,6 +12,7 @@ flowchart TD
Back --> DB("Database (PostgreSQL)")
Back <--> Celery --> DB
Back ----> S3("Minio (S3)")
Back -- REST API --> Find
```
### Architecture decision records
+7
View File
@@ -93,6 +93,13 @@ These are the environment variables you can set for the `impress-backend` contai
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
| POSTHOG_KEY | Posthog key for analytics | |
| REDIS_URL | Cache url | redis://redis:6379/1 |
| SEARCH_INDEXER_CLASS | Class of the backend for document indexation & search | |
| SEARCH_INDEXER_BATCH_SIZE | Size of each batch for indexation of all documents | 100000 |
| SEARCH_INDEXER_COUNTDOWN | Minimum debounce delay of indexation jobs (in seconds) | 1 |
| SEARCH_INDEXER_URL | Find application endpoint for indexation | |
| SEARCH_INDEXER_SECRET | Token for indexation queries | |
| SEARCH_INDEXER_QUERY_URL | Find application endpoint for search | |
| SEARCH_INDEXER_QUERY_LIMIT | Maximum number of results expected from search endpoint | 50 |
| SENTRY_DSN | Sentry host | |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
+41
View File
@@ -0,0 +1,41 @@
# Setup the Find search for Impress
This configuration will enable the fulltext search feature for Docs :
- Each save on **core.Document** or **core.DocumentAccess** will trigger the indexer
- The `api/v1.0/documents/search/` will work as a proxy with the Find API for fulltext search.
## Create an index service for Docs
Configure a **Service** for Docs application with these settings
- **Name**: `docs`<br>_request.auth.name of the Docs application._
- **Client id**: `impress`<br>_Name of the token audience or client_id of the Docs application._
See [how-to-use-indexer.md](how-to-use-indexer.md) for details.
## Configure settings of Docs
Add those Django settings the Docs application to enable the feature.
```shell
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
SEARCH_INDEXER_COUNTDOWN=10 # Debounce delay in seconds for the indexer calls.
# The token from service "docs" of Find application (development).
SEARCH_INDEXER_SECRET="find-api-key-for-docs-with-exactly-50-chars-length"
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
# Search endpoint. Uses the OIDC token for authentication
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
# Maximum number of results expected from the search endpoint
SEARCH_INDEXER_QUERY_LIMIT=50
```
We also need to enable the **OIDC Token** refresh or the authentication will fail quickly.
```shell
# Store OIDC tokens in the session
OIDC_STORE_ACCESS_TOKEN = True # Store the access token in the session
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session
OIDC_STORE_REFRESH_TOKEN_KEY = "your-32-byte-encryption-key==" # Must be a valid Fernet key (32 url-safe base64-encoded bytes)
```
+11
View File
@@ -97,6 +97,17 @@ Production deployments differ significantly from development environments. The t
| 5433 | PostgreSQL (Keycloak) |
| 1081 | MailCatcher |
**With fulltext search service**
| Port | Service |
| --------- | --------------------- |
| 8081 | Find (Django) |
| 9200 | Opensearch |
| 9600 | Opensearch admin |
| 5601 | Opensearch dashboard |
| 25432 | PostgreSQL (Find) |
## 6. Sizing Guidelines
**RAM** start at 8 GB dev / 16 GB staging / 32 GB prod. Postgres and Keycloak are the first to OOM; scale them first.
+16 -1
View File
@@ -36,6 +36,7 @@ OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/c
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token/introspect
OIDC_RP_CLIENT_ID=impress
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
@@ -49,6 +50,14 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Store OIDC tokens in the session
OIDC_STORE_ACCESS_TOKEN = True
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
# To create one, use the bin/fernetkey command.
# OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key=="
# AI
AI_FEATURE_ENABLED=true
AI_BASE_URL=https://openaiendpoint.com
@@ -68,4 +77,10 @@ Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
Y_PROVIDER_API_KEY=yprovider-api-key
# Theme customization
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
# Indexer (disabled)
# SEARCH_INDEXER_CLASS="core.services.search_indexers.SearchIndexer"
SEARCH_INDEXER_SECRET=find-api-key-for-docs-with-exactly-50-chars-length # Key generated by create_demo in Find app.
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
+6
View File
@@ -19,6 +19,12 @@
"matchPackageNames": ["redis"],
"allowedVersions": "<6.0.0"
},
{
"groupName": "allowed pylint versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
+10
View File
@@ -889,3 +889,13 @@ class MoveDocumentSerializer(serializers.Serializer):
choices=enums.MoveNodePositionChoices.choices,
default=enums.MoveNodePositionChoices.LAST_CHILD,
)
class SearchDocumentSerializer(serializers.Serializer):
"""Serializer for fulltext search requests through Find application"""
q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
page_size = serializers.IntegerField(
required=False, min_value=1, max_value=50, default=20
)
page = serializers.IntegerField(required=False, min_value=1, default=1)
+85 -1
View File
@@ -21,6 +21,7 @@ from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.http import Http404, StreamingHttpResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -31,6 +32,7 @@ from botocore.exceptions import ClientError
from csp.constants import NONE
from csp.decorators import csp_update
from lasuite.malware_detection import malware_detection
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
@@ -47,6 +49,10 @@ from core.services.converter_services import (
from core.services.converter_services import (
YdocConverter,
)
from core.services.search_indexers import (
get_document_indexer,
get_visited_document_ids_of,
)
from core.tasks.mail import send_ask_for_access_mail
from core.utils import extract_attachments, filter_descendants
@@ -373,6 +379,7 @@ class DocumentViewSet(
list_serializer_class = serializers.ListDocumentSerializer
trashbin_serializer_class = serializers.ListDocumentSerializer
tree_serializer_class = serializers.ListDocumentSerializer
search_serializer_class = serializers.ListDocumentSerializer
def get_queryset(self):
"""Get queryset performing all annotation and filtering on the document tree structure."""
@@ -1064,6 +1071,83 @@ class DocumentViewSet(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)
def _search_simple(self, request, text):
"""
Returns a queryset filtered by the content of the document title
"""
# As the 'list' view we get a prefiltered queryset (deleted docs are excluded)
queryset = self.get_queryset()
filterset = DocumentFilter({"title": text}, queryset=queryset)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
queryset = filterset.filter_queryset(queryset)
return self.get_response_for_queryset(
queryset.order_by("-updated_at"),
context={
"request": request,
},
)
def _search_fulltext(self, indexer, request, params):
"""
Returns a queryset from the results the fulltext search of Find
"""
access_token = request.session.get("oidc_access_token")
user = request.user
text = params.validated_data["q"]
queryset = models.Document.objects.all()
# Retrieve the documents ids from Find.
results = indexer.search(
text=text,
token=access_token,
visited=get_visited_document_ids_of(queryset, user),
)
docs_by_uuid = {str(d.pk): d for d in queryset.filter(pk__in=results)}
ordered_docs = [docs_by_uuid[id] for id in results]
page = self.paginate_queryset(ordered_docs)
serializer = self.get_serializer(
page if page else ordered_docs,
many=True,
context={
"request": request,
},
)
return self.get_paginated_response(serializer.data)
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
@method_decorator(refresh_oidc_access_token)
def search(self, request, *args, **kwargs):
"""
Returns a DRF response containing the filtered, annotated and ordered document list.
Applies filtering based on request parameter 'q' from `SearchDocumentSerializer`.
Depending of the configuration it can be:
- A fulltext search through the opensearch indexation app "find" if the backend is
enabled (see SEARCH_INDEXER_CLASS)
- A filtering by the model field 'title'.
The ordering is always by the most recent first.
"""
params = serializers.SearchDocumentSerializer(data=request.query_params)
params.is_valid(raise_exception=True)
indexer = get_document_indexer()
if indexer:
return self._search_fulltext(indexer, request, params=params)
# The indexer is not configured, we fallback on a simple icontains filter by the
# model field 'title'.
return self._search_simple(request, text=params.validated_data["q"])
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
@@ -1105,7 +1189,7 @@ class DocumentViewSet(
@drf.decorators.action(
detail=True,
methods=["get", "delete"],
url_path="versions/(?P<version_id>[0-9a-z-]+)",
url_path=r"versions/(?P<version_id>[A-Za-z0-9._+\-=~]{1,1024})",
)
# pylint: disable=unused-argument
def versions_detail(self, request, pk, version_id, *args, **kwargs):
+15 -7
View File
@@ -1,11 +1,19 @@
"""Impress Core application"""
# from django.apps import AppConfig
# from django.utils.translation import gettext_lazy as _
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
# class CoreConfig(AppConfig):
# """Configuration class for the impress core app."""
class CoreConfig(AppConfig):
"""Configuration class for the impress core app."""
# name = "core"
# app_label = "core"
# verbose_name = _("impress core application")
name = "core"
app_label = "core"
verbose_name = _("Impress core application")
def ready(self):
"""
Import signals when the app is ready.
"""
# pylint: disable=import-outside-toplevel, unused-import
from . import signals # noqa: PLC0415
@@ -0,0 +1,52 @@
"""
Handle search setup that needs to be done at bootstrap time.
"""
import logging
import time
from django.core.management.base import BaseCommand, CommandError
from core.services.search_indexers import get_document_indexer
logger = logging.getLogger("docs.search.bootstrap_search")
class Command(BaseCommand):
"""Index all documents to remote search service"""
help = __doc__
def add_arguments(self, parser):
"""Add argument to require forcing execution when not in debug mode."""
parser.add_argument(
"--batch-size",
action="store",
dest="batch_size",
type=int,
default=50,
help="Indexation query batch size",
)
def handle(self, *args, **options):
"""Launch and log search index generation."""
indexer = get_document_indexer()
if not indexer:
raise CommandError("The indexer is not enabled or properly configured.")
logger.info("Starting to regenerate Find index...")
start = time.perf_counter()
batch_size = options["batch_size"]
try:
count = indexer.index(batch_size=batch_size)
except Exception as err:
raise CommandError("Unable to regenerate index") from err
duration = time.perf_counter() - start
logger.info(
"Search index regenerated from %d document(s) in %.2f seconds.",
count,
duration,
)
+27 -23
View File
@@ -432,32 +432,35 @@ class Document(MP_Node, BaseModel):
def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed."""
super().save(*args, **kwargs)
if self._content:
file_key = self.file_key
bytes_content = self._content.encode("utf-8")
self.save_content(self._content)
# Attempt to directly check if the object exists using the storage client.
try:
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
except ClientError as excpt:
# If the error is a 404, the object doesn't exist, so we should create it.
if excpt.response["Error"]["Code"] == "404":
has_changed = True
else:
raise
def save_content(self, content):
"""Save content to object storage."""
file_key = self.file_key
bytes_content = content.encode("utf-8")
# Attempt to directly check if the object exists using the storage client.
try:
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
except ClientError as excpt:
# If the error is a 404, the object doesn't exist, so we should create it.
if excpt.response["Error"]["Code"] == "404":
has_changed = True
else:
# Compare the existing ETag with the MD5 hash of the new content.
has_changed = (
response["ETag"].strip('"')
!= hashlib.md5(bytes_content).hexdigest() # noqa: S324
)
raise
else:
# Compare the existing ETag with the MD5 hash of the new content.
has_changed = (
response["ETag"].strip('"') != hashlib.md5(bytes_content).hexdigest() # noqa: S324
)
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
def is_leaf(self):
"""
@@ -901,7 +904,8 @@ class Document(MP_Node, BaseModel):
# Mark all descendants as soft deleted
self.get_descendants().filter(ancestors_deleted_at__isnull=True).update(
ancestors_deleted_at=self.ancestors_deleted_at
ancestors_deleted_at=self.ancestors_deleted_at,
updated_at=self.updated_at,
)
@transaction.atomic
@@ -0,0 +1,331 @@
"""Document search index management utilities and indexers"""
import base64
import logging
from abc import ABC, abstractmethod
from collections import defaultdict
from functools import cache
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Subquery
from django.utils.module_loading import import_string
import requests
from core import models, utils
from core.services.converter_services import (
ServiceUnavailableError as YProviderServiceUnavailableError,
)
from core.services.converter_services import (
YdocConverter,
)
logger = logging.getLogger(__name__)
@cache
def get_document_indexer():
"""Returns an instance of indexer service if enabled and properly configured."""
classpath = settings.SEARCH_INDEXER_CLASS
# For this usecase an empty indexer class is not an issue but a feature.
if not classpath:
logger.info("Document indexer is not configured (see SEARCH_INDEXER_CLASS)")
return None
try:
indexer_class = import_string(settings.SEARCH_INDEXER_CLASS)
return indexer_class()
except ImportError as err:
logger.error("SEARCH_INDEXER_CLASS setting is not valid : %s", err)
except ImproperlyConfigured as err:
logger.error("Document indexer is not properly configured : %s", err)
return None
def get_batch_accesses_by_users_and_teams(paths):
"""
Get accesses related to a list of document paths,
grouped by users and teams, including all ancestor paths.
"""
ancestor_map = utils.get_ancestor_to_descendants_map(
paths, steplen=models.Document.steplen
)
ancestor_paths = list(ancestor_map.keys())
access_qs = models.DocumentAccess.objects.filter(
document__path__in=ancestor_paths
).values("document__path", "user__sub", "team")
access_by_document_path = defaultdict(lambda: {"users": set(), "teams": set()})
for access in access_qs:
ancestor_path = access["document__path"]
user_sub = access["user__sub"]
team = access["team"]
for descendant_path in ancestor_map.get(ancestor_path, []):
if user_sub:
access_by_document_path[descendant_path]["users"].add(str(user_sub))
if team:
access_by_document_path[descendant_path]["teams"].add(team)
return dict(access_by_document_path)
def get_visited_document_ids_of(queryset, user):
"""
Returns the ids of the documents that have a linktrace to the user and NOT owned.
It will be use to limit the opensearch responses to the public documents already
"visited" by the user.
"""
if isinstance(user, AnonymousUser):
return []
qs = models.LinkTrace.objects.filter(user=user)
docs = (
queryset.exclude(accesses__user=user)
.filter(
deleted_at__isnull=True,
ancestors_deleted_at__isnull=True,
)
.filter(pk__in=Subquery(qs.values("document_id")))
.order_by("pk")
.distinct("pk")
)
return [str(id) for id in docs.values_list("pk", flat=True)]
class BaseDocumentIndexer(ABC):
"""
Base class for document indexers.
Handles batching and access resolution. Subclasses must implement both
`serialize_document()` and `push()` to define backend-specific behavior.
"""
def __init__(self):
"""
Initialize the indexer.
"""
self.batch_size = settings.SEARCH_INDEXER_BATCH_SIZE
self.indexer_url = settings.SEARCH_INDEXER_URL
self.indexer_secret = settings.SEARCH_INDEXER_SECRET
self.search_url = settings.SEARCH_INDEXER_QUERY_URL
self.search_limit = settings.SEARCH_INDEXER_QUERY_LIMIT
if not self.indexer_url:
raise ImproperlyConfigured(
"SEARCH_INDEXER_URL must be set in Django settings."
)
if not self.indexer_secret:
raise ImproperlyConfigured(
"SEARCH_INDEXER_SECRET must be set in Django settings."
)
if not self.search_url:
raise ImproperlyConfigured(
"SEARCH_INDEXER_QUERY_URL must be set in Django settings."
)
def index(self, queryset=None, batch_size=None):
"""
Fetch documents in batches, serialize them, and push to the search backend.
Args:
queryset (optional): Document queryset
Defaults to all documents without filter.
batch_size (int, optional): Number of documents per batch.
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
"""
last_id = 0
count = 0
queryset = queryset or models.Document.objects.all()
batch_size = batch_size or self.batch_size
while True:
documents_batch = list(
queryset.filter(
id__gt=last_id,
).order_by("id")[:batch_size]
)
if not documents_batch:
break
doc_paths = [doc.path for doc in documents_batch]
last_id = documents_batch[-1].id
accesses_by_document_path = get_batch_accesses_by_users_and_teams(doc_paths)
serialized_batch = [
self.serialize_document(document, accesses_by_document_path)
for document in documents_batch
if document.content or document.title
]
if serialized_batch:
self.push(serialized_batch)
count += len(serialized_batch)
return count
@abstractmethod
def serialize_document(self, document, accesses):
"""
Convert a Document instance to a JSON-serializable format for indexing.
Must be implemented by subclasses.
"""
@abstractmethod
def push(self, data):
"""
Push a batch of serialized documents to the backend.
Must be implemented by subclasses.
"""
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
def search(self, text, token, visited=(), nb_results=None):
"""
Search for documents in Find app.
Ensure the same default ordering as "Docs" list : -updated_at
Returns ids of the documents
Args:
text (str): Text search content.
token (str): OIDC Authentication token.
visited (list, optional):
List of ids of active public documents with LinkTrace
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
nb_results (int, optional):
The number of results to return.
Defaults to 50 if not specified.
"""
nb_results = nb_results or self.search_limit
response = self.search_query(
data={
"q": text,
"visited": visited,
"services": ["docs"],
"nb_results": nb_results,
"order_by": "updated_at",
"order_direction": "desc",
},
token=token,
)
return [d["_id"] for d in response]
@abstractmethod
def search_query(self, data, token) -> dict:
"""
Retrieve documents from the Find app API.
Must be implemented by subclasses.
"""
class SearchIndexer(BaseDocumentIndexer):
"""
Document indexer that pushes documents to La Suite Find app.
"""
def to_markdown(self, document):
"""
Convert document as markdown.
Returns raw text if Ydoc service is not accessible
"""
content = ""
base64_content = document.content
if base64_content is not None and len(base64_content) > 0:
# Convert using the y-provider service
try:
yprovider = YdocConverter()
result = yprovider.convert(
base64.b64decode(base64_content),
"application/vnd.yjs.doc",
"text/markdown",
)
content = result
except YProviderServiceUnavailableError as e:
logger.error(
"Error getting content for document %s: %s", document.pk, e
)
return utils.base64_yjs_to_text(base64_content)
return content
def serialize_document(self, document, accesses):
"""
Convert a Document to the JSON format expected by La Suite Find.
Args:
document (Document): The document instance.
accesses (dict): Mapping of document ID to user/team access.
Returns:
dict: A JSON-serializable dictionary.
"""
doc_path = document.path
text_content = self.to_markdown(document)
return {
"id": str(document.id),
"title": document.title or "",
"content": text_content,
"depth": document.depth,
"path": document.path,
"numchild": document.numchild,
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
"users": list(accesses.get(doc_path, {}).get("users", set())),
"groups": list(accesses.get(doc_path, {}).get("teams", set())),
"reach": document.computed_link_reach,
"size": len(text_content.encode("utf-8")),
"mimetype": "text/markdown",
"is_active": not bool(document.ancestors_deleted_at),
}
def search_query(self, data, token) -> requests.Response:
"""
Retrieve documents from the Find app API.
Args:
data (dict): search data
token (str): OICD token
Returns:
dict: A JSON-serializable dictionary.
"""
response = requests.post(
self.search_url,
json=data,
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
response.raise_for_status()
return response.json()
def push(self, data):
"""
Push a batch of documents to the Find backend.
Args:
data (list): List of document dictionaries.
"""
response = requests.post(
self.indexer_url,
json=data,
headers={"Authorization": f"Bearer {self.indexer_secret}"},
timeout=10,
)
response.raise_for_status()
+33
View File
@@ -0,0 +1,33 @@
"""
Declare and configure the signals for the impress core application
"""
from functools import partial
from django.db import transaction
from django.db.models import signals
from django.dispatch import receiver
from . import models
from .tasks.search import trigger_batch_document_indexer
@receiver(signals.post_save, sender=models.Document)
def document_post_save(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Asynchronous call to the document indexer at the end of the transaction.
Note : Within the transaction we can have an empty content and a serialization
error.
"""
transaction.on_commit(partial(trigger_batch_document_indexer, instance))
@receiver(signals.post_save, sender=models.DocumentAccess)
def document_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument
"""
Asynchronous call to the document indexer at the end of the transaction.
"""
if not created:
transaction.on_commit(
partial(trigger_batch_document_indexer, instance.document)
)
+95
View File
@@ -0,0 +1,95 @@
"""Trigger document indexation using celery task."""
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django.db.models import Q
from django_redis.cache import RedisCache
from core import models
from core.services.search_indexers import (
get_document_indexer,
)
from impress.celery_app import app
logger = getLogger(__file__)
@app.task
def document_indexer_task(document_id):
"""Celery Task : Sends indexation query for a document."""
indexer = get_document_indexer()
if indexer:
logger.info("Start document %s indexation", document_id)
indexer.index(models.Document.objects.filter(pk=document_id))
def batch_indexer_throttle_acquire(timeout: int = 0, atomic: bool = True):
"""
Enable the task throttle flag for a delay.
Uses redis locks if available to ensure atomic changes
"""
key = "document-batch-indexer-throttle"
# Redis is used as cache database (not in tests). Use the lock feature here
# to ensure atomicity of changes to the throttle flag.
if isinstance(cache, RedisCache) and atomic:
with cache.locks(key):
return batch_indexer_throttle_acquire(timeout, atomic=False)
# Use add() here :
# - set the flag and returns true if not exist
# - do nothing and return false if exist
return cache.add(key, 1, timeout=timeout)
@app.task
def batch_document_indexer_task(timestamp):
"""Celery Task : Sends indexation query for a batch of documents."""
indexer = get_document_indexer()
if indexer:
queryset = models.Document.objects.filter(
Q(updated_at__gte=timestamp)
| Q(deleted_at__gte=timestamp)
| Q(ancestors_deleted_at__gte=timestamp)
)
count = indexer.index(queryset)
logger.info("Indexed %d documents", count)
def trigger_batch_document_indexer(item):
"""
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
Args:
document (Document): The document instance.
"""
countdown = int(settings.SEARCH_INDEXER_COUNTDOWN)
# DO NOT create a task if indexation if disabled
if not settings.SEARCH_INDEXER_CLASS:
return
if countdown > 0:
# Each time this method is called during a countdown, we increment the
# counter and each task decrease it, so the index be run only once.
if batch_indexer_throttle_acquire(timeout=countdown):
logger.info(
"Add task for batch document indexation from updated_at=%s in %d seconds",
item.updated_at.isoformat(),
countdown,
)
batch_document_indexer_task.apply_async(
args=[item.updated_at], countdown=countdown
)
else:
logger.info("Skip task for batch document %s indexation", item.pk)
else:
document_indexer_task.apply(args=[item.pk])
@@ -57,7 +57,7 @@ def test_authentication_getter_existing_user_via_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(3): # user by sub, user by mail, update sub
with django_assert_num_queries(4): # user by sub, user by mail, update sub
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -214,7 +214,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
with django_assert_num_queries(3):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -256,7 +256,7 @@ def test_authentication_getter_existing_user_change_fields_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(3):
with django_assert_num_queries(4):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -0,0 +1,65 @@
"""
Unit test for `index` command.
"""
from operator import itemgetter
from unittest import mock
from django.core.management import CommandError, call_command
from django.db import transaction
import pytest
from core import factories
from core.services.search_indexers import SearchIndexer
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index():
"""Test the command `index` that run the Find app indexer for all the available documents."""
user = factories.UserFactory()
indexer = SearchIndexer()
with transaction.atomic():
doc = factories.DocumentFactory()
empty_doc = factories.DocumentFactory(title=None, content="")
no_title_doc = factories.DocumentFactory(title=None)
factories.UserDocumentAccessFactory(document=doc, user=user)
factories.UserDocumentAccessFactory(document=empty_doc, user=user)
factories.UserDocumentAccessFactory(document=no_title_doc, user=user)
accesses = {
str(doc.path): {"users": [user.sub]},
str(empty_doc.path): {"users": [user.sub]},
str(no_title_doc.path): {"users": [user.sub]},
}
with mock.patch.object(SearchIndexer, "push") as mock_push:
call_command("index")
push_call_args = [call.args[0] for call in mock_push.call_args_list]
# called once but with a batch of docs
mock_push.assert_called_once()
assert sorted(push_call_args[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(no_title_doc, accesses),
],
key=itemgetter("id"),
)
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index_improperly_configured(indexer_settings):
"""The command should raise an exception if the indexer is not configured"""
indexer_settings.SEARCH_INDEXER_CLASS = None
with pytest.raises(CommandError) as err:
call_command("index")
assert str(err.value) == "The indexer is not enabled or properly configured."
+27
View File
@@ -24,3 +24,30 @@ def mock_user_teams():
"core.models.User.teams", new_callable=mock.PropertyMock
) as mock_teams:
yield mock_teams
@pytest.fixture(name="indexer_settings")
def indexer_settings_fixture(settings):
"""
Setup valid settings for the document indexer. Clear the indexer cache.
"""
# pylint: disable-next=import-outside-toplevel
from core.services.search_indexers import ( # noqa: PLC0415
get_document_indexer,
)
get_document_indexer.cache_clear()
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.SearchIndexer"
settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest"
settings.SEARCH_INDEXER_URL = "http://localhost:8081/api/v1.0/documents/index/"
settings.SEARCH_INDEXER_QUERY_URL = (
"http://localhost:8081/api/v1.0/documents/search/"
)
settings.SEARCH_INDEXER_COUNTDOWN = 1
yield settings
# clear cache to prevent issues with other tests
get_document_indexer.cache_clear()
@@ -0,0 +1,425 @@
"""
Tests for Documents API endpoint in impress's core app: list
"""
import random
from json import loads as json_loads
from django.test import RequestFactory
import pytest
import responses
from faker import Faker
from rest_framework.test import APIClient
from core import factories, models
from core.services.search_indexers import get_document_indexer
fake = Faker()
pytestmark = pytest.mark.django_db
def build_search_url(**kwargs):
"""Build absolute uri for search endpoint with ORDERED query arguments"""
return (
RequestFactory()
.get("/api/v1.0/documents/search/", dict(sorted(kwargs.items())))
.build_absolute_uri()
)
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@responses.activate
def test_api_documents_search_anonymous(reach, role, indexer_settings):
"""
Anonymous users should not be allowed to search documents whatever the
link reach and link role
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
factories.DocumentFactory(link_reach=reach, link_role=role)
# Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[],
status=200,
)
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_documents_search_endpoint_is_none(indexer_settings):
"""
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured.
Should fallback on title filter
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
assert get_document_indexer() is None
user = factories.UserFactory()
document = factories.DocumentFactory(title="alpha")
access = factories.UserDocumentAccessFactory(document=document, user=user)
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"deleted_at": None,
"user_role": access.role,
}
@responses.activate
def test_api_documents_search_invalid_params(indexer_settings):
"""Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/documents/search/")
assert response.status_code == 400
assert response.json() == {"q": ["This field is required."]}
response = client.get("/api/v1.0/documents/search/", data={"q": " "})
assert response.status_code == 400
assert response.json() == {"q": ["This field may not be blank."]}
response = client.get(
"/api/v1.0/documents/search/", data={"q": "any", "page": "NaN"}
)
assert response.status_code == 400
assert response.json() == {"page": ["A valid integer is required."]}
@responses.activate
def test_api_documents_search_format(indexer_settings):
"""Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
user_a, user_b, user_c = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory(
title="alpha",
users=(user_a, user_c),
link_traces=(user, user_b),
)
access = factories.UserDocumentAccessFactory(document=document, user=user)
# Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[
{"_id": str(document.pk)},
],
status=200,
)
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"deleted_at": None,
"user_role": access.role,
}
@responses.activate
@pytest.mark.parametrize(
"pagination, status, expected",
(
(
{"page": 1, "page_size": 10},
200,
{
"count": 10,
"previous": None,
"next": None,
"range": (0, None),
},
),
(
{},
200,
{
"count": 10,
"previous": None,
"next": None,
"range": (0, None),
"api_page_size": 21, # default page_size is 20
},
),
(
{"page": 2, "page_size": 10},
404,
{},
),
(
{"page": 1, "page_size": 5},
200,
{
"count": 10,
"previous": None,
"next": {"page": 2, "page_size": 5},
"range": (0, 5),
},
),
(
{"page": 2, "page_size": 5},
200,
{
"count": 10,
"previous": {"page_size": 5},
"next": None,
"range": (5, None),
},
),
({"page": 3, "page_size": 5}, 404, {}),
),
)
def test_api_documents_search_pagination(
indexer_settings, pagination, status, expected
):
"""Documents should be ordered by descending "score" by default"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
docs = factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
docs_by_uuid = {str(doc.pk): doc for doc in docs}
api_results = [{"_id": id} for id in docs_by_uuid.keys()]
# reorder randomly to simulate score ordering
random.shuffle(api_results)
# Find response
# pylint: disable-next=assignment-from-none
api_search = responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=api_results,
status=200,
)
response = client.get(
"/api/v1.0/documents/search/",
data={
"q": "alpha",
**pagination,
},
)
assert response.status_code == status
if response.status_code < 300:
previous_url = (
build_search_url(q="alpha", **expected["previous"])
if expected["previous"]
else None
)
next_url = (
build_search_url(q="alpha", **expected["next"])
if expected["next"]
else None
)
start, end = expected["range"]
content = response.json()
assert content["count"] == expected["count"]
assert content["previous"] == previous_url
assert content["next"] == next_url
results = content.pop("results")
# The find api results ordering by score is kept
assert [r["id"] for r in results] == [r["_id"] for r in api_results[start:end]]
# Check the query parameters.
assert api_search.call_count == 1
assert api_search.calls[0].response.status_code == 200
assert json_loads(api_search.calls[0].request.body) == {
"q": "alpha",
"visited": [],
"services": ["docs"],
"nb_results": 50,
"order_by": "updated_at",
"order_direction": "desc",
}
@responses.activate
@pytest.mark.parametrize(
"pagination, status, expected",
(
(
{"page": 1, "page_size": 10},
200,
{"count": 10, "previous": None, "next": None, "range": (0, None)},
),
(
{},
200,
{"count": 10, "previous": None, "next": None, "range": (0, None)},
),
(
{"page": 2, "page_size": 10},
404,
{},
),
(
{"page": 1, "page_size": 5},
200,
{
"count": 10,
"previous": None,
"next": {"page": 2, "page_size": 5},
"range": (0, 5),
},
),
(
{"page": 2, "page_size": 5},
200,
{
"count": 10,
"previous": {"page_size": 5},
"next": None,
"range": (5, None),
},
),
({"page": 3, "page_size": 5}, 404, {}),
),
)
def test_api_documents_search_pagination_endpoint_is_none(
indexer_settings, pagination, status, expected
):
"""Documents should be ordered by descending "-updated_at" by default"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
assert get_document_indexer() is None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
response = client.get(
"/api/v1.0/documents/search/",
data={
"q": "alpha",
**pagination,
},
)
assert response.status_code == status
if response.status_code < 300:
previous_url = (
build_search_url(q="alpha", **expected["previous"])
if expected["previous"]
else None
)
next_url = (
build_search_url(q="alpha", **expected["next"])
if expected["next"]
else None
)
queryset = models.Document.objects.order_by("-updated_at")
start, end = expected["range"]
expected_results = [str(d.pk) for d in queryset[start:end]]
content = response.json()
assert content["count"] == expected["count"]
assert content["previous"] == previous_url
assert content["next"] == next_url
results = content.pop("results")
assert [r["id"] for r in results] == expected_results
@@ -0,0 +1,441 @@
"""
Unit tests for the Document model
"""
# pylint: disable=too-many-lines
from operator import itemgetter
from unittest import mock
from django.core.cache import cache
from django.db import transaction
import pytest
from core import factories, models
from core.services.search_indexers import SearchIndexer
pytestmark = pytest.mark.django_db
def reset_batch_indexer_throttle():
"""Reset throttle flag"""
cache.delete("document-batch-indexer-throttle")
@pytest.fixture(autouse=True)
def reset_throttle():
"""Reset throttle flag before each test"""
reset_batch_indexer_throttle()
yield
reset_batch_indexer_throttle()
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer(mock_push):
"""Test indexation task on document creation"""
with transaction.atomic():
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
accesses = {}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer()
assert len(data) == 1
# One call
assert sorted(data[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc1, accesses),
indexer.serialize_document(doc2, accesses),
indexer.serialize_document(doc3, accesses),
],
key=itemgetter("id"),
)
# The throttle counters should be reset
assert cache.get("document-batch-indexer-throttle") == 1
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_no_batches(indexer_settings):
"""Test indexation task on doculment creation, no throttle"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
with mock.patch.object(SearchIndexer, "push") as mock_push:
with transaction.atomic():
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
accesses = {}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer()
# 3 calls
assert len(data) == 3
# one document per call
assert [len(d) for d in data] == [1] * 3
# all documents are indexed
assert sorted([d[0] for d in data], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc1, accesses),
indexer.serialize_document(doc2, accesses),
indexer.serialize_document(doc3, accesses),
],
key=itemgetter("id"),
)
# The throttle counters should be reset
assert cache.get("file-batch-indexer-throttle") is None
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_settings):
"""Task should not start an indexation when disabled"""
indexer_settings.SEARCH_INDEXER_CLASS = None
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=doc, user=user)
assert mock_push.assert_not_called
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_wrongly_configured(
mock_push, indexer_settings
):
"""Task should not start an indexation when disabled"""
indexer_settings.SEARCH_INDEXER_URL = None
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=doc, user=user)
assert mock_push.assert_not_called
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_with_accesses(mock_push):
"""Test indexation task on document creation"""
user = factories.UserFactory()
with transaction.atomic():
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
factories.UserDocumentAccessFactory(document=doc1, user=user)
factories.UserDocumentAccessFactory(document=doc2, user=user)
factories.UserDocumentAccessFactory(document=doc3, user=user)
accesses = {
str(doc1.path): {"users": [user.sub]},
str(doc2.path): {"users": [user.sub]},
str(doc3.path): {"users": [user.sub]},
}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer()
assert len(data) == 1
assert sorted(data[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc1, accesses),
indexer.serialize_document(doc2, accesses),
indexer.serialize_document(doc3, accesses),
],
key=itemgetter("id"),
)
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_deleted(mock_push):
"""Indexation task on deleted or ancestor_deleted documents"""
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
main_doc = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
child_doc = factories.DocumentFactory(
parent=main_doc,
link_reach=models.LinkReachChoices.AUTHENTICATED,
)
factories.UserDocumentAccessFactory(document=doc, user=user)
factories.UserDocumentAccessFactory(document=main_doc, user=user)
factories.UserDocumentAccessFactory(document=child_doc, user=user)
# Manually reset the throttle flag here or the next indexation will be ignored for 1 second
reset_batch_indexer_throttle()
with transaction.atomic():
main_doc_deleted = models.Document.objects.get(pk=main_doc.pk)
main_doc_deleted.soft_delete()
child_doc_deleted = models.Document.objects.get(pk=child_doc.pk)
main_doc_deleted.refresh_from_db()
child_doc_deleted.refresh_from_db()
assert main_doc_deleted.deleted_at is not None
assert child_doc_deleted.ancestors_deleted_at is not None
assert child_doc_deleted.deleted_at is None
assert child_doc_deleted.ancestors_deleted_at is not None
accesses = {
str(doc.path): {"users": [user.sub]},
str(main_doc_deleted.path): {"users": [user.sub]},
str(child_doc_deleted.path): {"users": [user.sub]},
}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer()
assert len(data) == 2
# First indexation on document creation
assert sorted(data[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(main_doc, accesses),
indexer.serialize_document(child_doc, accesses),
],
key=itemgetter("id"),
)
# Even deleted items are re-indexed : only update their status in the future
assert sorted(data[1], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(main_doc_deleted, accesses), # soft_delete()
indexer.serialize_document(child_doc_deleted, accesses),
],
key=itemgetter("id"),
)
@pytest.mark.django_db(transaction=True)
@pytest.mark.usefixtures("indexer_settings")
def test_models_documents_indexer_hard_deleted():
"""Indexation task on hard deleted document"""
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
factories.UserDocumentAccessFactory(document=doc, user=user)
# Call task on deleted document.
with mock.patch.object(SearchIndexer, "push") as mock_push:
doc.delete()
# Hard delete document are not re-indexed.
assert mock_push.assert_not_called
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_restored(mock_push):
"""Restart indexation task on restored documents"""
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
doc_deleted = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
doc_ancestor_deleted = factories.DocumentFactory(
parent=doc_deleted,
link_reach=models.LinkReachChoices.AUTHENTICATED,
)
factories.UserDocumentAccessFactory(document=doc, user=user)
factories.UserDocumentAccessFactory(document=doc_deleted, user=user)
factories.UserDocumentAccessFactory(document=doc_ancestor_deleted, user=user)
doc_deleted.soft_delete()
doc_deleted.refresh_from_db()
doc_ancestor_deleted.refresh_from_db()
assert doc_deleted.deleted_at is not None
assert doc_deleted.ancestors_deleted_at is not None
assert doc_ancestor_deleted.deleted_at is None
assert doc_ancestor_deleted.ancestors_deleted_at is not None
# Manually reset the throttle flag here or the next indexation will be ignored for 1 second
reset_batch_indexer_throttle()
with transaction.atomic():
doc_restored = models.Document.objects.get(pk=doc_deleted.pk)
doc_restored.restore()
doc_ancestor_restored = models.Document.objects.get(pk=doc_ancestor_deleted.pk)
assert doc_restored.deleted_at is None
assert doc_restored.ancestors_deleted_at is None
assert doc_ancestor_restored.deleted_at is None
assert doc_ancestor_restored.ancestors_deleted_at is None
accesses = {
str(doc.path): {"users": [user.sub]},
str(doc_deleted.path): {"users": [user.sub]},
str(doc_ancestor_deleted.path): {"users": [user.sub]},
}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer()
# All docs are re-indexed
assert len(data) == 2
# First indexation on items creation & soft delete (in the same transaction)
assert sorted(data[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(doc_deleted, accesses),
indexer.serialize_document(doc_ancestor_deleted, accesses),
],
key=itemgetter("id"),
)
# Restored items are re-indexed : only update their status in the future
assert sorted(data[1], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc_restored, accesses), # restore()
indexer.serialize_document(doc_ancestor_restored, accesses),
],
key=itemgetter("id"),
)
@pytest.mark.django_db(transaction=True)
@pytest.mark.usefixtures("indexer_settings")
def test_models_documents_post_save_indexer_throttle():
"""Test indexation task skipping on document update"""
indexer = SearchIndexer()
user = factories.UserFactory()
with mock.patch.object(SearchIndexer, "push"):
with transaction.atomic():
docs = factories.DocumentFactory.create_batch(5, users=(user,))
accesses = {str(item.path): {"users": [user.sub]} for item in docs}
with mock.patch.object(SearchIndexer, "push") as mock_push:
# Simulate 1 running task
cache.set("document-batch-indexer-throttle", 1)
# save doc to trigger the indexer, but nothing should be done since
# the flag is up
with transaction.atomic():
docs[0].save()
docs[2].save()
docs[3].save()
assert [call.args[0] for call in mock_push.call_args_list] == []
with mock.patch.object(SearchIndexer, "push") as mock_push:
# No waiting task
cache.delete("document-batch-indexer-throttle")
with transaction.atomic():
docs[0].save()
docs[2].save()
docs[3].save()
data = [call.args[0] for call in mock_push.call_args_list]
# One call
assert len(data) == 1
assert sorted(data[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(docs[0], accesses),
indexer.serialize_document(docs[2], accesses),
indexer.serialize_document(docs[3], accesses),
],
key=itemgetter("id"),
)
@pytest.mark.django_db(transaction=True)
@pytest.mark.usefixtures("indexer_settings")
def test_models_documents_access_post_save_indexer():
"""Test indexation task on DocumentAccess update"""
users = factories.UserFactory.create_batch(3)
with mock.patch.object(SearchIndexer, "push"):
with transaction.atomic():
doc = factories.DocumentFactory(users=users)
doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by(
"user__sub"
)
reset_batch_indexer_throttle()
with mock.patch.object(SearchIndexer, "push") as mock_push:
with transaction.atomic():
for doc_access in doc_accesses:
doc_access.save()
data = [call.args[0] for call in mock_push.call_args_list]
# One call
assert len(data) == 1
assert [d["id"] for d in data[0]] == [str(doc.pk)]
@pytest.mark.django_db(transaction=True)
def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
"""Test indexation task on ItemAccess update, no throttle"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
users = factories.UserFactory.create_batch(3)
with transaction.atomic():
doc = factories.DocumentFactory(users=users)
doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by(
"user__sub"
)
reset_batch_indexer_throttle()
with mock.patch.object(SearchIndexer, "push") as mock_push:
with transaction.atomic():
for doc_access in doc_accesses:
doc_access.save()
data = [call.args[0] for call in mock_push.call_args_list]
# 3 calls
assert len(data) == 3
# one document per call
assert [len(d) for d in data] == [1] * 3
# the same document is indexed 3 times
assert [d[0]["id"] for d in data] == [str(doc.pk)] * 3
@@ -0,0 +1,701 @@
"""Tests for Documents search indexers"""
from functools import partial
from json import dumps as json_dumps
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
import pytest
import responses
from requests import HTTPError
from core import factories, models, utils
from core.services.converter_services import (
ServiceUnavailableError as YProviderServiceUnavailableError,
)
from core.services.search_indexers import (
BaseDocumentIndexer,
SearchIndexer,
get_document_indexer,
get_visited_document_ids_of,
)
pytestmark = pytest.mark.django_db
class FakeDocumentIndexer(BaseDocumentIndexer):
"""Fake indexer for test purpose"""
def serialize_document(self, document, accesses):
return {}
def push(self, data):
pass
def search_query(self, data, token):
return {}
def test_services_search_indexer_class_invalid(indexer_settings):
"""
Should raise RuntimeError if SEARCH_INDEXER_CLASS cannot be imported.
"""
indexer_settings.SEARCH_INDEXER_CLASS = "unknown.Unknown"
assert get_document_indexer() is None
def test_services_search_indexer_class(indexer_settings):
"""
Import indexer class defined in setting SEARCH_INDEXER_CLASS.
"""
indexer_settings.SEARCH_INDEXER_CLASS = (
"core.tests.test_services_search_indexers.FakeDocumentIndexer"
)
assert isinstance(
get_document_indexer(),
import_string("core.tests.test_services_search_indexers.FakeDocumentIndexer"),
)
def test_services_search_indexer_is_configured(indexer_settings):
"""
Should return true only when the indexer class and other configuration settings
are valid.
"""
indexer_settings.SEARCH_INDEXER_CLASS = None
# None
get_document_indexer.cache_clear()
assert not get_document_indexer()
# Empty
indexer_settings.SEARCH_INDEXER_CLASS = ""
get_document_indexer.cache_clear()
assert not get_document_indexer()
# Valid class
indexer_settings.SEARCH_INDEXER_CLASS = (
"core.services.search_indexers.SearchIndexer"
)
get_document_indexer.cache_clear()
assert get_document_indexer() is not None
indexer_settings.SEARCH_INDEXER_URL = ""
# Invalid url
get_document_indexer.cache_clear()
assert not get_document_indexer()
def test_services_search_indexer_url_is_none(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is None or empty.
"""
indexer_settings.SEARCH_INDEXER_URL = None
with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer()
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
def test_services_search_indexer_url_is_empty(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is empty string.
"""
indexer_settings.SEARCH_INDEXER_URL = ""
with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer()
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
def test_services_search_indexer_secret_is_none(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_SECRET is None.
"""
indexer_settings.SEARCH_INDEXER_SECRET = None
with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer()
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
exc_info.value
)
def test_services_search_indexer_secret_is_empty(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_SECRET is empty string.
"""
indexer_settings.SEARCH_INDEXER_SECRET = ""
with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer()
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
exc_info.value
)
def test_services_search_endpoint_is_none(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is None.
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer()
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
exc_info.value
)
def test_services_search_endpoint_is_empty(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is empty.
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = ""
with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer()
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
exc_info.value
)
@pytest.mark.usefixtures("indexer_settings")
@patch("core.services.converter_services.YdocConverter.convert")
def test_services_search_indexers_serialize_document_returns_expected_json(
mock_convert,
):
"""
It should serialize documents with correct metadata and access control.
"""
user_a, user_b = factories.UserFactory.create_batch(2)
document = factories.DocumentFactory()
factories.DocumentFactory(parent=document)
markdown_content = (
f"## {document.title}\n{utils.base64_yjs_to_text(document.content)}"
)
mock_convert.return_value = markdown_content
factories.UserDocumentAccessFactory(document=document, user=user_a)
factories.UserDocumentAccessFactory(document=document, user=user_b)
factories.TeamDocumentAccessFactory(document=document, team="team1")
factories.TeamDocumentAccessFactory(document=document, team="team2")
accesses = {
document.path: {
"users": {str(user_a.sub), str(user_b.sub)},
"teams": {"team1", "team2"},
}
}
indexer = SearchIndexer()
result = indexer.serialize_document(document, accesses)
assert mock_convert.call_count == 1
assert set(result.pop("users")) == {str(user_a.sub), str(user_b.sub)}
assert set(result.pop("groups")) == {"team1", "team2"}
assert result == {
"id": str(document.id),
"title": document.title,
"depth": 1,
"path": document.path,
"numchild": 1,
"content": markdown_content,
"mimetype": "text/markdown",
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
"reach": document.link_reach,
"size": len(markdown_content),
"is_active": True,
}
@pytest.mark.usefixtures("indexer_settings")
@patch("core.services.converter_services.YdocConverter.convert")
def test_services_search_indexers_serialize_document_no_converter(
mock_convert,
):
"""
It should serialize documents with correct metadata and access control.
"""
user_a, user_b = factories.UserFactory.create_batch(2)
document = factories.DocumentFactory()
factories.DocumentFactory(parent=document)
mock_convert.side_effect = YProviderServiceUnavailableError()
text_content = utils.base64_yjs_to_text(document.content)
factories.UserDocumentAccessFactory(document=document, user=user_a)
factories.UserDocumentAccessFactory(document=document, user=user_b)
factories.TeamDocumentAccessFactory(document=document, team="team1")
factories.TeamDocumentAccessFactory(document=document, team="team2")
accesses = {
document.path: {
"users": {str(user_a.sub), str(user_b.sub)},
"teams": {"team1", "team2"},
}
}
indexer = SearchIndexer()
result = indexer.serialize_document(document, accesses)
assert mock_convert.call_count == 1
assert set(result.pop("users")) == {str(user_a.sub), str(user_b.sub)}
assert set(result.pop("groups")) == {"team1", "team2"}
assert result == {
"id": str(document.id),
"title": document.title,
"depth": 1,
"path": document.path,
"numchild": 1,
"content": text_content,
"mimetype": "text/markdown",
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
"reach": document.link_reach,
"size": len(text_content),
"is_active": True,
}
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_serialize_document_deleted():
"""Deleted documents are marked as just in the serialized json."""
parent = factories.DocumentFactory()
document = factories.DocumentFactory(parent=parent)
parent.soft_delete()
document.refresh_from_db()
indexer = SearchIndexer()
result = indexer.serialize_document(document, {})
assert result["is_active"] is False
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_serialize_document_empty():
"""Empty documents returns empty content in the serialized json."""
document = factories.DocumentFactory(content="", title=None)
indexer = SearchIndexer()
result = indexer.serialize_document(document, {})
assert result["content"] == ""
assert result["title"] == ""
@responses.activate
def test_services_search_indexers_index_errors(indexer_settings):
"""
Documents indexing response handling on Find API HTTP errors.
"""
factories.DocumentFactory()
indexer_settings.SEARCH_INDEXER_URL = "http://app-find/api/v1.0/documents/index/"
responses.add(
responses.POST,
"http://app-find/api/v1.0/documents/index/",
status=401,
body=json_dumps({"message": "Authentication failed."}),
)
with pytest.raises(HTTPError):
SearchIndexer().index()
@patch.object(SearchIndexer, "push")
def test_services_search_indexers_batches_pass_only_batch_accesses(
mock_push, indexer_settings
):
"""
Documents indexing should be processed in batches,
and only the access data relevant to each batch should be used.
"""
indexer_settings.SEARCH_INDEXER_BATCH_SIZE = 2
documents = factories.DocumentFactory.create_batch(5)
# Attach a single user access to each document
expected_user_subs = {}
for document in documents:
access = factories.UserDocumentAccessFactory(document=document)
expected_user_subs[str(document.id)] = str(access.user.sub)
assert SearchIndexer().index() == 5
# Should be 3 batches: 2 + 2 + 1
assert mock_push.call_count == 3
seen_doc_ids = set()
for call in mock_push.call_args_list:
batch = call.args[0]
assert isinstance(batch, list)
for doc_json in batch:
doc_id = doc_json["id"]
seen_doc_ids.add(doc_id)
# Only one user expected per document
assert doc_json["users"] == [expected_user_subs[doc_id]]
assert doc_json["groups"] == []
# Make sure all 5 documents were indexed
assert seen_doc_ids == {str(d.id) for d in documents}
@patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_batch_size_argument(mock_push):
"""
Documents indexing should be processed in batches,
batch_size overrides SEARCH_INDEXER_BATCH_SIZE
"""
documents = factories.DocumentFactory.create_batch(5)
# Attach a single user access to each document
expected_user_subs = {}
for document in documents:
access = factories.UserDocumentAccessFactory(document=document)
expected_user_subs[str(document.id)] = str(access.user.sub)
assert SearchIndexer().index(batch_size=2) == 5
# Should be 3 batches: 2 + 2 + 1
assert mock_push.call_count == 3
seen_doc_ids = set()
for call in mock_push.call_args_list:
batch = call.args[0]
assert isinstance(batch, list)
for doc_json in batch:
doc_id = doc_json["id"]
seen_doc_ids.add(doc_id)
# Only one user expected per document
assert doc_json["users"] == [expected_user_subs[doc_id]]
assert doc_json["groups"] == []
# Make sure all 5 documents were indexed
assert seen_doc_ids == {str(d.id) for d in documents}
@patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ignore_empty_documents(mock_push):
"""
Documents indexing should be processed in batches,
and only the access data relevant to each batch should be used.
"""
document = factories.DocumentFactory()
factories.DocumentFactory(content="", title="")
empty_title = factories.DocumentFactory(title="")
empty_content = factories.DocumentFactory(content="")
assert SearchIndexer().index() == 3
assert mock_push.call_count == 1
# Make sure only not eempty documents are indexed
results = {doc["id"] for doc in mock_push.call_args[0][0]}
assert results == {
str(d.id)
for d in (
document,
empty_content,
empty_title,
)
}
@patch.object(SearchIndexer, "push")
def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings):
"""
Documents indexing batch can be empty if all the docs are empty.
"""
indexer_settings.SEARCH_INDEXER_BATCH_SIZE = 2
document = factories.DocumentFactory()
# Only empty docs
factories.DocumentFactory.create_batch(5, content="", title="")
assert SearchIndexer().index() == 1
assert mock_push.call_count == 1
results = [doc["id"] for doc in mock_push.call_args[0][0]]
assert results == [str(document.id)]
@patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_link_reach(mock_push):
"""Document accesses and reach should take into account ancestors link reaches."""
great_grand_parent = factories.DocumentFactory(link_reach="restricted")
grand_parent = factories.DocumentFactory(
parent=great_grand_parent, link_reach="authenticated"
)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="public")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
assert SearchIndexer().index() == 4
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 4
assert results[str(great_grand_parent.id)]["reach"] == "restricted"
assert results[str(grand_parent.id)]["reach"] == "authenticated"
assert results[str(parent.id)]["reach"] == "public"
assert results[str(document.id)]["reach"] == "public"
@patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_users(mock_push):
"""Document accesses and reach should include users from ancestors."""
user_gp, user_p, user_d = factories.UserFactory.create_batch(3)
grand_parent = factories.DocumentFactory(users=[user_gp])
parent = factories.DocumentFactory(parent=grand_parent, users=[user_p])
document = factories.DocumentFactory(parent=parent, users=[user_d])
assert SearchIndexer().index() == 3
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 3
assert results[str(grand_parent.id)]["users"] == [str(user_gp.sub)]
assert set(results[str(parent.id)]["users"]) == {str(user_gp.sub), str(user_p.sub)}
assert set(results[str(document.id)]["users"]) == {
str(user_gp.sub),
str(user_p.sub),
str(user_d.sub),
}
@patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_teams(mock_push):
"""Document accesses and reach should include teams from ancestors."""
grand_parent = factories.DocumentFactory(teams=["team_gp"])
parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"])
document = factories.DocumentFactory(parent=parent, teams=["team_d"])
assert SearchIndexer().index() == 3
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 3
assert results[str(grand_parent.id)]["groups"] == ["team_gp"]
assert set(results[str(parent.id)]["groups"]) == {"team_gp", "team_p"}
assert set(results[str(document.id)]["groups"]) == {"team_gp", "team_p", "team_d"}
@patch("requests.post")
def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
"""
push() should call requests.post with the correct URL from settings
the timeout set to 10 seconds and the data as JSON.
"""
indexer_settings.SEARCH_INDEXER_URL = "http://example.com/index"
indexer = SearchIndexer()
sample_data = [{"id": "123", "title": "Test"}]
mock_response = mock_post.return_value
mock_response.raise_for_status.return_value = None # No error
indexer.push(sample_data)
mock_post.assert_called_once()
args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_INDEXER_URL
assert kwargs.get("json") == sample_data
assert kwargs.get("timeout") == 10
def test_get_visited_document_ids_of():
"""
get_visited_document_ids_of() returns the ids of the documents viewed
by the user BUT without specific access configuration (like public ones)
"""
user = factories.UserFactory()
other = factories.UserFactory()
anonymous = AnonymousUser()
queryset = models.Document.objects.all()
assert not get_visited_document_ids_of(queryset, anonymous)
assert not get_visited_document_ids_of(queryset, user)
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
create_link(document=doc1)
create_link(document=doc2)
# The third document is not visited
assert sorted(get_visited_document_ids_of(queryset, user)) == sorted(
[str(doc1.pk), str(doc2.pk)]
)
factories.UserDocumentAccessFactory(user=other, document=doc1)
factories.UserDocumentAccessFactory(user=user, document=doc2)
# The second document have an access for the user
assert get_visited_document_ids_of(queryset, user) == [str(doc1.pk)]
@pytest.mark.usefixtures("indexer_settings")
def test_get_visited_document_ids_of_deleted():
"""
get_visited_document_ids_of() returns the ids of the documents viewed
by the user if they are not deleted.
"""
user = factories.UserFactory()
anonymous = AnonymousUser()
queryset = models.Document.objects.all()
assert not get_visited_document_ids_of(queryset, anonymous)
assert not get_visited_document_ids_of(queryset, user)
doc = factories.DocumentFactory()
doc_deleted = factories.DocumentFactory()
doc_ancestor_deleted = factories.DocumentFactory(parent=doc_deleted)
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
create_link(document=doc)
create_link(document=doc_deleted)
create_link(document=doc_ancestor_deleted)
# The all documents are visited
assert sorted(get_visited_document_ids_of(queryset, user)) == sorted(
[str(doc.pk), str(doc_deleted.pk), str(doc_ancestor_deleted.pk)]
)
doc_deleted.soft_delete()
# Only the first document is not deleted
assert get_visited_document_ids_of(queryset, user) == [str(doc.pk)]
@responses.activate
def test_services_search_indexers_search_errors(indexer_settings):
"""
Documents indexing response handling on Find API HTTP errors.
"""
factories.DocumentFactory()
indexer_settings.SEARCH_INDEXER_QUERY_URL = (
"http://app-find/api/v1.0/documents/search/"
)
responses.add(
responses.POST,
"http://app-find/api/v1.0/documents/search/",
status=401,
body=json_dumps({"message": "Authentication failed."}),
)
with pytest.raises(HTTPError):
SearchIndexer().search("alpha", token="mytoken")
@patch("requests.post")
def test_services_search_indexers_search(mock_post, indexer_settings):
"""
search() should call requests.post to SEARCH_INDEXER_QUERY_URL with the
document ids from linktraces.
"""
user = factories.UserFactory()
indexer = SearchIndexer()
mock_response = mock_post.return_value
mock_response.raise_for_status.return_value = None # No error
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
create_link(document=doc1)
create_link(document=doc2)
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
indexer.search("alpha", visited=visited, token="mytoken")
args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
query_data = kwargs.get("json")
assert query_data["q"] == "alpha"
assert sorted(query_data["visited"]) == sorted([str(doc1.pk), str(doc2.pk)])
assert query_data["services"] == ["docs"]
assert query_data["nb_results"] == 50
assert query_data["order_by"] == "updated_at"
assert query_data["order_direction"] == "desc"
assert kwargs.get("headers") == {"Authorization": "Bearer mytoken"}
assert kwargs.get("timeout") == 10
@patch("requests.post")
def test_services_search_indexers_search_nb_results(mock_post, indexer_settings):
"""
Find API call should have nb_results == SEARCH_INDEXER_QUERY_LIMIT
or the given nb_results argument.
"""
indexer_settings.SEARCH_INDEXER_QUERY_LIMIT = 25
user = factories.UserFactory()
indexer = SearchIndexer()
mock_response = mock_post.return_value
mock_response.raise_for_status.return_value = None # No error
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
create_link(document=doc1)
create_link(document=doc2)
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
indexer.search("alpha", visited=visited, token="mytoken")
args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
assert kwargs.get("json")["nb_results"] == 25
# The argument overrides the setting value
indexer.search("alpha", visited=visited, token="mytoken", nb_results=109)
args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
assert kwargs.get("json")["nb_results"] == 109
+25
View File
@@ -75,3 +75,28 @@ def test_utils_extract_attachments():
base64_string = base64.b64encode(update).decode("utf-8")
# image_key2 is missing the "/media/" part and shouldn't get extracted
assert utils.extract_attachments(base64_string) == [image_key1, image_key3]
def test_utils_get_ancestor_to_descendants_map_single_path():
"""Test ancestor mapping of a single path."""
paths = ["000100020005"]
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
assert result == {
"0001": {"000100020005"},
"00010002": {"000100020005"},
"000100020005": {"000100020005"},
}
def test_utils_get_ancestor_to_descendants_map_multiple_paths():
"""Test ancestor mapping of multiple paths with shared prefixes."""
paths = ["000100020005", "00010003"]
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
assert result == {
"0001": {"000100020005", "00010003"},
"00010002": {"000100020005"},
"000100020005": {"000100020005"},
"00010003": {"00010003"},
}
+22
View File
@@ -2,6 +2,7 @@
import base64
import re
from collections import defaultdict
import pycrdt
from bs4 import BeautifulSoup
@@ -9,6 +10,27 @@ from bs4 import BeautifulSoup
from core import enums
def get_ancestor_to_descendants_map(paths, steplen):
"""
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.
Each path is assumed to use materialized path format with fixed-length segments.
Args:
paths (list of str): List of full document paths.
steplen (int): Length of each path segment.
Returns:
dict[str, set[str]]: Mapping from ancestor path to its descendant paths (including itself).
"""
ancestor_map = defaultdict(set)
for path in paths:
for i in range(steplen, len(path) + 1, steplen):
ancestor = path[:i]
ancestor_map[ancestor].add(path)
return ancestor_map
def filter_descendants(paths, root_paths, skip_sorting=False):
"""
Filters paths to keep only those that are descendants of any path in root_paths.
@@ -1,16 +1,19 @@
# ruff: noqa: S311, S106
"""create_demo management command"""
import base64
import logging
import math
import random
import time
from collections import defaultdict
from uuid import uuid4
from django import db
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
import pycrdt
from faker import Faker
from core import models
@@ -27,6 +30,16 @@ def random_true_with_probability(probability):
return random.random() < probability
def get_ydoc_for_text(text):
"""Return a ydoc from plain text for demo purposes."""
ydoc = pycrdt.Doc()
paragraph = pycrdt.XmlElement("p", {}, [pycrdt.XmlText(text)])
fragment = pycrdt.XmlFragment([paragraph])
ydoc["document-store"] = fragment
update = ydoc.get_update()
return base64.b64encode(update).decode("utf-8")
class BulkQueue:
"""A utility class to create Django model instances in bulk by just pushing to a queue."""
@@ -48,7 +61,7 @@ class BulkQueue:
self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001
def push(self, obj):
"""Add a model instance to queue to that it gets created in bulk."""
"""Add a model instance to queue so that it gets created in bulk."""
objects = self.queue[obj._meta.model.__name__] # noqa: SLF001
objects.append(obj)
if len(objects) > self.BATCH_SIZE:
@@ -139,17 +152,19 @@ def create_demo(stdout):
# pylint: disable=protected-access
key = models.Document._int2str(i) # noqa: SLF001
padding = models.Document.alphabet[0] * (models.Document.steplen - len(key))
queue.push(
models.Document(
depth=1,
path=f"{padding}{key}",
creator_id=random.choice(users_ids),
title=fake.sentence(nb_words=4),
link_reach=models.LinkReachChoices.AUTHENTICATED
if random_true_with_probability(0.5)
else random.choice(models.LinkReachChoices.values),
)
title = fake.sentence(nb_words=4)
document = models.Document(
id=uuid4(),
depth=1,
path=f"{padding}{key}",
creator_id=random.choice(users_ids),
title=title,
link_reach=models.LinkReachChoices.AUTHENTICATED
if random_true_with_probability(0.5)
else random.choice(models.LinkReachChoices.values),
)
document.save_content(get_ydoc_for_text(f"Content for {title:s}"))
queue.push(document)
queue.flush()
+25
View File
@@ -99,6 +99,31 @@ class Base(Configuration):
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Search
SEARCH_INDEXER_CLASS = values.Value(
default=None,
environ_name="SEARCH_INDEXER_CLASS",
environ_prefix=None,
)
SEARCH_INDEXER_BATCH_SIZE = values.IntegerValue(
default=100_000, environ_name="SEARCH_INDEXER_BATCH_SIZE", environ_prefix=None
)
SEARCH_INDEXER_URL = values.Value(
default=None, environ_name="SEARCH_INDEXER_URL", environ_prefix=None
)
SEARCH_INDEXER_COUNTDOWN = values.IntegerValue(
default=1, environ_name="SEARCH_INDEXER_COUNTDOWN", environ_prefix=None
)
SEARCH_INDEXER_SECRET = values.Value(
default=None, environ_name="SEARCH_INDEXER_SECRET", environ_prefix=None
)
SEARCH_INDEXER_QUERY_URL = values.Value(
default=None, environ_name="SEARCH_INDEXER_QUERY_URL", environ_prefix=None
)
SEARCH_INDEXER_QUERY_LIMIT = values.PositiveIntegerValue(
default=50, environ_name="SEARCH_INDEXER_QUERY_LIMIT", environ_prefix=None
)
# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(DATA_DIR, "static")
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Titouroù personel"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Aotreoù"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Deiziadoù a-bouez"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Gwezennadur"
@@ -79,7 +79,7 @@ msgstr "Doare korf"
msgid "Format"
msgstr "Stumm"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "eilenn {title}"
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr "anv klok"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "anv berr"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "postel identelezh"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "postel ar merour"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "yezh"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "Ar yezh a vo implijet evit etrefas an implijer."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Ar gwerzhid-eur a vo implijet evit etrefas an implijer."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "trevnad"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Pe vefe an implijer un aparailh pe un implijer gwirion."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "statud ar skipailh"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Ma c'hall an implijer kevreañ ouzh al lec'hienn verañ-mañ."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "oberiant"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ma rank bezañ tretet an implijer-mañ evel oberiant. Diziuzit an dra-mañ e-plas dilemel kontoù."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "implijer"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "implijerien"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "titl"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "bomm"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Restr"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Restroù"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Restr hep titl"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} en deus rannet ur restr ganeoc'h!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} en deus pedet ac'hanoc'h gant ar rol \"{role}\" war ar restr da-heul:"
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} en deus rannet ur restr ganeoc'h: {title}"
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Roud liamm ar restr/an implijer"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Roudoù liamm ar restr/an implijer"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "Ur roud liamm a zo dija evit an restr/an implijer."
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Restr muiañ-karet"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Restroù muiañ-karet"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Ar restr-mañ a zo ur restr muiañ karet gant an implijer-mañ."
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Liamm restr/implijer"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Liammoù restr/implijer"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "An implijer-mañ a zo dija er restr-mañ."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr "An implijer pe ar skipailh a rank bezañ termenet, ket an daou avat."
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Goulenn tizhout ar restr"
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Goulennoù tizhout ar restr"
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr "An implijer en deus goulennet tizhout ar restr-mañ."
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} en defe c'hoant da dizhout ar restr-mañ!"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} en defe c'hoant da dizhout ar restr da-heul:"
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} en defe c'hoant da dizhout ar restr: {title}"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "deskrivadur"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "kod"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "publik"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr "M'eo foran ar patrom-mañ hag implijus gant n'eus forzh piv."
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Patrom"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Patromoù"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "Liamm patrom/implijer"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "Liammoù patrom/implijer"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "An implijer-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "postel"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Pedadenn d'ur restr"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Pedadennoù d'ur restr"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Persönliche Daten"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Berechtigungen"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Wichtige Daten"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Baumstruktur"
@@ -79,7 +79,7 @@ msgstr "Typ"
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "Kopie von {title}"
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr "Name"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "Kurzbezeichnung"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "Admin E-Mail-Adresse"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "Sprache"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "Gerät"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "Status des Teammitgliedes"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "aktiviert"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "Benutzer"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "Titel"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "Auszug"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "Für dieses Dokument/ diesen Benutzer ist bereits eine Linkverfolgung vorhanden."
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr ""
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr ""
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr ""
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr ""
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr ""
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr ""
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr ""
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr ""
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr ""
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr ""
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr ""
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr ""
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Información Personal"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Permisos"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Fechas importantes"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Estructura en árbol"
@@ -79,7 +79,7 @@ msgstr "Tipo de Cuerpo"
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "copia de {title}"
@@ -178,228 +178,228 @@ msgstr "Obligatorio. 255 caracteres o menos. Solo caracteres ASCII."
msgid "full name"
msgstr "nombre completo"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "nombre abreviado"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "correo electrónico de identidad"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "correo electrónico del administrador"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "idioma"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "El idioma en el que el usuario desea ver la interfaz."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "La zona horaria en la que el usuario quiere ver los tiempos."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "dispositivo"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Si el usuario es un dispositivo o un usuario real."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "rol en el equipo"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Si el usuario puede iniciar sesión en esta página web de administración."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "activo"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si este usuario debe ser considerado como activo. Deseleccionar en lugar de eliminar cuentas."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "usuario"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "usuarios"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "título"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "resumen"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Documentos"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Documento sin título"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "¡{name} ha compartido un documento contigo!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "Te ha invitado {name} al siguiente documento con el rol \"{role}\" :"
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha compartido un documento contigo: {title}"
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Traza del enlace de documento/usuario"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Trazas del enlace de documento/usuario"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "Ya existe una traza de enlace para este documento/usuario."
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Documento favorito"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Documentos favoritos"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Este documento ya ha sido marcado como favorito por el usuario."
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Relación documento/usuario"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Relaciones documento/usuario"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Este usuario ya forma parte del documento."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Este equipo ya forma parte del documento."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr "Debe establecerse un usuario o un equipo, no ambos."
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Solicitud de acceso"
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Solicitud de accesos"
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr "Este usuario ya ha solicitado acceso a este documento."
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "¡{name} desea acceder a un documento!"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} desea acceso al siguiente documento:"
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} está pidiendo acceso al documento: {title}"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "descripción"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "código"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "público"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr "Si esta plantilla es pública para que cualquiera la utilice."
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Plantilla"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Plantillas"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "Relación plantilla/usuario"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "Relaciones plantilla/usuario"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "Este usuario ya forma parte de la plantilla."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Este equipo ya se encuentra en esta plantilla."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "dirección de correo electrónico"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Invitación al documento"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Invitaciones a documentos"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Este correo electrónico está asociado a un usuario registrado."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Infos Personnelles"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Permissions"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Arborescence"
@@ -79,7 +79,7 @@ msgstr "Type de corps"
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "copie de {title}"
@@ -178,228 +178,228 @@ msgstr "Obligatoire. 255 caractères ou moins. Caractères ASCII uniquement."
msgid "full name"
msgstr "nom complet"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "nom court"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "adresse e-mail de l'administrateur"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "langue"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "appareil"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "statut d'équipe"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "actif"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "utilisateur"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "utilisateurs"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "titre"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "extrait"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Document"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Documents"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Document sans titre"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant :"
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} a partagé un document avec vous : {title}"
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Trace du lien document/utilisateur"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Traces du lien document/utilisateur"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "Une trace de lien existe déjà pour ce document/utilisateur."
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Document favori"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Documents favoris"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Ce document est déjà un favori de cet utilisateur."
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Relation document/utilisateur"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Relations document/utilisateur"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Cet utilisateur est déjà dans ce document."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Cette équipe est déjà dans ce document."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr "L'utilisateur ou l'équipe doivent être définis, pas les deux."
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr "Cet utilisateur a déjà demandé l'accès à ce document."
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} souhaiterait accéder au document suivant !"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} souhaiterait accéder au document suivant :"
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} demande l'accès au document : {title}"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "description"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "public"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr "Si ce modèle est public, utilisable par n'importe qui."
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Modèle"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Modèles"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "Relation modèle/utilisateur"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "Relations modèle/utilisateur"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "Cet utilisateur est déjà dans ce modèle."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Cette équipe est déjà modèle."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "adresse e-mail"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Invitation à un document"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Invitations à un document"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Informazioni personali"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Permessi"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Date importanti"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Struttura ad albero"
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "copia di {title}"
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr "nome completo"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "nome"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "indirizzo email di identità"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "Indirizzo email dell'amministratore"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "lingua"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "La lingua in cui l'utente vuole vedere l'interfaccia."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Il fuso orario in cui l'utente vuole vedere gli orari."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "dispositivo"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Se l'utente è un dispositivo o un utente reale."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "stato del personale"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Indica se l'utente può accedere a questo sito amministratore."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "attivo"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Indica se questo utente deve essere trattato come attivo. Deseleziona invece di eliminare gli account."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "utente"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "utenti"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "titolo"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Documenti"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Documento senza titolo"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ha condiviso un documento con te!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} ti ha invitato con il ruolo \"{role}\" nel seguente documento:"
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha condiviso un documento con te: {title}"
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Documento preferito"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Documenti preferiti"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Questo utente è già presente in questo documento."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Questo team è già presente in questo documento."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "descrizione"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "pubblico"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr "Indica se questo modello è pubblico per chiunque."
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Modello"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Modelli"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "Questo utente è già in questo modello."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Questo team è già in questo modello."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "indirizzo e-mail"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Invito al documento"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Inviti al documento"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Questa email è già associata a un utente registrato."
+102 -102
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -17,22 +17,22 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Toestemmingen"
msgstr "Machtigingen"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Belangrijke datums"
msgstr "Belangrijke data"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Document structuur"
msgstr "Boomstructuur"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
@@ -40,7 +40,7 @@ msgstr "Titel"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Ik ben Eigenaar"
msgstr "Ik ben eigenaar"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Masked"
@@ -48,15 +48,15 @@ msgstr "Gemaskeerd"
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
msgid "Favorite"
msgstr "Favoriete"
msgstr "Favoriet"
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
msgid "A new document was created on your behalf!"
msgstr "Een nieuw document was gecreëerd voor u!"
msgstr "Een nieuw document is namens u gemaakt!"
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
msgid "You have been granted ownership of a new document:"
msgstr "U heeft eigenaarschap van een nieuw document:"
msgstr "U heeft eigenaarschap van een nieuw document gekregen:"
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
@@ -79,7 +79,7 @@ msgstr "Text type"
msgid "Format"
msgstr "Formaat"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "kopie van {title}"
@@ -92,11 +92,11 @@ msgstr "Lezer"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Bewerker"
msgstr "Redacteur"
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Administrator"
msgstr "Beheerder"
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
@@ -104,7 +104,7 @@ msgstr "Eigenaar"
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Niet toegestaan"
msgstr "Beperkt"
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
@@ -148,11 +148,11 @@ msgstr "primaire sleutel voor dossier als UUID"
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr "gemaakt op"
msgstr "gecreëerd op"
#: build/lib/core/models.py:88 core/models.py:88
msgid "date and time at which a record was created"
msgstr "datum en tijd wanneer dossier was gecreëerd"
msgstr "datum en tijd waarop dossier is gecreeërd"
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
@@ -164,7 +164,7 @@ msgstr "datum en tijd waarop dossier laatst was gewijzigd"
#: build/lib/core/models.py:130 core/models.py:130
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "Wij konden geen gebruiker vinden met deze id, maar de email is al geassocieerd met een geregistreerde gebruiker."
msgstr "Wij konden geen gebruiker vinden met dit id, maar de email is al geassocieerd met een geregistreerde gebruiker."
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
@@ -172,234 +172,234 @@ msgstr "id"
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr "Vereist. Minder dan 255 ASCII tekens."
msgstr "Vereist. 255 tekens of minder. Alleen ASCII tekens."
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr "volledige naam"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "gebruikersnaam"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "identiteit email adres"
msgstr "identiteit emailadres"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "admin email adres"
msgstr "admin emailadres"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "taal"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wilt zien."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker de tijden wilt zien."
msgstr "De tijdzone waarin de gebruiker de tijden wil zien."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "apparaat"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "beheerder status"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen in het admin gedeelte."
msgstr "Of de gebruiker kan inloggen in het beheer gedeelte."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "actief"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Of een gebruiker als actief moet worden beschouwd. Deselecteer dit in plaats van het account te deleten."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "gebruiker"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "gebruikers"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "titel"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "uittreksel"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Document"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Documenten"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Naamloos Document"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} heeft een document met gedeeld!"
msgstr "{name} heeft een document met u gedeeld!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} heeft u uitgenodigd met de rol \"{role}\" op het volgende document:"
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} heeft een document met u gedeeld: {title}"
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Document/gebruiker url"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Document/gebruiker url"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "Een url bestaat al voor dit document/deze gebruiker."
msgstr "Een link bestaat al voor dit document/deze gebruiker."
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Document favoriet"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Document favorieten"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dit document is al in gebruik als favoriete door dezelfde gebruiker."
msgstr "Dit document is al in gebruik als favoriet door dezelfde gebruiker."
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Document/gebruiker relatie"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Document/gebruiker relaties"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "De gebruiker is al in dit document."
msgstr "De gebruiker bestaat al in dit document."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Het team is al in dit document."
msgstr "Dit team bestaat al in dit document."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr "Een gebruiker of team moet gekozen worden, maar niet beide."
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Document verzoekt om toegang"
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Document verzoekt om toegangen"
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr "Deze gebruiker heeft al om toegang tot dit document gevraagd."
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} wenst toegang tot een document!"
msgstr "{name} verzoekt toegang tot een document!"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} wenst toegang tot het volgende document:"
msgstr "{name} verzoekt toegang tot het volgende document:"
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} vraagt toegang tot het document: {title}"
msgstr "{name} verzoekt toegang tot het document: {title}"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "omschrijving"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "publiek"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr "Of dit template als publiek is en door iedereen te gebruiken is."
msgstr "Of dit sjabloon door iedereen publiekelijk te gebruiken is."
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Template"
msgstr "Sjabloon"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Templates"
msgstr "Sjabloon"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "Template/gebruiker relatie"
msgstr "Sjabloon/gebruiker relatie"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "Template/gebruiker relaties"
msgstr "Sjabloon/gebruiker relaties"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "De gebruiker bestaat al in dit template."
msgstr "De gebruiker bestaat al in dit sjabloon."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Het team bestaat al in dit template."
msgstr "Het team bestaat al in dit sjabloon."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "email adres"
msgstr "e-mailadres"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Document uitnodiging"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Document uitnodigingen"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Informações Pessoais"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Permissões"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Datas importantes"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Estrutura de árvore"
@@ -79,7 +79,7 @@ msgstr "Tipo de corpo"
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "cópia de {title}"
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr ""
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr ""
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr ""
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr ""
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr ""
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr ""
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr ""
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr ""
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr ""
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr ""
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr ""
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Личная информация"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Разрешения"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Важные даты"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Древовидная структура"
@@ -79,7 +79,7 @@ msgstr "Тип сообщения"
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "копия {title}"
@@ -178,228 +178,228 @@ msgstr "Обязательно. 255 символов или меньше. Тол
msgid "full name"
msgstr "полное имя"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "короткое имя"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "личный адрес электронной почты"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "e-mail администратора"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "язык"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "Язык, на котором пользователь хочет видеть интерфейс."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Часовой пояс, в котором пользователь хочет видеть время."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "устройство"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Пользователь является устройством или человеком."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "статус сотрудника"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Может ли пользователь войти на этот административный сайт."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "активный"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Должен ли пользователь рассматриваться как активный. Альтернатива удалению учётных записей."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "пользователь"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "пользователи"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "заголовок"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "отрывок"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Документ"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Документы"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Безымянный документ"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} делится с вами документом!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} делится с вами документом: {title}"
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Трассировка связи документ/пользователь"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Трассировка связей документ/пользователь"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Избранный документ"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Избранные документы"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Этот документ уже помечен как избранный для этого пользователя."
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Отношение документ/пользователь"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Отношения документ/пользователь"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Этот пользователь уже имеет доступ к этому документу."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Эта команда уже имеет доступ к этому документу."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Документ запрашивает доступ"
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Документ запрашивает доступы"
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr "Этот пользователь уже запросил доступ к этому документу."
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хочет получить доступ к документу!"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} хочет получить доступ к следующему документу:"
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запрашивает доступ к документу: {title}"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "описание"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "доступно всем"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr "Этот шаблон доступен всем пользователям."
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Шаблоны"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "Отношение шаблон/пользователь"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "Отношения шаблон/пользователь"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "Этот пользователь уже указан в этом шаблоне."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Эта команда уже указана в этом шаблоне."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "адрес электронной почты"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Приглашение для документа"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Приглашения для документов"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Этот адрес уже связан с зарегистрированным пользователем."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Osebni podatki"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Dovoljenja"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Pomembni datumi"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Drevesna struktura"
@@ -79,7 +79,7 @@ msgstr "Vrsta telesa"
msgid "Format"
msgstr "Oblika"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr "polno ime"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "kratko ime"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "elektronski naslov identitete"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "elektronski naslov skrbnika"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "jezik"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "Jezik, v katerem uporabnik želi videti vmesnik."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Časovni pas, v katerem želi uporabnik videti uro."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "naprava"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Ali je uporabnik naprava ali pravi uporabnik."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "kadrovski status"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Ali se uporabnik lahko prijavi na to skrbniško mesto."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "aktivni"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ali je treba tega uporabnika obravnavati kot aktivnega. Namesto brisanja računov počistite to izbiro."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "uporabnik"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "uporabniki"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "naslov"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "odlomek"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Dokumenti"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Dokument brez naslova"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} je delil dokument z vami!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} vas je povabil z vlogo \"{role}\" na naslednjem dokumentu:"
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} je delil dokument z vami: {title}"
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Dokument/sled povezave uporabnika"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Sledi povezav dokumenta/uporabnika"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Priljubljeni dokument"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Priljubljeni dokumenti"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Ta dokument je že ciljno usmerjen s priljubljenim primerkom relacije za istega uporabnika."
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Odnos dokument/uporabnik"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Odnosi dokument/uporabnik"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Ta uporabnik je že v tem dokumentu."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Ta ekipa je že v tem dokumentu."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr "Nastaviti je treba bodisi uporabnika ali ekipo, a ne obojega."
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "opis"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "koda"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "javno"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr "Ali je ta predloga javna za uporabo."
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Predloga"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Predloge"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "Odnos predloga/uporabnik"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "Odnosi med predlogo in uporabnikom"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "Ta uporabnik je že v tej predlogi."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Ta ekipa je že v tej predlogi."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "elektronski naslov"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Vabilo na dokument"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Vabila na dokument"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Personuppgifter"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Behörigheter"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Viktiga datum"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr ""
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr ""
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr ""
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr ""
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr ""
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "aktiv"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr ""
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr ""
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr ""
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr ""
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr ""
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "e-postadress"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Bjud in dokument"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Inbjudningar dokument"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Denna e-postadress är redan associerad med en registrerad användare."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr ""
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr ""
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr ""
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr ""
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr ""
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr ""
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr ""
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr ""
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr ""
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr ""
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr ""
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr ""
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "Особисті дані"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "Дозволи"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "Важливі дати"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "Ієрархічна структура"
@@ -79,7 +79,7 @@ msgstr "Тип вмісту"
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "копія {title}"
@@ -178,228 +178,228 @@ msgstr "Обов'язкове. 255 символів або менше. Тіль
msgid "full name"
msgstr "повне ім'я"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "коротке ім'я"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "адреса електронної пошти особи"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "електронна адреса адміністратора"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "мова"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "Мова, якою користувач хоче бачити інтерфейс."
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Часовий пояс, в якому користувач хоче бачити час."
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "пристрій"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Чи є користувач пристроєм чи реальним користувачем."
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "статус співробітника"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Чи може користувач увійти на цей сайт адміністратора."
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "активний"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Чи слід ставитися до цього користувача як до активного. Зніміть вибір замість видалення облікового запису."
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "користувач"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "користувачі"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "заголовок"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "уривок"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "Документ"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "Документи"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Документ без назви"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ділиться з вами документом!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ділиться з вами документом: {title}"
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Трасування посилання Документ/користувач"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Трасування посилань Документ/користувач"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Обраний документ"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Обрані документи"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Цей документ вже вказаний як обраний для одного користувача."
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Цей користувач вже має доступ до цього документу."
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Ця команда вже має доступ до цього документа."
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr "Вкажіть користувача або команду, а не обох."
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Запит доступу до документа"
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Запит доступу для документа"
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr "Цей користувач вже попросив доступ до цього документа."
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хоче отримати доступ до документа!"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} бажає отримати доступ до наступного документа:"
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запитує доступ до документа: {title}"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "опис"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "публічне"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr "Чи є цей шаблон публічним для будь-кого користувача."
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Шаблони"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "Цей користувач вже має доступ до цього шаблону."
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Ця команда вже має доступ до цього шаблону."
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "електронна адреса"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Запрошення до редагування документа"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Запрошення до редагування документів"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:37 core/admin.py:37
#: build/lib/core/admin.py:36 core/admin.py:36
msgid "Personal info"
msgstr "个人信息"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
msgid "Permissions"
msgstr "权限"
#: build/lib/core/admin.py:62 core/admin.py:62
#: build/lib/core/admin.py:61 core/admin.py:61
msgid "Important dates"
msgstr "重要日期"
#: build/lib/core/admin.py:148 core/admin.py:148
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "树状结构"
@@ -79,7 +79,7 @@ msgstr "正文类型"
msgid "Format"
msgstr "格式"
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "{title} 的副本"
@@ -178,228 +178,228 @@ msgstr "必填项。限255个字符以内。仅支持ASCII字符。"
msgid "full name"
msgstr "全名"
#: build/lib/core/models.py:151 core/models.py:151
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "简称"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "身份电子邮件地址"
#: build/lib/core/models.py:158 core/models.py:158
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "管理员电子邮件地址"
#: build/lib/core/models.py:165 core/models.py:165
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "语言"
#: build/lib/core/models.py:166 core/models.py:166
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "用户希望看到的界面语言。"
#: build/lib/core/models.py:174 core/models.py:174
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "用户查看时间希望的时区。"
#: build/lib/core/models.py:177 core/models.py:177
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "设备"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "用户是设备还是真实用户。"
#: build/lib/core/models.py:182 core/models.py:182
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "员工状态"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "用户是否可以登录该管理员站点。"
#: build/lib/core/models.py:187 core/models.py:187
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "激活"
#: build/lib/core/models.py:190 core/models.py:190
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "是否应将此用户视为活跃用户。取消选择此选项而不是删除账户。"
#: build/lib/core/models.py:202 core/models.py:202
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "用户"
#: build/lib/core/models.py:203 core/models.py:203
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "个用户"
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "标题"
#: build/lib/core/models.py:360 core/models.py:360
#: build/lib/core/models.py:362 core/models.py:362
msgid "excerpt"
msgstr "摘要"
#: build/lib/core/models.py:409 core/models.py:409
#: build/lib/core/models.py:411 core/models.py:411
msgid "Document"
msgstr "文档"
#: build/lib/core/models.py:410 core/models.py:410
#: build/lib/core/models.py:412 core/models.py:412
msgid "Documents"
msgstr "个文档"
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "未命名文档"
#: build/lib/core/models.py:855 core/models.py:855
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} 与您共享了一个文档!"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} 邀请您以“{role}”角色访问以下文档:"
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} 与您共享了一个文档:{title}"
#: build/lib/core/models.py:965 core/models.py:965
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "文档/用户链接跟踪"
#: build/lib/core/models.py:966 core/models.py:966
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "个文档/用户链接跟踪"
#: build/lib/core/models.py:972 core/models.py:972
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "此文档/用户的链接跟踪已存在。"
#: build/lib/core/models.py:995 core/models.py:995
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "文档收藏"
#: build/lib/core/models.py:996 core/models.py:996
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "文档收藏夹"
#: build/lib/core/models.py:1002 core/models.py:1002
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "该文档已被同一用户的收藏关系实例关联。"
#: build/lib/core/models.py:1024 core/models.py:1024
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "文档/用户关系"
#: build/lib/core/models.py:1025 core/models.py:1025
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "文档/用户关系集"
#: build/lib/core/models.py:1031 core/models.py:1031
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "该用户已在此文档中。"
#: build/lib/core/models.py:1037 core/models.py:1037
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "该团队已在此文档中。"
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr "必须设置用户或团队之一,不能同时设置两者。"
#: build/lib/core/models.py:1189 core/models.py:1189
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "文档需要访问权限"
#: build/lib/core/models.py:1190 core/models.py:1190
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "文档需要访问权限"
#: build/lib/core/models.py:1196 core/models.py:1196
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr "用户已申请该文档的访问权限。"
#: build/lib/core/models.py:1261 core/models.py:1261
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} 申请访问文档!"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} 申请访问以下文档:"
#: build/lib/core/models.py:1271 core/models.py:1271
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name}申请文档:{title}的访问权限"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "说明"
#: build/lib/core/models.py:1284 core/models.py:1284
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "代码"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "公开"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr "该模板是否公开供任何人使用。"
#: build/lib/core/models.py:1295 core/models.py:1295
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "模板"
#: build/lib/core/models.py:1296 core/models.py:1296
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "模板"
#: build/lib/core/models.py:1349 core/models.py:1349
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "模板/用户关系"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "模板/用户关系集"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "该用户已在此模板中。"
#: build/lib/core/models.py:1362 core/models.py:1362
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "该团队已在此模板中。"
#: build/lib/core/models.py:1439 core/models.py:1439
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "电子邮件地址"
#: build/lib/core/models.py:1458 core/models.py:1458
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "文档邀请"
#: build/lib/core/models.py:1459 core/models.py:1459
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "文档邀请"
#: build/lib/core/models.py:1479 core/models.py:1479
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "此电子邮件已经与现有注册用户关联。"
+30 -30
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "3.8.2"
version = "3.9.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,42 +25,42 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.13.4",
"boto3==1.39.4",
"Brotli==1.1.0",
"beautifulsoup4==4.14.2",
"boto3==1.40.59",
"Brotli==1.2.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-cors-headers==4.9.0",
"django-countries==7.6.1",
"django-csp==4.0",
"django-filter==25.1",
"django-lasuite[all]==0.0.14",
"django-filter==25.2",
"django-lasuite[all]==0.0.16",
"django-parler==2.3",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.7",
"django==5.2.8",
"django-treebeard==4.7.1",
"djangorestframework==3.16.0",
"djangorestframework==3.16.1",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.24.0",
"lxml==6.0.0",
"markdown==3.8.2",
"jsonschema==4.25.1",
"lxml==6.0.2",
"markdown==3.9",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"openai==1.95.0",
"psycopg[binary]==3.2.9",
"pycrdt==0.12.25",
"nested-multipart-parser==1.6.0",
"openai==2.6.1",
"psycopg[binary]==3.2.12",
"pycrdt==0.12.42",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.4",
"sentry-sdk==2.32.0",
"whitenoise==6.9.0",
"requests==2.32.5",
"sentry-sdk==2.42.1",
"whitenoise==6.11.0",
]
[project.urls]
@@ -73,21 +73,21 @@ dependencies = [
dev = [
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.7.1",
"freezegun==1.5.2",
"drf-spectacular-sidecar==2025.10.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.4.0",
"pyfakefs==5.9.1",
"ipython==9.6.0",
"pyfakefs==5.10.0",
"pylint-django==2.6.1",
"pylint==3.3.7",
"pytest-cov==6.2.1",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==8.4.1",
"pytest==8.4.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.7",
"ruff==0.12.2",
"types-requests==2.32.4.20250611",
"responses==0.25.8",
"ruff==0.14.2",
"types-requests==2.32.4.20250913",
]
[tool.setuptools]
@@ -494,7 +494,7 @@ test.describe('Doc Editor', () => {
if (request.method().includes('GET')) {
await route.fulfill({
json: {
status: requestCount ? 'ready' : 'processing',
status: requestCount > 1 ? 'ready' : 'processing',
file: '/anything.html',
},
});
@@ -518,6 +518,12 @@ test.describe('Doc Editor', () => {
await fileChooser.setFiles(path.join(__dirname, 'assets/test.html'));
await expect(editor.getByText('Analyzing file...')).toBeVisible();
// To be sure the retry happens even after a page reload
await page.reload();
await expect(editor.getByText('Analyzing file...')).toBeVisible();
// The retry takes a few seconds
await expect(editor.getByText('test.html')).toBeVisible({
timeout: 7000,
@@ -800,6 +806,12 @@ test.describe('Doc Editor', () => {
});
await expect(interlinkChild1).toBeVisible({ timeout: 10000 });
await expect(interlinkChild1.locator('svg').first()).toBeVisible();
await page.keyboard.press('@');
await page.keyboard.press('Escape');
await expect(editor.getByText('@')).toBeVisible();
});
test('it checks multiple big doc scroll to the top', async ({
@@ -2,12 +2,11 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import { pdf } from 'pdf-parse';
import { PDFParse } from 'pdf-parse';
import {
TestLanguage,
createDoc,
randomName,
verifyDocName,
waitForLanguageSwitch,
} from './utils-common';
@@ -32,7 +31,7 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
page.getByText('Download your document in a .docx or .pdf format.'),
page.getByText('Download your document in a .docx, .odt or .pdf format.'),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
@@ -86,11 +85,16 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfInfo = await pdfParse.getInfo();
const pdfText = await pdfParse.getText();
expect(pdfData.total).toBe(2);
expect(pdfData.text).toContain('Hello\n\nWorld\n\n'); // This is the doc text
expect(pdfData.info?.Title).toBe(randomDoc);
expect(pdfInfo.total).toBe(2);
expect(pdfText.pages).toStrictEqual([
{ text: 'Hello', num: 1 },
{ text: 'World', num: 2 },
]);
expect(pdfInfo?.info.Title).toBe(randomDoc);
});
test('it exports the doc to docx', async ({ page, browserName }) => {
@@ -138,6 +142,51 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
});
test('it exports the doc to odt', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor-odt', browserName, 1);
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World ODT');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeVisible();
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Odt' }).click();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.odt`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.odt`);
});
/**
* This test tell us that the export to pdf is working with images
* but it does not tell us if the images are being displayed correctly
@@ -219,10 +268,10 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfExport = await pdf(pdfBuffer);
const pdfText = pdfExport.text;
expect(pdfText).toContain('Hello World');
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain('Hello World');
});
test('it exports the doc with quotes', async ({ page, browserName }) => {
@@ -265,9 +314,9 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
expect(pdfData.text).toContain('Hello World'); // This is the pdf text
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain('Hello World');
});
test('it exports the doc with multi columns', async ({
@@ -320,46 +369,33 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
expect(pdfData.text).toContain('Column 1');
expect(pdfData.text).toContain('Column 2');
expect(pdfData.text).toContain('Column 3');
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain('Column 1');
expect(pdfText.text).toContain('Column 2');
expect(pdfText.text).toContain('Column 3');
});
test('it injects the correct language attribute into PDF export', async ({
page,
browserName,
}) => {
const [randomDocFrench] = await createDoc(
page,
'doc-language-export-french',
browserName,
1,
);
await waitForLanguageSwitch(page, TestLanguage.French);
// Wait for the page to be ready after language switch
await page.waitForLoadState('domcontentloaded');
const header = page.locator('header').first();
await header.locator('h1').getByText('Docs').click();
const randomDocFrench = randomName(
'doc-language-export-french',
browserName,
1,
)[0];
await page
.getByRole('button', {
name: 'Nouveau doc',
})
.click();
const input = page.getByRole('textbox', { name: 'Titre du document' });
await expect(input).toBeVisible();
await expect(input).toHaveText('', { timeout: 10000 });
await input.click();
await input.fill(randomDocFrench);
await input.blur();
const editor = page.locator('.ProseMirror.bn-editor');
await editor.click();
await editor.fill('Contenu de test pour export en français');
await writeInEditor({
page,
text: 'Contenu de test pour export en français',
});
await page
.getByRole('button', {
@@ -447,8 +483,72 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${docChild}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain(randomDoc);
});
expect(pdfData.text).toContain(randomDoc);
test('it exports the doc with interlinking to odt', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'export-interlinking-odt',
browserName,
1,
);
await verifyDocName(page, randomDoc);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'export-interlink-child-odt',
);
await verifyDocName(page, docChild);
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Link a doc').first().click();
const input = page.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
);
const searchContainer = page.locator('.quick-search-container');
await input.fill('export-interlink');
await expect(searchContainer).toBeVisible();
await expect(searchContainer.getByText(randomDoc)).toBeVisible();
// We are in docChild, we want to create a link to randomDoc (parent)
await searchContainer.getByText(randomDoc).click();
// Search the interlinking link in the editor (not in the document tree)
const editor = page.locator('.ProseMirror.bn-editor');
const interlink = editor.getByRole('button', {
name: randomDoc,
});
await expect(interlink).toBeVisible();
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Odt' }).click();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${docChild}.odt`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${docChild}.odt`);
});
});
@@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import { createDoc, getGridRow } from './utils-common';
import { createDoc, getGridRow, verifyDocName } from './utils-common';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
type SmallDoc = {
id: string;
@@ -11,7 +12,7 @@ test.describe('Documents Grid mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test('it checks the grid when mobile', async ({ page }) => {
await page.route('**/documents/**', async (route) => {
await page.route(/.*\/documents\/.*/, async (route) => {
const request = route.request();
if (request.method().includes('GET') && request.url().includes('page=')) {
await route.fulfill({
@@ -133,6 +134,8 @@ test.describe('Document grid item options', () => {
test('it deletes the document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `delete doc`, browserName);
await verifyDocName(page, docTitle);
await page.goto('/');
await expect(page.getByText(docTitle)).toBeVisible();
@@ -161,7 +164,7 @@ test.describe('Document grid item options', () => {
test("it checks if the delete option is disabled if we don't have the destroy capability", async ({
page,
}) => {
await page.route('*/**/api/v1.0/documents/?page=1', async (route) => {
await page.route(/.*\/api\/v1.0\/documents\/\?page=1/, async (route) => {
await route.fulfill({
json: {
results: [
@@ -192,90 +195,68 @@ test.describe('Document grid item options', () => {
});
await page.goto('/');
const button = page.getByTestId(
`docs-grid-actions-button-mocked-document-id`,
);
const button = page
.getByTestId(`docs-grid-actions-button-mocked-document-id`)
.first();
await expect(button).toBeVisible();
await button.click();
const removeButton = page.getByTestId(
`docs-grid-actions-remove-mocked-document-id`,
);
const removeButton = page
.getByTestId(`docs-grid-actions-remove-mocked-document-id`)
.first();
await expect(removeButton).toBeVisible();
await removeButton.isDisabled();
});
});
test.describe('Documents filters', () => {
test('it checks the prebuild left panel filters', async ({ page }) => {
test('it checks the left panel filters', async ({ page, browserName }) => {
void page.goto('/');
// Create my doc
const [docName] = await createDoc(page, 'my-doc', browserName, 1);
await verifyDocName(page, docName);
// Another user create a doc and share it with me
const { cleanup, otherPage, otherBrowserName } =
await connectOtherUserToDoc({
browserName,
docUrl: '/',
});
const [docShareName] = await createDoc(
otherPage,
'my-share-doc',
otherBrowserName,
1,
);
await verifyDocName(otherPage, docShareName);
await otherPage.getByRole('button', { name: 'Share' }).click();
await addNewMember(otherPage, 0, 'Editor', browserName);
// Let's check the filters
await page.getByRole('button', { name: 'Back to homepage' }).click();
const row = await getGridRow(page, docName);
const rowShare = await getGridRow(page, docShareName);
// All Docs
const response = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
const allCount = result.count as number;
await expect(page.getByTestId('grid-loader')).toBeHidden();
await expect(row).toBeVisible();
await expect(rowShare).toBeVisible();
const allDocs = page.getByLabel('All docs');
const myDocs = page.getByLabel('My docs');
const sharedWithMe = page.getByLabel('Shared with me');
// Initial state
await expect(allDocs).toBeVisible();
await expect(allDocs).toHaveAttribute('aria-current', 'page');
await expect(myDocs).toBeVisible();
await expect(myDocs).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await expect(myDocs).not.toHaveAttribute('aria-current');
await expect(sharedWithMe).toBeVisible();
await expect(sharedWithMe).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
await expect(sharedWithMe).not.toHaveAttribute('aria-current');
await allDocs.click();
await page.waitForURL('**/?target=all_docs');
let url = new URL(page.url());
let target = url.searchParams.get('target');
expect(target).toBe('all_docs');
// My docs
await myDocs.click();
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('my_docs');
const responseMyDocs = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1&is_creator_me=true') &&
response.status() === 200,
);
const resultMyDocs = await responseMyDocs.json();
const countMyDocs = resultMyDocs.count as number;
await expect(page.getByTestId('grid-loader')).toBeHidden();
expect(countMyDocs).toBeLessThanOrEqual(allCount);
// My Docs
await page.getByRole('link', { name: 'My docs' }).click();
await expect(row).toBeVisible();
await expect(rowShare).toBeHidden();
// Shared with me
await sharedWithMe.click();
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('shared_with_me');
const responseSharedWithMe = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1&is_creator_me=false') &&
response.status() === 200,
);
const resultSharedWithMe = await responseSharedWithMe.json();
const countSharedWithMe = resultSharedWithMe.count as number;
await expect(page.getByTestId('grid-loader')).toBeHidden();
expect(countSharedWithMe).toBeLessThanOrEqual(allCount);
expect(countSharedWithMe + countMyDocs).toEqual(allCount);
await page.getByRole('link', { name: 'Shared with me' }).click();
await expect(row).toBeHidden();
await expect(rowShare).toBeVisible();
await cleanup();
});
});
@@ -68,9 +68,18 @@ test.describe('Doc Header', () => {
await createDoc(page, 'doc-update-emoji', browserName, 1);
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
const optionMenu = page.getByLabel('Open the document options');
const addEmojiMenuItem = page.getByRole('menuitem', { name: 'Add emoji' });
const removeEmojiMenuItem = page.getByRole('menuitem', {
name: 'Remove emoji',
});
// Top parent should not have emoji picker
await expect(emojiPicker).toBeHidden();
await optionMenu.click();
await expect(addEmojiMenuItem).toBeHidden();
await expect(removeEmojiMenuItem).toBeHidden();
await page.keyboard.press('Escape');
const { name: docChild } = await createRootSubPage(
page,
@@ -80,13 +89,23 @@ test.describe('Doc Header', () => {
await verifyDocName(page, docChild);
await expect(emojiPicker).toBeVisible();
// Emoji picker should be hidden initially
await expect(emojiPicker).toBeHidden();
// Add emoji
await optionMenu.click();
await expect(removeEmojiMenuItem).toBeHidden();
await addEmojiMenuItem.click();
await expect(emojiPicker).toHaveText('📄');
// Change emoji
await emojiPicker.click({
delay: 100,
});
await page.getByRole('button', { name: '😀' }).first().click();
await expect(emojiPicker).toHaveText('😀');
// Update title
const docTitle = page.getByRole('textbox', { name: 'Document title' });
await docTitle.fill('Hello Emoji World');
await docTitle.blur();
@@ -95,6 +114,12 @@ test.describe('Doc Header', () => {
// Check the tree
const row = await getTreeRow(page, 'Hello Emoji World');
await expect(row.getByText('😀')).toBeVisible();
// Remove emoji
await optionMenu.click();
await expect(addEmojiMenuItem).toBeHidden();
await removeEmojiMenuItem.click();
await expect(emojiPicker).toBeHidden();
});
test('it deletes the doc', async ({ page, browserName }) => {
@@ -31,7 +31,7 @@ test.describe('Document list members', () => {
return cleanUrl.split('/').pop() || '';
})();
await page.route('**/documents/**/accesses/', async (route) => {
await page.route(/.*\/documents\/.*\/accesses\//, async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page') ?? '1';
@@ -119,6 +119,10 @@ test.describe('Doc Trashbin', () => {
await row.getByText(subDocName).click();
await verifyDocName(page, subDocName);
await expect(
page.locator('.--docs--editor-container.--docs--doc-deleted'),
).toBeVisible();
await expect(page.getByLabel('Alert deleted document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeDisabled();
await expect(page.locator('.bn-editor')).toHaveAttribute(
@@ -352,7 +352,7 @@ test.describe('Doc Tree', () => {
await page.getByRole('menuitem', { name: 'Remove emoji' }).click();
await expect(row.getByText('😀')).toBeHidden();
await expect(titleEmojiPicker).not.toHaveText('😀');
await expect(titleEmojiPicker).toBeHidden();
});
});
@@ -7,7 +7,7 @@ import {
keyCloakSignIn,
verifyDocName,
} from './utils-common';
import { writeInEditor } from './utils-editor';
import { getEditor, writeInEditor } from './utils-editor';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
@@ -182,15 +182,14 @@ test.describe('Doc Visibility: Restricted', () => {
});
test.describe('Doc Visibility: Public', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('It checks a public doc in read only mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Public read only',
@@ -200,6 +199,8 @@ test.describe('Doc Visibility: Public', () => {
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello Public Viewonly' });
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
@@ -241,49 +242,67 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByTestId('search-docs-button')).toBeVisible();
await expect(page.getByTestId('new-doc-button')).toBeVisible();
const urlDoc = page.url();
const docUrl = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl,
withoutSignIn: true,
});
await expectLoginPage(page);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByTestId('search-docs-button')).toBeHidden();
await expect(page.getByTestId('new-doc-button')).toBeHidden();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
const card = page.getByLabel('It is the card information');
await expect(otherPage.locator('h2').getByText(docTitle)).toBeVisible();
await expect(otherPage.getByTestId('search-docs-button')).toBeHidden();
await expect(otherPage.getByTestId('new-doc-button')).toBeHidden();
await expect(
otherPage.getByRole('button', { name: 'Share' }),
).toBeVisible();
const card = otherPage.getByLabel('It is the card information');
await expect(card).toBeVisible();
await expect(card.getByText('Reader')).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText(
otherPage.locator('.--docs--editor-container.--docs--doc-readonly'),
).toBeVisible();
const otherEditor = await getEditor({ page: otherPage });
await expect(otherEditor).toHaveAttribute('contenteditable', 'false');
await expect(otherEditor.getByText('Hello Public Viewonly')).toBeVisible();
// Cursor and selection of the anonymous user are not visible
await otherEditor.getByText('Hello Public').selectText();
await expect(
page.locator('.collaboration-cursor-custom__base'),
).toBeHidden();
await expect(page.locator('.ProseMirror-yjs-selection')).toBeHidden();
// Can still see changes made by others
await writeInEditor({ page, text: 'Can you see it ?' });
await expect(otherEditor.getByText('Can you see it ?')).toBeVisible();
await otherPage.getByRole('button', { name: 'Share' }).click();
await expect(
otherPage.getByText(
'You can view this document but need additional access to see its members or modify settings.',
),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Request access' }),
otherPage.getByRole('button', { name: 'Request access' }),
).toBeHidden();
await cleanup();
});
test('It checks a public doc in editable mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello Public Editable' });
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
@@ -317,20 +336,47 @@ test.describe('Doc Visibility: Public', () => {
cardContainer.getByText('Public document', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
const docUrl = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl,
withoutSignIn: true,
docTitle,
});
await expectLoginPage(page);
await expect(otherPage.getByTestId('search-docs-button')).toBeHidden();
await expect(otherPage.getByTestId('new-doc-button')).toBeHidden();
await page.goto(urlDoc);
const otherEditor = await getEditor({ page: otherPage });
await expect(otherEditor).toHaveAttribute('contenteditable', 'true');
await expect(otherEditor.getByText('Hello Public Editable')).toBeVisible();
await verifyDocName(page, docTitle);
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
// We can see the collaboration cursor of the anonymous user
await otherEditor.getByText('Hello Public').selectText();
await expect(
page.locator('.collaboration-cursor-custom__base').getByText('Anonymous'),
).toBeVisible();
await expect(
otherPage.getByRole('button', { name: 'Share' }),
).toBeVisible();
const card = otherPage.getByLabel('It is the card information');
await expect(card).toBeVisible();
await expect(card.getByText('Editor')).toBeVisible();
await otherPage.getByRole('button', { name: 'Share' }).click();
await expect(
otherPage.getByText(
'You can view this document but need additional access to see its members or modify settings.',
),
).toBeVisible();
await expect(
otherPage.getByRole('button', { name: 'Request access' }),
).toBeHidden();
await cleanup();
});
});
@@ -1,37 +0,0 @@
/**
* Type definitions for pdf-parse library
* The library doesn't export complete type definitions for the parsed PDF data
*/
declare module 'pdf-parse' {
export interface PdfInfo {
Title?: string;
Author?: string;
Subject?: string;
Keywords?: string;
Creator?: string;
Producer?: string;
CreationDate?: string;
ModDate?: string;
[key: string]: unknown;
}
export interface PdfData {
/** Total number of pages */
numpages: number;
/** Alias for numpages */
total?: number;
/** Extracted text content from the PDF */
text: string;
/** PDF metadata information */
info?: PdfInfo;
/** PDF metadata (alternative structure) */
metadata?: unknown;
/** PDF version */
version?: string;
}
export function pdf(buffer: Buffer): Promise<PdfData>;
export default pdf;
}
@@ -31,7 +31,7 @@ export const overrideConfig = async (
page: Page,
newConfig: { [_K in keyof typeof CONFIG]?: unknown },
) =>
await page.route('**/api/v1.0/config/', async (route) => {
await page.route(/.*\/api\/v1.0\/config\/.*/, async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
@@ -204,7 +204,7 @@ export const waitForResponseCreateDoc = (page: Page) => {
};
export const mockedDocument = async (page: Page, data: object) => {
await page.route('**/documents/**/', async (route) => {
await page.route(/\**\/documents\/\**/, async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
@@ -256,7 +256,7 @@ export const mockedDocument = async (page: Page, data: object) => {
};
export const mockedListDocs = async (page: Page, data: object[] = []) => {
await page.route('**/documents/**/', async (route) => {
await page.route(/\**\/documents\/\**/, async (route) => {
const request = route.request();
if (request.method().includes('GET') && request.url().includes('page=')) {
await route.fulfill({
@@ -277,6 +277,7 @@ export const expectLoginPage = async (page: Page) =>
).toBeVisible({
timeout: 10000,
});
// language helper
export const TestLanguage = {
English: {
@@ -300,7 +301,7 @@ export async function waitForLanguageSwitch(
page: Page,
lang: TestLanguageValue,
) {
await page.route('**/api/v1.0/users/**', async (route, request) => {
await page.route(/\**\/api\/v1.0\/users\/\**/, async (route, request) => {
if (request.method().includes('PATCH')) {
await route.fulfill({
json: {
@@ -118,12 +118,16 @@ export const connectOtherUserToDoc = async ({
await otherPage.goto(docUrl);
if (!withoutSignIn) {
await otherPage
const loginFromApp = otherPage
.getByRole('main', { name: 'Main content' })
.getByLabel('Login')
.click({
timeout: 15000,
});
.getByLabel('Login');
const loginFromHome = otherPage.getByRole('button', {
name: 'Start Writing',
});
await loginFromApp.or(loginFromHome).first().click({
timeout: 15000,
});
await keyCloakSignIn(otherPage, otherBrowserName, false);
}
@@ -159,7 +163,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
...json,
},
];
await page.route('**/invitations/**/', async (route) => {
await page.route(/.*\/invitations\/.*/, async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
@@ -180,7 +184,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
});
await page.route(
'**/invitations/120ec765-43af-4602-83eb-7f4e1224548a/**/',
/.*\/invitations\/120ec765-43af-4602-83eb-7f4e1224548a\/.*/,
async (route) => {
const request = route.request();
if (request.method().includes('DELETE')) {
@@ -195,7 +199,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
};
export const mockedAccesses = async (page: Page, json?: object) => {
await page.route('**/accesses/**/', async (route) => {
await page.route(/.*\/accesses\/.*/, async (route) => {
const request = route.request();
if (
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "3.8.2",
"version": "3.9.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -15,7 +15,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.55.1",
"@playwright/test": "1.56.1",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-plugin-docs": "*",
@@ -23,7 +23,7 @@
},
"dependencies": {
"convert-stream": "1.0.2",
"pdf-parse": "2.1.7"
"pdf-parse": "2.4.5"
},
"packageManager": "yarn@1.22.22"
}
+19 -18
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "3.8.2",
"version": "3.9.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -25,44 +25,45 @@
"@blocknote/react": "0.41.1",
"@blocknote/xl-docx-exporter": "0.41.1",
"@blocknote/xl-multi-column": "0.41.1",
"@blocknote/xl-odt-exporter": "0.41.1",
"@blocknote/xl-pdf-exporter": "0.41.1",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/material-symbols-outlined": "5.2.25",
"@fontsource-variable/material-symbols-outlined": "5.2.28",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.16.2",
"@hocuspocus/provider": "3.3.0",
"@mantine/core": "8.3.4",
"@mantine/hooks": "8.3.4",
"@hocuspocus/provider": "3.4.0",
"@mantine/core": "8.3.6",
"@mantine/hooks": "8.3.6",
"@openfun/cunningham-react": "3.2.3",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.17.0",
"@tanstack/react-query": "5.90.2",
"@tiptap/extensions": "3.4.4",
"@sentry/nextjs": "10.22.0",
"@tanstack/react-query": "5.90.6",
"@tiptap/extensions": "3.10.1",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.25",
"crisp-sdk-web": "1.0.26",
"docx": "*",
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.5.0",
"i18next": "25.5.3",
"emoji-regex": "10.6.0",
"i18next": "25.6.0",
"i18next-browser-languagedetector": "8.2.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.7.2",
"next": "15.5.4",
"posthog-js": "1.271.0",
"posthog-js": "1.284.0",
"react": "*",
"react-aria-components": "1.13.0",
"react-dom": "*",
"react-i18next": "16.0.0",
"react-intersection-observer": "9.16.0",
"react-i18next": "16.2.3",
"react-intersection-observer": "10.0.0",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.1.19",
@@ -83,13 +84,13 @@
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "5.0.4",
"@vitejs/plugin-react": "5.1.0",
"copy-webpack-plugin": "13.0.1",
"cross-env": "10.1.0",
"dotenv": "17.2.3",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "27.0.0",
"jsdom": "27.1.0",
"node-fetch": "2.7.0",
"prettier": "3.6.2",
"stylelint": "16.25.0",
@@ -97,8 +98,8 @@
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack": "5.102.0",
"vitest": "4.0.6",
"webpack": "5.102.1",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"
@@ -31,7 +31,7 @@ const StyledButton = styled(Button)<StyledButtonProps>`
font-weight: 500;
font-size: 0.938rem;
padding: 0;
${({ $css }) => $css};
border-radius: 4px;
&:hover {
background-color: var(
--c--components--button--primary-text--background--color-hover
@@ -41,6 +41,7 @@ const StyledButton = styled(Button)<StyledButtonProps>`
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-400);
border-radius: 4px;
}
${({ $css }) => $css};
`;
export interface DropButtonProps {
@@ -13,7 +13,7 @@ export const Icon = ({
iconName,
disabled,
variant = 'outlined',
$variation,
$variation = 'text',
...textProps
}: IconProps) => {
const hasLabel = 'aria-label' in textProps || 'aria-labelledby' in textProps;
@@ -41,15 +41,19 @@ type IconOptionsProps = TextType & {
isHorizontal?: boolean;
};
export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
export const IconOptions = ({
isHorizontal,
$css,
...props
}: IconOptionsProps) => {
return (
<Icon
{...props}
iconName={isHorizontal ? 'more_horiz' : 'more_vert'}
$css={css`
user-select: none;
${props.$css}
${$css}
`}
{...props}
/>
);
};
@@ -12,6 +12,7 @@ import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useKeyboardAction } from '@/hooks';
import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
@@ -57,6 +58,7 @@ export const DropdownMenu = ({
testId,
}: PropsWithChildren<DropdownMenuProps>) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const keyboardAction = useKeyboardAction();
const [isOpen, setIsOpen] = useState(opened ?? false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const blockButtonRef = useRef<HTMLDivElement>(null);
@@ -93,6 +95,14 @@ export const DropdownMenu = ({
}
}, [isOpen, options]);
const triggerOption = useCallback(
(option: DropdownMenuOption) => {
onOpenChange?.(false);
void option.callback?.();
},
[onOpenChange],
);
if (disabled) {
return children;
}
@@ -170,9 +180,9 @@ export const DropdownMenu = ({
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenChange?.(false);
void option.callback?.();
triggerOption(option);
}}
onKeyDown={keyboardAction(() => triggerOption(option))}
key={option.label}
$align="center"
$justify="space-between"
@@ -44,6 +44,11 @@
contain: content;
}
.c__button--medium {
min-height: var(--c--components--button--medium-height);
height: auto;
}
/**
* Modal
*/
@@ -44,7 +44,7 @@ export function useAuthQuery(
staleTime: 1000 * 60 * 15, // 15 minutes
retry: (failureCount, error) => {
// we assume that a 401 means the user is not logged in
if (error.status == 401) {
if (error.status === 401) {
return false;
}
return failureCount < DEFAULT_QUERY_RETRY;
@@ -1,4 +1,5 @@
import { APIError, errorCauses } from '@/api';
import { sleep } from '@/utils';
interface CheckDocMediaStatusResponse {
file?: string;
@@ -25,3 +26,28 @@ export const checkDocMediaStatus = async ({
return response.json() as Promise<CheckDocMediaStatusResponse>;
};
/**
* Upload file can be analyzed on the server side,
* we had this function to wait for the analysis to be done
* before returning the file url. It will keep the loader
* on the upload button until the analysis is done.
* @param url
* @returns Promise<CheckDocMediaStatusResponse> status_code
* @description Waits for the upload to be analyzed by checking the status of the file.
*/
export const loopCheckDocMediaStatus = async (
url: string,
): Promise<CheckDocMediaStatusResponse> => {
const SLEEP_TIME = 5000;
const response = await checkDocMediaStatus({
urlMedia: url,
});
if (response.status === 'ready') {
return response;
} else {
await sleep(SLEEP_TIME);
return await loopCheckDocMediaStatus(url);
}
};
@@ -12,16 +12,12 @@ import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
import {
Doc,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
import { Doc, useProviderStore } from '@/docs/doc-management';
import { useAuth } from '@/features/auth';
import {
@@ -84,26 +80,21 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { setEditor } = useEditorStore();
const { t } = useTranslation();
const { isSynced: isConnectedToCollabServer } = useProviderStore();
const refEditorContainer = useRef<HTMLDivElement>(null);
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
const isDeletedDoc = !!doc.deleted_at;
useSaveDoc(doc.id, provider.document, !readOnly, isConnectedToCollabServer);
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
const { i18n } = useTranslation();
const lang = i18n.resolvedLanguage;
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const collabName = readOnly
? 'Reader'
: user?.full_name || user?.email || t('Anonymous');
const collabName = user?.full_name || user?.email || t('Anonymous');
const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity';
const editor: DocsBlockNoteEditor = useCreateBlockNote(
{
collaboration: {
provider,
provider: provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: collabName,
@@ -117,10 +108,6 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
renderCursor: (user: { color: string; name: string }) => {
const cursorElement = document.createElement('span');
if (user.name === 'Reader') {
return cursorElement;
}
cursorElement.classList.add('collaboration-cursor-custom__base');
const caretElement = document.createElement('span');
caretElement.classList.add('collaboration-cursor-custom__caret');
@@ -169,7 +156,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
);
useHeadings(editor);
useShortcuts(editor);
useShortcuts(editor, refEditorContainer.current);
useUploadStatus(editor);
useEffect(() => {
@@ -181,12 +170,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
}, [setEditor, editor]);
return (
<Box
$padding={{ top: 'md' }}
$background="white"
$css={cssEditor(readOnly, isDeletedDoc)}
className="--docs--editor-container"
>
<Box ref={refEditorContainer} $css={cssEditor}>
{errorAttachment && (
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
<TextErrors
@@ -201,8 +185,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
editor={editor}
formattingToolbar={false}
slashMenu={false}
editable={!readOnly}
theme="light"
aria-label={t('Document editor')}
>
<BlockNoteSuggestionMenu />
<BlockNoteToolbar />
@@ -211,14 +195,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
);
};
interface BlockNoteEditorVersionProps {
interface BlockNoteReaderProps {
initialContent: Y.XmlFragment;
}
export const BlockNoteEditorVersion = ({
initialContent,
}: BlockNoteEditorVersionProps) => {
const readOnly = true;
export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => {
const { setEditor } = useEditorStore();
const { t } = useTranslation();
const editor = useCreateBlockNote(
{
collaboration: {
@@ -234,9 +217,26 @@ export const BlockNoteEditorVersion = ({
[initialContent],
);
useEffect(() => {
setEditor(editor);
return () => {
setEditor(undefined);
};
}, [setEditor, editor]);
useHeadings(editor);
return (
<Box $css={cssEditor(readOnly, true)} className="--docs--editor-container">
<BlockNoteView editor={editor} editable={!readOnly} theme="light" />
<Box $css={cssEditor}>
<BlockNoteView
editor={editor}
editable={false}
theme="light"
aria-label={t('Document version viewer')}
formattingToolbar={false}
slashMenu={false}
/>
</Box>
);
};
@@ -1,32 +1,87 @@
import { Loader } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import clsx from 'clsx';
import { useEffect } from 'react';
import { css } from 'styled-components';
import * as Y from 'yjs';
import { Box, Loading, Text, TextErrors } from '@/components';
import { DocHeader, DocVersionHeader } from '@/docs/doc-header/';
import { Box, Loading } from '@/components';
import { DocHeader } from '@/docs/doc-header/';
import {
Doc,
base64ToBlocknoteXmlFragment,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
import { TableContent } from '@/docs/doc-table-content/';
import { Versions, useDocVersion } from '@/docs/doc-versioning/';
import { useSkeletonStore } from '@/features/skeletons';
import { useResponsiveStore } from '@/stores';
import { BlockNoteEditor, BlockNoteEditorVersion } from './BlockNoteEditor';
import { BlockNoteEditor, BlockNoteReader } from './BlockNoteEditor';
interface DocEditorContainerProps {
docHeader: React.ReactNode;
docEditor: React.ReactNode;
isDeletedDoc: boolean;
readOnly: boolean;
}
export const DocEditorContainer = ({
docHeader,
docEditor,
isDeletedDoc,
readOnly,
}: DocEditorContainerProps) => {
const { isDesktop } = useResponsiveStore();
return (
<>
<Box
$maxWidth="868px"
$width="100%"
$height="100%"
className="--docs--doc-editor"
>
<Box
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
className="--docs--doc-editor-header"
>
{docHeader}
</Box>
<Box
$direction="row"
$width="100%"
$css="overflow-x: clip; flex: 1;"
$position="relative"
className="--docs--doc-editor-content"
>
<Box $css="flex:1;" $position="relative" $width="100%">
<Box
$padding={{ top: 'md', bottom: '2rem' }}
$background="white"
className={clsx('--docs--editor-container', {
'--docs--doc-readonly': readOnly,
'--docs--doc-deleted': isDeletedDoc,
})}
$height="100%"
>
{docEditor}
</Box>
</Box>
</Box>
</Box>
</>
);
};
interface DocEditorProps {
doc: Doc;
versionId?: Versions['version_id'];
}
export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
export const DocEditor = ({ doc }: DocEditorProps) => {
const { isDesktop } = useResponsiveStore();
const isVersion = !!versionId && typeof versionId === 'string';
const { provider, isReady } = useProviderStore();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const isDeletedDoc = !!doc.deleted_at;
const readOnly =
!doc.abilities.partial_update || !isEditable || isLoading || isDeletedDoc;
const { setIsSkeletonVisible } = useSkeletonStore();
const isProviderReady = isReady && provider;
@@ -42,7 +97,7 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
return (
<>
{isDesktop && !isVersion && (
{isDesktop && (
<Box
$position="absolute"
$css={css`
@@ -53,102 +108,22 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
<TableContent />
</Box>
)}
<Box
$maxWidth="868px"
$width="100%"
$height="100%"
className="--docs--doc-editor"
>
<Box
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
className="--docs--doc-editor-header"
>
{isVersion ? <DocVersionHeader /> : <DocHeader doc={doc} />}
</Box>
<Box
$direction="row"
$width="100%"
$css="overflow-x: clip; flex: 1;"
$position="relative"
className="--docs--doc-editor-content"
>
<Box $css="flex:1;" $position="relative" $width="100%">
{isVersion ? (
<DocVersionEditor docId={doc.id} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} provider={provider} />
)}
</Box>
</Box>
</Box>
<DocEditorContainer
docHeader={<DocHeader doc={doc} />}
docEditor={
readOnly ? (
<BlockNoteReader
initialContent={provider.document.getXmlFragment(
'document-store',
)}
/>
) : (
<BlockNoteEditor doc={doc} provider={provider} />
)
}
isDeletedDoc={isDeletedDoc}
readOnly={readOnly}
/>
</>
);
};
interface DocVersionEditorProps {
docId: Doc['id'];
versionId: Versions['version_id'];
}
export const DocVersionEditor = ({
docId,
versionId,
}: DocVersionEditorProps) => {
const {
data: version,
isLoading,
isError,
error,
} = useDocVersion({
docId,
versionId,
});
const { replace } = useRouter();
const [initialContent, setInitialContent] = useState<Y.XmlFragment>();
useEffect(() => {
if (!version?.content) {
return;
}
setInitialContent(base64ToBlocknoteXmlFragment(version.content));
}, [version?.content]);
if (isError && error) {
if (error.status === 404) {
void replace(`/404`);
return null;
}
return (
<Box $margin="large" className="--docs--doc-version-editor-error">
<TextErrors
causes={error.cause}
icon={
error.status === 502 ? (
<Text
className="material-icons"
$theme="danger"
aria-hidden={true}
>
wifi_off
</Text>
) : undefined
}
/>
</Box>
);
}
if (isLoading || !version || !initialContent) {
return (
<Box $align="center" $justify="center" $height="100%">
<Loader />
</Box>
);
}
return <BlockNoteEditorVersion initialContent={initialContent} />;
};
@@ -22,8 +22,8 @@ import { Box, Icon } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
const PDFBlockStyle = createGlobalStyle`
.bn-block-content[data-content-type="pdf"] {
width: fit-content;
.bn-block-content[data-content-type="pdf"] .bn-file-block-content-wrapper[style*="fit-content"] {
width: 100% !important;
}
`;
@@ -46,7 +46,7 @@ type CreatePDFBlockConfig = BlockConfig<
interface PdfBlockComponentProps {
block: BlockNoDefaults<
Record<'callout', CreatePDFBlockConfig>,
Record<'pdf', CreatePDFBlockConfig>,
InlineContentSchema,
StyleSchema
>;
@@ -1,34 +1,146 @@
import {
BlockConfig,
BlockNoDefaults,
BlockNoteEditor,
InlineContentSchema,
StyleSchema,
} from '@blocknote/core';
import { createReactBlockSpec } from '@blocknote/react';
import { t } from 'i18next';
import { useEffect } from 'react';
import { Box, Text } from '@/components';
import { useMediaUrl } from '@/core';
import { loopCheckDocMediaStatus } from '../../api';
import Loader from '../../assets/loader.svg';
import Warning from '../../assets/warning.svg';
type UploadLoaderPropSchema = {
readonly information: { readonly default: '' };
readonly type: {
readonly default: 'loading';
readonly values: readonly ['loading', 'warning'];
};
readonly blockUploadName: { readonly default: '' };
readonly blockUploadShowPreview: { readonly default: true };
readonly blockUploadType: {
readonly default: '';
};
readonly blockUploadUrl: { readonly default: '' };
};
type UploadLoaderBlockConfig = BlockConfig<
'uploadLoader',
UploadLoaderPropSchema,
'none'
>;
type UploadLoaderBlockType = BlockNoDefaults<
Record<'uploadLoader', UploadLoaderBlockConfig>,
InlineContentSchema,
StyleSchema
>;
type UploadLoaderEditor = BlockNoteEditor<
Record<'uploadLoader', UploadLoaderBlockConfig>,
InlineContentSchema,
StyleSchema
>;
interface UploadLoaderBlockComponentProps {
block: UploadLoaderBlockType;
editor: UploadLoaderEditor;
contentRef: (node: HTMLElement | null) => void;
}
const UploadLoaderBlockComponent = ({
block,
editor,
}: UploadLoaderBlockComponentProps) => {
const mediaUrl = useMediaUrl();
useEffect(() => {
if (!block.props.blockUploadUrl || block.props.type !== 'loading') {
return;
}
const url = block.props.blockUploadUrl;
loopCheckDocMediaStatus(url)
.then((response) => {
// Replace the loading block with the resource block (image, audio, video, pdf ...)
try {
editor.replaceBlocks(
[block.id],
[
{
type: block.props.blockUploadType,
props: {
url: `${mediaUrl}${response.file}`,
showPreview: block.props.blockUploadShowPreview,
name: block.props.blockUploadName,
caption: '',
backgroundColor: 'default',
textAlignment: 'left',
},
} as never,
],
);
} catch {
/* During collaboration, another user might have updated the block */
}
})
.catch((error) => {
console.error('Error analyzing file:', error);
try {
editor.updateBlock(block.id, {
type: 'uploadLoader',
props: {
type: 'warning',
information: t(
'The antivirus has detected an anomaly in your file.',
),
},
});
} catch {
/* During collaboration, another user might have updated the block */
}
});
}, [block, editor, mediaUrl]);
return (
<Box className="bn-visual-media-wrapper" $direction="row" $gap="0.5rem">
{block.props.type === 'warning' ? (
<Warning />
) : (
<Loader style={{ animation: 'spin 1.5s linear infinite' }} />
)}
<Text>{block.props.information}</Text>
</Box>
);
};
export const UploadLoaderBlock = createReactBlockSpec(
{
type: 'uploadLoader',
propSchema: {
information: { default: '' as const },
information: { default: '' },
type: {
default: 'loading' as const,
values: ['loading', 'warning'] as const,
default: 'loading',
values: ['loading', 'warning'],
},
blockUploadName: { default: '' },
blockUploadShowPreview: { default: true },
blockUploadType: {
default: '',
},
blockUploadUrl: { default: '' },
},
content: 'none',
},
{
render: ({ block }) => {
return (
<Box className="bn-visual-media-wrapper" $direction="row" $gap="0.5rem">
{block.props.type === 'warning' ? (
<Warning />
) : (
<Loader style={{ animation: 'spin 1.5s linear infinite' }} />
)}
<Text>{block.props.information}</Text>
</Box>
);
},
render: (props) => <UploadLoaderBlockComponent {...props} />,
},
);
@@ -29,6 +29,9 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
render: ({ inlineContent, updateInlineContent }) => {
const { data: doc } = useDoc({ id: inlineContent.props.docId });
/**
* Update the content title if the referenced doc title changes
*/
useEffect(() => {
if (doc?.title && doc.title !== inlineContent.props.title) {
updateInlineContent({
@@ -39,7 +42,15 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
},
});
}
}, [inlineContent.props, doc?.title, updateInlineContent]);
/**
* When doing collaborative editing, doc?.title might be out of sync
* causing an infinite loop of updates.
* To prevent this, we only run this effect when doc?.title changes,
* not when inlineContent.props.title changes.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doc?.title]);
return <LinkSelected {...inlineContent.props} />;
},
@@ -66,7 +77,7 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
onClick={handleClick}
draggable="false"
$css={css`
display: inline;
display: contents;
padding: 0.1rem 0.4rem;
border-radius: 4px;
& svg {
@@ -78,6 +89,10 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
background-color: ${colorsTokens['greyscale-100']};
}
transition: background-color 0.2s ease-in-out;
.--docs--doc-deleted & {
pointer-events: none;
}
`}
>
{emoji ? (
@@ -3,6 +3,7 @@ import {
StyleSchema,
} from '@blocknote/core';
import { useBlockNoteEditor } from '@blocknote/react';
import type { KeyboardEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -99,6 +100,55 @@ export const SearchPage = ({
}, 100);
}, [inputRef]);
const closeSearch = (insertContent: string) => {
updateInlineContent({
type: 'interlinkingSearchInline',
props: {
disabled: true,
trigger,
},
});
contentRef(null);
editor.focus();
editor.insertInlineContent([insertContent]);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
e.preventDefault();
// Keep the trigger character ('@' or '/') in the editor when closing with Escape
closeSearch(trigger);
} else if (e.key === 'Backspace' && search.length === 0) {
e.preventDefault();
closeSearch('');
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
// Allow arrow keys to be handled by the command menu for navigation
const commandList = e.currentTarget
.closest('.inline-content')
?.nextElementSibling?.querySelector('[cmdk-list]');
// Create a synthetic keyboard event for the command menu
const syntheticEvent = new KeyboardEvent('keydown', {
key: e.key,
bubbles: true,
cancelable: true,
});
commandList?.dispatchEvent(syntheticEvent);
e.preventDefault();
} else if (e.key === 'Enter') {
// Handle Enter key to select the currently highlighted item
const selectedItem = e.currentTarget
.closest('.inline-content')
?.nextElementSibling?.querySelector(
'[cmdk-item][data-selected="true"]',
) as HTMLElement;
selectedItem?.click();
e.preventDefault();
}
};
return (
<Box as="span" $position="relative">
<Box
@@ -124,50 +174,7 @@ export const SearchPage = ({
const value = (e.target as HTMLInputElement).value;
setSearch(value);
}}
onKeyDown={(e) => {
if (
(e.key === 'Backspace' && search.length === 0) ||
e.key === 'Escape'
) {
e.preventDefault();
updateInlineContent({
type: 'interlinkingSearchInline',
props: {
disabled: true,
trigger,
},
});
contentRef(null);
editor.focus();
editor.insertInlineContent(['']);
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
// Allow arrow keys to be handled by the command menu for navigation
const commandList = e.currentTarget
.closest('.inline-content')
?.nextElementSibling?.querySelector('[cmdk-list]');
// Create a synthetic keyboard event for the command menu
const syntheticEvent = new KeyboardEvent('keydown', {
key: e.key,
bubbles: true,
cancelable: true,
});
commandList?.dispatchEvent(syntheticEvent);
e.preventDefault();
} else if (e.key === 'Enter') {
// Handle Enter key to select the currently highlighted item
const selectedItem = e.currentTarget
.closest('.inline-content')
?.nextElementSibling?.querySelector(
'[cmdk-item][data-selected="true"]',
) as HTMLElement;
selectedItem?.click();
e.preventDefault();
}
}}
onKeyDown={handleKeyDown}
/>
</Box>
<Box
@@ -224,6 +231,8 @@ export const SearchPage = ({
},
});
contentRef(null);
editor.insertInlineContent([
{
type: 'interlinkingLinkInline',
@@ -1,3 +1,4 @@
export * from './BlockNoteEditor';
export * from './DocEditor';
export * from './EmojiPicker';
export * from './custom-blocks/';
@@ -43,7 +43,7 @@ describe('useSaveDoc', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
@@ -62,37 +62,6 @@ describe('useSaveDoc', () => {
addEventListenerSpy.mockRestore();
});
it('should not save when canSave is false', () => {
vi.useFakeTimers();
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
content: 'test-content',
title: 'test-title',
}),
});
renderHook(() => useSaveDoc(docId, yDoc, false, true), {
wrapper: AppWrapper,
});
act(() => {
// Trigger a local update
yDoc.getMap('test').set('key', 'value');
// Advance timers to trigger the save interval
vi.advanceTimersByTime(61000);
});
// Since canSave is false, no API call should be made
expect(fetchMock.calls().length).toBe(0);
vi.useRealTimers();
});
it('should save when there are local changes', async () => {
vi.useFakeTimers();
const yDoc = new Y.Doc();
@@ -106,7 +75,7 @@ describe('useSaveDoc', () => {
}),
});
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
@@ -143,7 +112,7 @@ describe('useSaveDoc', () => {
}),
});
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
@@ -163,7 +132,7 @@ describe('useSaveDoc', () => {
const docId = 'test-doc-id';
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, true), {
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
@@ -9,12 +9,13 @@ export const useHeadings = (editor: DocsBlockNoteEditor) => {
useEffect(() => {
setHeadings(editor);
editor?.onChange(() => {
const unsubscribe = editor?.onChange(() => {
setHeadings(editor);
});
return () => {
resetHeadings();
unsubscribe();
};
}, [editor, resetHeadings, setHeadings]);
};
@@ -13,11 +13,10 @@ const SAVE_INTERVAL = 60000;
export const useSaveDoc = (
docId: string,
yDoc: Y.Doc,
canSave: boolean,
isConnectedToCollabServer: boolean,
) => {
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => {
setIsLocalChange(false);
},
@@ -47,7 +46,7 @@ export const useSaveDoc = (
}, [yDoc]);
const saveDoc = useCallback(() => {
if (!canSave || !isLocalChange) {
if (!isLocalChange) {
return false;
}
@@ -58,14 +57,7 @@ export const useSaveDoc = (
});
return true;
}, [
canSave,
isLocalChange,
updateDoc,
docId,
yDoc,
isConnectedToCollabServer,
]);
}, [isLocalChange, updateDoc, docId, yDoc, isConnectedToCollabServer]);
const router = useRouter();
@@ -2,7 +2,10 @@ import { useEffect } from 'react';
import { DocsBlockNoteEditor } from '../types';
export const useShortcuts = (editor: DocsBlockNoteEditor) => {
export const useShortcuts = (
editor: DocsBlockNoteEditor,
el: HTMLDivElement | null,
) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === '@' && editor?.isFocused()) {
@@ -29,11 +32,14 @@ export const useShortcuts = (editor: DocsBlockNoteEditor) => {
}
};
// Attach the event listener to the document instead of the window
document.addEventListener('keydown', handleKeyDown);
if (!el) {
return;
}
el.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
el.removeEventListener('keydown', handleKeyDown);
};
}, [editor]);
}, [editor, el]);
};
@@ -1,36 +1,12 @@
import { useCallback, useEffect, useRef } from 'react';
import { captureException } from '@sentry/nextjs';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { backendUrl } from '@/api';
import { useMediaUrl } from '@/core/config';
import { sleep } from '@/utils';
import { checkDocMediaStatus, useCreateDocAttachment } from '../api';
import { useCreateDocAttachment } from '../api';
import { DocsBlockNoteEditor } from '../types';
/**
* Upload file can be analyzed on the server side,
* we had this function to wait for the analysis to be done
* before returning the file url. It will keep the loader
* on the upload button until the analysis is done.
* @param url
* @returns Promise<CheckDocMediaStatusResponse> status_code
* @description Waits for the upload to be analyzed by checking the status of the file.
*/
const loopCheckDocMediaStatus = async (url: string) => {
const SLEEP_TIME = 5000;
const response = await checkDocMediaStatus({
urlMedia: url,
});
if (response.status === 'ready') {
return response;
} else {
await sleep(SLEEP_TIME);
return await loopCheckDocMediaStatus(url);
}
};
export const useUploadFile = (docId: string) => {
const {
mutateAsync: createDocAttachment,
@@ -63,94 +39,9 @@ export const useUploadFile = (docId: string) => {
export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
const ANALYZE_URL = 'media-check';
const { t } = useTranslation();
const mediaUrl = useMediaUrl();
const timeoutIds = useRef<Record<string, NodeJS.Timeout>>({});
const blockAnalyzeProcess = useCallback(
(editor: DocsBlockNoteEditor, blockId: string, url: string) => {
if (timeoutIds.current[url]) {
clearTimeout(timeoutIds.current[url]);
}
// Delay to let the time to the dom to be rendered
const timoutId = setTimeout(() => {
// Replace the resource block by a loading block
const { insertedBlocks, removedBlocks } = editor.replaceBlocks(
[blockId],
[
{
type: 'uploadLoader',
props: {
information: t('Analyzing file...'),
type: 'loading',
},
},
],
);
loopCheckDocMediaStatus(url)
.then((response) => {
if (insertedBlocks.length === 0 || removedBlocks.length === 0) {
return;
}
const loadingBlockId = insertedBlocks[0].id;
const removedBlock = removedBlocks[0];
removedBlock.props = {
...removedBlock.props,
url: `${mediaUrl}${response.file}`,
};
// Replace the loading block with the resource block (image, audio, video, pdf ...)
editor.replaceBlocks([loadingBlockId], [removedBlock]);
})
.catch((error) => {
console.error('Error analyzing file:', error);
const loadingBlock = insertedBlocks[0];
if (!loadingBlock) {
return;
}
loadingBlock.props = {
...loadingBlock.props,
type: 'warning',
information: t(
'The antivirus has detected an anomaly in your file.',
),
};
editor.updateBlock(loadingBlock.id, loadingBlock);
});
}, 250);
timeoutIds.current[url] = timoutId;
},
[t, mediaUrl],
);
useEffect(() => {
const blocksAnalyze = editor?.document.filter(
(block) => 'url' in block.props && block.props.url.includes(ANALYZE_URL),
);
if (!blocksAnalyze?.length) {
return;
}
blocksAnalyze.forEach((block) => {
if (!('url' in block.props)) {
return;
}
blockAnalyzeProcess(editor, block.id, block.props.url);
});
}, [blockAnalyzeProcess, editor]);
useEffect(() => {
editor.onChange((_, context) => {
const unsubscribe = editor.onChange((_, context) => {
const blocksChanges = context.getChanges();
if (!blocksChanges.length) {
@@ -169,11 +60,49 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
return;
}
blockAnalyzeProcess(
editor,
blockChanges.block.id,
blockChanges.block.props.url,
);
const blockUploadUrl = blockChanges.block.props.url;
const blockUploadType = blockChanges.block.type;
const blockUploadName = blockChanges.block.props.name;
const blockUploadShowPreview =
('showPreview' in blockChanges.block.props &&
blockChanges.block.props.showPreview) ||
false;
const timeoutId = setTimeout(() => {
// Replace the resource block by a uploadLoader block
// to show analyzing status
try {
editor.replaceBlocks(
[blockChanges.block.id],
[
{
type: 'uploadLoader',
props: {
information: t('Analyzing file...'),
type: 'loading',
blockUploadName,
blockUploadType,
blockUploadUrl,
blockUploadShowPreview,
},
},
],
);
} catch (error) {
captureException(error, {
extra: { info: 'Error replacing block for upload loader' },
});
}
}, 250);
return () => {
clearTimeout(timeoutId);
unsubscribe();
};
});
}, [blockAnalyzeProcess, mediaUrl, editor, t]);
return () => {
unsubscribe();
};
}, [editor, t]);
};
@@ -1,12 +1,13 @@
import { css } from 'styled-components';
export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
export const cssEditor = css`
&,
& > .bn-container,
& .ProseMirror {
height: 100%;
padding-bottom: 2rem;
}
& .ProseMirror {
/**
* WCAG Accessibility contrast fixes for BlockNote editor
*/
@@ -131,13 +132,6 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
.bn-block-outer:not([data-prev-depth-changed]):before {
border-left: none;
}
${isDeletedDoc &&
`
.node-interlinkingLinkInline button {
pointer-events: none;
}
`}
}
& .bn-editor {
@@ -187,8 +181,10 @@ export const cssEditor = (readonly: boolean, isDeletedDoc: boolean) => css`
}
@media screen and (width <= 560px) {
.--docs--doc-readonly & .bn-editor {
padding-left: 10px;
}
& .bn-editor {
${readonly && `padding-left: 10px;`}
padding-right: 10px;
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {
@@ -0,0 +1,32 @@
import React from 'react';
import { DocsExporterODT } from '../types';
import { odtRegisterParagraphStyleForBlock } from '../utils';
export const blockMappingCalloutODT: DocsExporterODT['mappings']['blockMapping']['callout'] =
(block, exporter) => {
// Map callout to paragraph with emoji prefix
const emoji = block.props.emoji || '💡';
// Transform inline content (text, bold, links, etc.)
const inlineContent = exporter.transformInlineContent(block.content);
// Resolve background and alignment → create a dedicated paragraph style
const styleName = odtRegisterParagraphStyleForBlock(
exporter,
{
backgroundColor: block.props.backgroundColor,
textAlignment: block.props.textAlignment,
},
{ paddingCm: 0.42 },
);
return React.createElement(
'text:p',
{
'text:style-name': styleName,
},
`${emoji} `,
...inlineContent,
);
};
@@ -50,9 +50,9 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
const { width, height } = dimensions;
if (previewWidth && previewWidth > MAX_WIDTH) {
previewWidth = MAX_WIDTH;
}
// Ensure the final width never exceeds MAX_WIDTH to prevent images
// from overflowing the page width in the exported document
const finalWidth = Math.min(previewWidth || width, MAX_WIDTH);
return [
new Paragraph({
@@ -71,8 +71,8 @@ export const blockMappingImageDocx: DocsExporterDocx['mappings']['blockMapping']
}
: undefined,
transformation: {
width: previewWidth || width,
height: ((previewWidth || width) / width) * height,
width: finalWidth,
height: (finalWidth / width) * height,
},
}),
],
@@ -0,0 +1,140 @@
import React from 'react';
import { DocsExporterODT } from '../types';
import { convertSvgToPng, odtRegisterParagraphStyleForBlock } from '../utils';
const MAX_WIDTH = 600;
export const blockMappingImageODT: DocsExporterODT['mappings']['blockMapping']['image'] =
async (block, exporter) => {
try {
const blob = await exporter.resolveFile(block.props.url);
if (!blob || !blob.type) {
console.warn(`Failed to resolve image: ${block.props.url}`);
return null;
}
let pngConverted: string | undefined;
let dimensions: { width: number; height: number } | undefined;
let previewWidth = block.props.previewWidth || undefined;
if (!blob.type.includes('image')) {
console.warn(`Not an image type: ${blob.type}`);
return null;
}
if (blob.type.includes('svg')) {
const svgText = await blob.text();
const FALLBACK_SIZE = 536;
previewWidth = previewWidth || blob.size || FALLBACK_SIZE;
pngConverted = await convertSvgToPng(svgText, previewWidth);
const img = new Image();
img.src = pngConverted;
await new Promise((resolve) => {
img.onload = () => {
dimensions = { width: img.width, height: img.height };
resolve(null);
};
});
} else {
dimensions = await getImageDimensions(blob);
}
if (!dimensions) {
return null;
}
const { width, height } = dimensions;
if (previewWidth && previewWidth > MAX_WIDTH) {
previewWidth = MAX_WIDTH;
}
// Convert image to base64 for ODT embedding
const arrayBuffer = pngConverted
? await (await fetch(pngConverted)).arrayBuffer()
: await blob.arrayBuffer();
const base64 = btoa(
Array.from(new Uint8Array(arrayBuffer))
.map((byte) => String.fromCharCode(byte))
.join(''),
);
const finalWidth = previewWidth || width;
const finalHeight = ((previewWidth || width) / width) * height;
const baseParagraphProps = {
backgroundColor: block.props.backgroundColor,
textAlignment: block.props.textAlignment,
};
const paragraphStyleName = odtRegisterParagraphStyleForBlock(
exporter,
baseParagraphProps,
{ paddingCm: 0 },
);
// Convert pixels to cm (ODT uses cm for dimensions)
const widthCm = finalWidth / 37.795275591;
const heightCm = finalHeight / 37.795275591;
// Create ODT image structure using React.createElement
const frame = React.createElement(
'text:p',
{
'text:style-name': paragraphStyleName,
},
React.createElement(
'draw:frame',
{
'draw:name': `Image${Date.now()}`,
'text:anchor-type': 'as-char',
'svg:width': `${widthCm}cm`,
'svg:height': `${heightCm}cm`,
},
React.createElement(
'draw:image',
{
xlinkType: 'simple',
xlinkShow: 'embed',
xlinkActuate: 'onLoad',
},
React.createElement('office:binary-data', {}, base64),
),
),
);
// Add caption if present
if (block.props.caption) {
const captionStyleName = odtRegisterParagraphStyleForBlock(
exporter,
baseParagraphProps,
{ paddingCm: 0, parentStyleName: 'Caption' },
);
return [
frame,
React.createElement(
'text:p',
{ 'text:style-name': captionStyleName },
block.props.caption,
),
];
}
return frame;
} catch (error) {
console.error(`Error processing image for ODT export:`, error);
return null;
}
};
async function getImageDimensions(blob: Blob) {
if (typeof window !== 'undefined') {
const bmp = await createImageBitmap(blob);
const { width, height } = bmp;
bmp.close();
return { width, height };
}
}
@@ -1,11 +1,14 @@
export * from './calloutDocx';
export * from './calloutODT';
export * from './calloutPDF';
export * from './headingPDF';
export * from './imageDocx';
export * from './imageODT';
export * from './imagePDF';
export * from './paragraphPDF';
export * from './quoteDocx';
export * from './quotePDF';
export * from './tablePDF';
export * from './uploadLoaderPDF';
export * from './uploadLoaderDocx';
export * from './uploadLoaderODT';
export * from './uploadLoaderPDF';
@@ -0,0 +1,17 @@
import React from 'react';
import { DocsExporterODT } from '../types';
export const blockMappingUploadLoaderODT: DocsExporterODT['mappings']['blockMapping']['uploadLoader'] =
(block) => {
// Map uploadLoader to paragraph with information text
const information = block.props.information || '';
const type = block.props.type || 'loading';
const prefix = type === 'warning' ? '⚠️ ' : '⏳ ';
return React.createElement(
'text:p',
{ 'text:style-name': 'Text_20_body' },
`${prefix}${information}`,
);
};
@@ -1,4 +1,5 @@
import { DOCXExporter } from '@blocknote/xl-docx-exporter';
import { ODTExporter } from '@blocknote/xl-odt-exporter';
import { PDFExporter } from '@blocknote/xl-pdf-exporter';
import {
Button,
@@ -23,12 +24,14 @@ import { Doc, useTrans } from '@/docs/doc-management';
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { docxDocsSchemaMappings } from '../mappingDocx';
import { odtDocsSchemaMappings } from '../mappingODT';
import { pdfDocsSchemaMappings } from '../mappingPDF';
import { downloadFile } from '../utils';
enum DocDownloadFormat {
PDF = 'pdf',
DOCX = 'docx',
ODT = 'odt',
}
interface ModalExportProps {
@@ -124,7 +127,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
: rawPdfDocument;
blobExport = await pdf(pdfDocument).toBlob();
} else {
} else if (format === DocDownloadFormat.DOCX) {
const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, {
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
});
@@ -133,6 +136,16 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
documentOptions: { title: documentTitle },
sectionOptions: {},
});
} else if (format === DocDownloadFormat.ODT) {
const exporter = new ODTExporter(editor.schema, odtDocsSchemaMappings, {
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
});
blobExport = await exporter.toODTDocument(exportDocument);
} else {
toast(t('The export failed'), VariantType.ERROR);
setIsExporting(false);
return;
}
downloadFile(blobExport, `${filename}.${format}`);
@@ -213,7 +226,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
className="--docs--modal-export-content"
>
<Text $variation="600" $size="sm" as="p">
{t('Download your document in a .docx or .pdf format.')}
{t('Download your document in a .docx, .odt or .pdf format.')}
</Text>
<Select
clearable={false}
@@ -231,6 +244,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
label={t('Format')}
options={[
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
{ label: t('ODT'), value: DocDownloadFormat.ODT },
{ label: t('PDF'), value: DocDownloadFormat.PDF },
]}
value={format}
@@ -1,2 +1,3 @@
export * from './interlinkingLinkPDF';
export * from './interlinkingLinkDocx';
export * from './interlinkingLinkODT';
@@ -0,0 +1,23 @@
import React from 'react';
import { DocsExporterODT } from '../types';
export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
(inline) => {
const url = window.location.origin + inline.props.url;
const title = inline.props.title;
// Create ODT hyperlink using React.createElement to avoid TypeScript JSX namespace issues
// Uses the same structure as BlockNote's default link mapping
return React.createElement(
'text:a',
{
xlinkType: 'simple',
'text:style-name': 'Internet_20_link',
'office:target-frame-name': '_top',
xlinkShow: 'replace',
xlinkHref: url,
},
`📄${title}`,
);
};
@@ -0,0 +1,36 @@
import { odtDefaultSchemaMappings } from '@blocknote/xl-odt-exporter';
import {
blockMappingCalloutODT,
blockMappingImageODT,
blockMappingUploadLoaderODT,
} from './blocks-mapping';
import { inlineContentMappingInterlinkingLinkODT } from './inline-content-mapping';
import { DocsExporterODT } from './types';
// Align default inline mappings to our editor inline schema without using `any`
const baseInlineMappings =
odtDefaultSchemaMappings.inlineContentMapping as unknown as DocsExporterODT['mappings']['inlineContentMapping'];
export const odtDocsSchemaMappings: DocsExporterODT['mappings'] = {
...odtDefaultSchemaMappings,
blockMapping: {
...odtDefaultSchemaMappings.blockMapping,
callout: blockMappingCalloutODT,
image: blockMappingImageODT,
// We're reusing the file block mapping for PDF blocks
// The types don't match exactly but the implementation is compatible
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdf: odtDefaultSchemaMappings.blockMapping.file as any,
uploadLoader: blockMappingUploadLoaderODT,
},
inlineContentMapping: {
...baseInlineMappings,
interlinkingSearchInline: () => null,
interlinkingLinkInline: inlineContentMappingInterlinkingLinkODT,
},
styleMapping: {
...odtDefaultSchemaMappings.styleMapping,
},
};
@@ -51,3 +51,13 @@ export type DocsExporterDocx = Exporter<
IRunPropertiesOptions,
TextRun
>;
export type DocsExporterODT = Exporter<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema,
React.ReactNode,
React.ReactNode,
Record<string, string>,
React.ReactNode
>;
@@ -5,6 +5,7 @@ import {
} from '@blocknote/core';
import { Canvg } from 'canvg';
import { IParagraphOptions, ShadingType } from 'docx';
import React from 'react';
export function downloadFile(blob: Blob, filename: string) {
const url = window.URL.createObjectURL(blob);
@@ -98,3 +99,76 @@ export function docxBlockPropsToStyles(
})(),
};
}
// ODT helpers
type OdtExporterLike = {
options?: { colors?: typeof COLORS_DEFAULT };
registerStyle: (fn: (name: string) => React.ReactNode) => string;
};
function isOdtExporterLike(value: unknown): value is OdtExporterLike {
return (
!!value &&
typeof (value as { registerStyle?: unknown }).registerStyle === 'function'
);
}
export function odtRegisterParagraphStyleForBlock(
exporter: unknown,
props: Partial<DefaultProps>,
options?: { paddingCm?: number; parentStyleName?: string },
) {
if (!isOdtExporterLike(exporter)) {
throw new Error('Invalid ODT exporter: missing registerStyle');
}
const colors = exporter.options?.colors;
const bgColorHex =
props.backgroundColor && props.backgroundColor !== 'default' && colors
? colors[props.backgroundColor].background
: undefined;
const textColorHex =
props.textColor && props.textColor !== 'default' && colors
? colors[props.textColor].text
: undefined;
const foTextAlign =
!props.textAlignment || props.textAlignment === 'left'
? 'start'
: props.textAlignment === 'center'
? 'center'
: props.textAlignment === 'right'
? 'end'
: 'justify';
const paddingCm = options?.paddingCm ?? 0.42; // ~1rem (16px)
const parentStyleName = options?.parentStyleName;
// registerStyle is available on ODT exporter; call through with React elements
const styleName = exporter.registerStyle((name: string) =>
React.createElement(
'style:style',
{
'style:name': name,
'style:family': 'paragraph',
...(parentStyleName
? { 'style:parent-style-name': parentStyleName }
: {}),
},
React.createElement('style:paragraph-properties', {
'fo:text-align': foTextAlign,
'fo:padding': `${paddingCm}cm`,
...(bgColorHex ? { 'fo:background-color': bgColorHex } : {}),
}),
textColorHex
? React.createElement('style:text-properties', {
'fo:color': textColorHex,
})
: undefined,
),
);
return styleName;
}
@@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, test, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
// Force mobile layout so the children count is rendered
vi.mock('@/stores', () => ({
useResponsiveStore: () => ({ isDesktop: false }),
}));
// Provide stable mocks for hooks used by the component
vi.mock('../../doc-management', async () => {
const actual = await vi.importActual<any>('../../doc-management');
return {
...actual,
useTrans: () => ({ transRole: vi.fn((r) => String(r)) }),
useIsCollaborativeEditable: () => ({ isEditable: true }),
};
});
vi.mock('@/core', () => ({
useConfig: () => ({ data: {} }),
}));
vi.mock('@/hook', () => ({
useDate: () => ({
relativeDate: () => 'yesterday',
calculateDaysLeft: () => 5,
}),
}));
import { DocHeaderInfo } from '../components/DocHeaderInfo';
describe('DocHeaderInfo', () => {
test('renders the number of sub-documents when numchild is provided (mobile layout)', () => {
const doc = {
numchild: 3,
updated_at: new Date().toISOString(),
} as any;
render(<DocHeaderInfo doc={doc} />, { wrapper: AppWrapper });
expect(screen.getByText(/Contains 3 sub-documents/i)).toBeInTheDocument();
});
});
@@ -44,7 +44,7 @@ describe('DocToolBox - Licence', () => {
await userEvent.click(optionsButton);
expect(
await screen.findByText(
'Download your document in a .docx or .pdf format.',
'Download your document in a .docx, .odt or .pdf format.',
),
).toBeInTheDocument();
}, 10000);
@@ -76,7 +76,7 @@ export const BoutonShare = ({
return (
<Button
color="tertiary-text"
color="primary-text"
onClick={open}
size="medium"
disabled={isDisabled}
@@ -1,23 +1,20 @@
import { useTranslation } from 'react-i18next';
import { Box, HorizontalSeparator, Text } from '@/components';
import { useConfig } from '@/core';
import { Box, HorizontalSeparator } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
LinkReach,
Role,
getDocLinkReach,
useIsCollaborativeEditable,
useTrans,
} from '@/docs/doc-management';
import { useDate } from '@/hook';
import { useResponsiveStore } from '@/stores';
import { AlertNetwork } from './AlertNetwork';
import { AlertPublic } from './AlertPublic';
import { AlertRestore } from './AlertRestore';
import { BoutonShare } from './BoutonShare';
import { DocHeaderInfo } from './DocHeaderInfo';
import { DocTitle } from './DocTitle';
import { DocToolBox } from './DocToolBox';
@@ -29,27 +26,11 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { transRole } = useTrans();
const { isEditable } = useIsCollaborativeEditable(doc);
const docIsPublic = getDocLinkReach(doc) === LinkReach.PUBLIC;
const docIsAuth = getDocLinkReach(doc) === LinkReach.AUTHENTICATED;
const { relativeDate, calculateDaysLeft } = useDate();
const { data: config } = useConfig();
const isDeletedDoc = !!doc.deleted_at;
let dateToDisplay = t('Last update: {{update}}', {
update: relativeDate(doc.updated_at),
});
if (config?.TRASHBIN_CUTOFF_DAYS && doc.deleted_at) {
const daysLeft = calculateDaysLeft(
doc.deleted_at,
config.TRASHBIN_CUTOFF_DAYS,
);
dateToDisplay = `${t('Days remaining:')} ${daysLeft} ${t('days', { count: daysLeft })}`;
}
return (
<>
<Box
@@ -80,33 +61,8 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
>
<Box $gap={spacingsTokens['3xs']} $overflow="auto">
<DocTitle doc={doc} />
<Box $direction="row">
{isDesktop && (
<>
<Text
$variation="600"
$size="s"
$weight="bold"
$theme={isEditable ? 'greyscale' : 'warning'}
>
{transRole(
isEditable
? doc.user_role || doc.link_role
: Role.READER,
)}
&nbsp;·&nbsp;
</Text>
<Text $variation="600" $size="s">
{dateToDisplay}
</Text>
</>
)}
{!isDesktop && (
<Text $variation="400" $size="s">
{dateToDisplay}
</Text>
)}
<DocHeaderInfo doc={doc} />
</Box>
</Box>
{!isDeletedDoc && <DocToolBox doc={doc} />}
@@ -0,0 +1,80 @@
import { t } from 'i18next';
import React from 'react';
import { Text } from '@/components';
import { useConfig } from '@/core';
import { useDate } from '@/hook';
import { useResponsiveStore } from '@/stores';
import {
Doc,
Role,
useIsCollaborativeEditable,
useTrans,
} from '../../doc-management';
interface DocHeaderInfoProps {
doc: Doc;
}
export const DocHeaderInfo = ({ doc }: DocHeaderInfoProps) => {
const { isDesktop } = useResponsiveStore();
const { transRole } = useTrans();
const { isEditable } = useIsCollaborativeEditable(doc);
const { relativeDate, calculateDaysLeft } = useDate();
const { data: config } = useConfig();
const childrenCount = doc.numchild ?? 0;
const relativeOnly = relativeDate(doc.updated_at);
let dateToDisplay = t('Last update: {{update}}', {
update: relativeOnly,
});
if (config?.TRASHBIN_CUTOFF_DAYS && doc.deleted_at) {
const daysLeft = calculateDaysLeft(
doc.deleted_at,
config.TRASHBIN_CUTOFF_DAYS,
);
dateToDisplay = `${t('Days remaining:')} ${daysLeft} ${t('days', { count: daysLeft })}`;
}
const hasChildren = childrenCount > 0;
if (isDesktop) {
return (
<>
<Text
$variation="600"
$size="s"
$weight="bold"
$theme={isEditable ? 'greyscale' : 'warning'}
>
{transRole(isEditable ? doc.user_role || doc.link_role : Role.READER)}
&nbsp;·&nbsp;
</Text>
<Text $variation="600" $size="s">
{dateToDisplay}
</Text>
</>
);
}
return (
<>
<Text $variation="400" $size="s">
{hasChildren ? relativeOnly : dateToDisplay}
</Text>
{hasChildren && (
<Text $variation="400" $size="s">
&nbsp;&nbsp;
{t('Contains {{count}} sub-documents', {
count: childrenCount,
})}
</Text>
)}
</>
);
};
@@ -55,8 +55,16 @@ const DocTitleEmojiPicker = ({ doc }: DocTitleProps) => {
const { colorsTokens } = useCunninghamTheme();
const { emoji } = getEmojiAndTitle(doc.title ?? '');
if (!emoji) {
return null;
}
return (
<Tooltip content={t('Document emoji')} aria-hidden={true} placement="top">
<Tooltip
content={t('Edit document emoji')}
aria-hidden={true}
placement="top"
>
<Box
$css={css`
padding: 4px;
@@ -70,11 +78,17 @@ const DocTitleEmojiPicker = ({ doc }: DocTitleProps) => {
`}
>
<DocIcon
buttonProps={{
$width: '32px',
$height: '32px',
$justify: 'space-between',
$align: 'center',
}}
withEmojiPicker={doc.abilities.partial_update}
docId={doc.id}
title={doc.title}
emoji={emoji}
$size="25px"
$size="23px"
defaultIcon={
<SimpleFileIcon
width="25px"
@@ -94,7 +108,6 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { spacingsTokens } = useCunninghamTheme();
const { isTopRoot } = useDocUtils(doc);
const { untitledDocument } = useTrans();
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title ?? '');
@@ -139,19 +152,9 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
className="--docs--doc-title"
$direction="row"
$align="center"
$gap={spacingsTokens['xs']}
$gap="4px"
$minHeight="40px"
>
{isTopRoot && (
<SimpleFileIcon
width="25px"
height="25px"
aria-hidden="true"
aria-label={t('Simple document icon')}
color={colorsTokens['primary-500']}
style={{ flexShrink: '0' }}
/>
)}
{!isTopRoot && <DocTitleEmojiPicker doc={doc} />}
<Tooltip content={t('Rename')} aria-hidden={true} placement="top">
@@ -60,7 +60,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const selectHistoryModal = useModal();
const modalShare = useModal();
const { isSmallMobile, isDesktop } = useResponsiveStore();
const { isSmallMobile, isMobile } = useResponsiveStore();
const copyDocLink = useCopyDocLink(doc.id);
const { mutate: duplicateDoc } = useDuplicateDoc({
onSuccess: (data) => {
@@ -69,10 +69,10 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
});
const { isFeatureFlagActivated } = useAnalytics();
const removeFavoriteDoc = useDeleteFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
});
const makeFavoriteDoc = useCreateFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
});
useEffect(() => {
@@ -90,28 +90,20 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { updateDocEmoji } = useDocTitleUpdate();
const options: DropdownMenuOption[] = [
...(isSmallMobile
? [
{
label: t('Share'),
icon: 'group',
callback: modalShare.open,
},
{
label: t('Export'),
icon: 'download',
callback: () => {
setIsModalExportOpen(true);
},
show: !!ModalExport,
},
{
label: t('Copy link'),
icon: 'add_link',
callback: copyDocLink,
},
]
: []),
{
label: t('Share'),
icon: 'group',
callback: modalShare.open,
show: isSmallMobile,
},
{
label: t('Export'),
icon: 'download',
callback: () => {
setIsModalExportOpen(true);
},
show: !!ModalExport && isSmallMobile,
},
{
label: doc.is_favorite ? t('Unpin') : t('Pin'),
icon: 'push_pin',
@@ -124,17 +116,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
},
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
},
...(emoji && doc.abilities.partial_update && !isTopRoot
? [
{
label: t('Remove emoji'),
icon: 'emoji_emotions',
callback: () => {
updateDocEmoji(doc.id, doc.title ?? '', '');
},
},
]
: []),
{
label: t('Version history'),
icon: 'history',
@@ -142,7 +123,31 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
callback: () => {
selectHistoryModal.open();
},
show: isDesktop,
show: !isMobile,
showSeparator: isTopRoot ? true : false,
},
{
label: t('Remove emoji'),
icon: 'emoji_emotions',
callback: () => {
updateDocEmoji(doc.id, doc.title ?? '', '');
},
showSeparator: true,
show: !!emoji && doc.abilities.partial_update && !isTopRoot,
},
{
label: t('Add emoji'),
icon: 'emoji_emotions',
callback: () => {
updateDocEmoji(doc.id, doc.title ?? '', '📄');
},
showSeparator: true,
show: !emoji && doc.abilities.partial_update && !isTopRoot,
},
{
label: t('Copy link'),
icon: 'add_link',
callback: copyDocLink,
},
{
label: t('Copy as {{format}}', { format: 'Markdown' }),
@@ -158,6 +163,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
void copyCurrentEditorToClipboard('html');
},
show: isFeatureFlagActivated('CopyAsHTML'),
showSeparator: true,
},
{
label: t('Duplicate'),
@@ -170,6 +176,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
canSave: doc.abilities.partial_update,
});
},
showSeparator: true,
},
{
label: isChild ? t('Delete sub-document') : t('Delete document'),
@@ -224,25 +231,22 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
aria-label={t('Export the document')}
/>
)}
<DropdownMenu options={options} label={t('Open the document options')}>
<IconOptions
aria-hidden="true"
isHorizontal
$theme="primary"
$padding={{ all: 'xs' }}
$css={css`
border-radius: 4px;
&:hover {
background-color: ${colorsTokens['greyscale-100']};
}
${isSmallMobile
? css`
padding: 10px;
border: 1px solid ${colorsTokens['greyscale-300']};
`
: ''}
`}
/>
<DropdownMenu
options={options}
label={t('Open the document options')}
buttonCss={css`
padding: ${spacingsTokens['xs']};
&:hover {
background-color: ${colorsTokens['greyscale-100']};
}
${isSmallMobile
? css`
border: 1px solid ${colorsTokens['greyscale-300']};
`
: ''}
`}
>
<IconOptions aria-hidden="true" isHorizontal $theme="primary" />
</DropdownMenu>
</Box>
@@ -1,2 +1,2 @@
export * from './DocHeader';
export * from './DocVersionHeader';
export * from './DocTitle';
@@ -10,13 +10,16 @@ import {
getEmojiAndTitle,
} from '../utils';
// Mock Y.js
vi.mock('yjs', () => ({
Doc: vi.fn().mockImplementation(() => ({
getXmlFragment: vi.fn().mockReturnValue('mocked-xml-fragment'),
})),
applyUpdate: vi.fn(),
}));
vi.mock('yjs', () => {
class MockDoc {
getXmlFragment = vi.fn().mockReturnValue('mocked-xml-fragment');
}
return {
Doc: MockDoc,
applyUpdate: vi.fn(),
};
});
describe('doc-management utils', () => {
beforeEach(() => {
@@ -26,45 +29,30 @@ describe('doc-management utils', () => {
describe('base64ToYDoc', () => {
it('should convert base64 string to Y.Doc', () => {
const base64String = 'dGVzdA=='; // "test" in base64
const mockYDoc = { getXmlFragment: vi.fn() };
(Y.Doc as any).mockReturnValue(mockYDoc);
const result = base64ToYDoc(base64String);
expect(Y.Doc).toHaveBeenCalled();
expect(Y.applyUpdate).toHaveBeenCalledWith(mockYDoc, expect.any(Buffer));
expect(result).toBe(mockYDoc);
expect(result).toBeInstanceOf(Y.Doc);
expect(Y.applyUpdate).toHaveBeenCalledWith(result, expect.any(Buffer));
});
it('should handle empty base64 string', () => {
const base64String = '';
const mockYDoc = { getXmlFragment: vi.fn() };
(Y.Doc as any).mockReturnValue(mockYDoc);
const result = base64ToYDoc(base64String);
expect(Y.Doc).toHaveBeenCalled();
expect(Y.applyUpdate).toHaveBeenCalledWith(mockYDoc, expect.any(Buffer));
expect(result).toBe(mockYDoc);
expect(result).toBeInstanceOf(Y.Doc);
expect(Y.applyUpdate).toHaveBeenCalledWith(result, expect.any(Buffer));
});
});
describe('base64ToBlocknoteXmlFragment', () => {
it('should convert base64 to Blocknote XML fragment', () => {
const base64String = 'dGVzdA==';
const mockYDoc = {
getXmlFragment: vi.fn().mockReturnValue('mocked-xml-fragment'),
};
(Y.Doc as any).mockReturnValue(mockYDoc);
const result = base64ToBlocknoteXmlFragment(base64String);
expect(Y.Doc).toHaveBeenCalled();
expect(Y.applyUpdate).toHaveBeenCalledWith(mockYDoc, expect.any(Buffer));
expect(mockYDoc.getXmlFragment).toHaveBeenCalledWith('document-store');
expect(Y.applyUpdate).toHaveBeenCalled();
expect(result).toBe('mocked-xml-fragment');
});
});
@@ -21,18 +21,18 @@ export const createFavoriteDoc = async ({ id }: CreateFavoriteDocParams) => {
interface CreateFavoriteDocProps {
onSuccess?: () => void;
listInvalideQueries?: string[];
listInvalidQueries?: string[];
}
export function useCreateFavoriteDoc({
onSuccess,
listInvalideQueries,
listInvalidQueries,
}: CreateFavoriteDocProps) {
const queryClient = useQueryClient();
return useMutation<void, APIError, CreateFavoriteDocParams>({
mutationFn: createFavoriteDoc,
onSuccess: () => {
listInvalideQueries?.forEach((queryKey) => {
listInvalidQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
@@ -21,18 +21,18 @@ export const deleteFavoriteDoc = async ({ id }: DeleteFavoriteDocParams) => {
interface DeleteFavoriteDocProps {
onSuccess?: () => void;
listInvalideQueries?: string[];
listInvalidQueries?: string[];
}
export function useDeleteFavoriteDoc({
onSuccess,
listInvalideQueries,
listInvalidQueries,
}: DeleteFavoriteDocProps) {
const queryClient = useQueryClient();
return useMutation<void, APIError, DeleteFavoriteDocParams>({
mutationFn: deleteFavoriteDoc,
onSuccess: () => {
listInvalideQueries?.forEach((queryKey) => {
listInvalidQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
@@ -60,7 +60,7 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
const { provider } = useProviderStore();
const { mutateAsync: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
});
return useMutation<DuplicateDocResponse, APIError, DuplicateDocParams>({
@@ -34,7 +34,7 @@ export const updateDoc = async ({
};
type UseUpdateDoc = UseMutationOptions<Doc, APIError, Partial<Doc>> & {
listInvalideQueries?: string[];
listInvalidQueries?: string[];
};
export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
@@ -43,7 +43,7 @@ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
mutationFn: updateDoc,
...queryConfig,
onSuccess: (data, variables, onMutateResult, context) => {
queryConfig?.listInvalideQueries?.forEach((queryKey) => {
queryConfig?.listInvalidQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});

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