Compare commits

..

46 Commits

Author SHA1 Message Date
Manuel Raynaud
22426551f0 💚(docker) keep pip in the Docker image
We have to keep pip to avoid unterminated management command when image
is built in arm64 architecture
2026-05-07 15:39:23 +02:00
Manuel Raynaud
03b00af3d0 💚(docker) ignore .venv with compilemessages command
The compilemessages management command was also compiling messages for
all the libraries present in the .venv folder. We have to ignore it, on
arm64 this management command is taking too much time otherwise.
2026-05-07 14:04:33 +02:00
Manuel Raynaud
1268bbe5ea 🔧(actions) migrate from pip to uv
Migreate usage of pip to uv in github actions. How python is setup is
also changed.
2026-05-07 13:09:43 +02:00
Manuel Raynaud
8fc13d75dc ♻️(backend) migrate from setuptool to uv_build as build backend
We already migrate from pip to uv to manage our dependencies. We can also
migrate the build backend from setuptool to uv_build.
In the pyproject file, the readme property has been removed, because
uv_build try to read it, but the readme is at the root of the project
and not copied into the Dockerfile instructions. This readme can be used
when the package is published on pypi but it is not the case for Docs.
2026-05-07 13:09:43 +02:00
Manuel Raynaud
aea6fbef9b 🏗️(core) migrate from pip to uv
We want to remove the usage of pip in order to use uv as python package
manager.
2026-05-07 13:09:42 +02:00
Manuel Raynaud
a47c35195d 🐛(backend) replace document creation table locks with retry strategy
We have situation where the number of locks in the database can increase
dangerously creating deadlock situation. To remove this situation we
decided to change the strategy to manage document creation concurrency.
We decided to use a retry strategy, trying to create the document
multiple times while a usable path is found. To avoid having an
inifinite loop, we use a max_attempts counter configurable using the
setting TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS
2026-05-07 11:52:48 +02:00
Manuel Raynaud
8f67b37d70 ♻️(backend) split core/utils.py module
We need to split the core/utils.py in multiple submodule created in
core/utils/*.py. We need to do this to avoir circular import between
this module and the models module.
2026-05-07 11:45:57 +02:00
Manuel Raynaud
0b20d9f435 🐛(backend) manage race condition between GET and PATCH content
When a PATCH and a GET on the content endpoint are made at the same time
for different users a race condition can happen and the metadata
returned
by the S3 head_object can be outdated when the object is fetched leading
to an error raised because the Content-Length header does not match the
size of the response body. To avoid this, we no longer used head_object
followed bu get_object, we have to manage
everything in one call with the get_object. The get_object also accepts
as parameters an etag or last-modified header and will return a 304 if
the content has not changed, so we can use this to not return the entire
body if this one has not changed.
2026-05-07 09:43:20 +00:00
Anthony LC
a166716a2f ️(frontend) close websocket connection when user change tab
When a user change to another tab, after a delay of "inactivity"
we disconnect the user from the collaboration server.
When the user come back we reconnect to the server
again. It will reduce the connection to the collaboration
server and reduce outburst during reconnection during
a ingress ngnix restart.
2026-05-06 16:19:40 +02:00
Manuel Raynaud
6fe0221596 (backend) new settings COLLABORATION_WS_INACTIVITY_TIMEOUT
We want to configure the timeout, in second, a user is consider as
inactive. After this inactivity period we want to close the websocket
connection
2026-05-06 16:19:40 +02:00
Anthony LC
bd662d72bf 🐛(frontend) fix loading comments transaction
When we load the comments we have to notify the
subscribers of the DocsThreadStore. This generates
a Yjs transaction that is currently treated as a
user-initiated content change that will trigger
a patch request when the doc will try to save.
We now update the transaction origin when we notify
the subscribers so that we can reliably identify
and ignore those transactions in the useSaveDoc
hook.
2026-05-06 15:55:47 +02:00
Anthony LC
3701fe5a45 🐛(frontend) interlinking are exported correctly in print mode
Interlinking are now exported correctly in print
mode. The interlinking was not a link in print
mode, but now it is.
2026-05-06 15:12:24 +02:00
Anthony LC
0f527a789a 🔒️(frontend) sanitize color during collaboration
To improve security we sanitize the color used
for collaboration presence to ensure it's a valid
hex color. If the color is not valid, we generate
a random color instead. This prevents potential
issues with invalid color values being used in the UI.
2026-05-05 15:21:16 +02:00
renovate[bot]
85128c7b11 ⬆️(dependencies) update axios to v1.15.2 [SECURITY] 2026-05-05 12:25:22 +00:00
Anthony LC
5f700ed6c4 💬(frontend) add missing link in onboarding description
We added a missing link in the onboarding step
description to direct users to ready-made templates f
or common use cases. This enhancement aims to improve
the user experience by providing easy access to
resources that can help users get started quickly
and customize their workflow efficiently.
2026-05-05 13:23:19 +02:00
Anthony LC
b0d9ed15c0 ️(frontend) add skeleton on content loading
Content is longer to load than other parts of the
editor because of the connection with websocket
to the collaboration server. To improve the user
experience, we add a skeleton on the content part
of the editor while the others parts are displayed.
2026-05-05 12:12:11 +02:00
Anthony LC
d41e44dcd5 🐛(frontend) fix uikit dnd tree
The last version of UIKit has a bug that causes
the dnd tree to break. It is due to some
pointers event that are not properly handled.
We remove the pointer event in waiting for the
fix to be released.
2026-05-05 10:56:59 +02:00
Anthony LC
07e7b3feb6 🏷️(mail) adapt to mjml v5
We upgraded to mjml v5, which has some breaking changes.
By default the ubuntu font was loaded, with google
fonts, that is not GDPR compliant.
We switched to Inter, and uses fonts.bunny.net to
load the font, which is GDPR compliant.
2026-05-05 10:56:59 +02:00
Anthony LC
aa71cfdfc0 ️(frontend) remove listener on hover with react-dropzone
React-dropzone was rerendering components on hover,
which was unnecessary. This commit removes the
rerendering by adapting its settings.
2026-05-05 10:53:39 +02:00
Anthony LC
7afa17a181 💄(frontend) adapt css to Blocknote v0.49
We updated Blocknote to v0.49, which includes
some breaking changes in the CSS. This commit adapts
our custom styles to the new version of Blocknote.
2026-05-05 10:53:39 +02:00
Anthony LC
af2b381097 🐛(frontend) fix scroll table of content
The scroll of the table of content was calculated
on mount of the component, so when the editor height change,
the scroll of the table of content was not updated.
We added a observer to observe the height of the
editor and update the scroll of the table of
content when the height change.
2026-05-05 10:53:39 +02:00
Anthony LC
5015d42677 🏷️(frontend) adapt types to i18next v26
We updated i18next to v26, which includes some
breaking changes. This commit adapts our types
to the new version, ensuring compatibility and
proper type checking throughout our codebase.
2026-05-05 10:53:39 +02:00
Anthony LC
738ff90fc7 🏷️(frontend) adapt types to upgrade Cunningham and ui-kit
We upgraded Cunningham and ui-kit dependencies, which
introduced some breaking changes. This commit adapts
our code to these changes, ensuring compatibility
with the new versions of these libraries.
2026-05-05 10:53:38 +02:00
Anthony LC
0e8094c733 🏷️(frontend) adapt types to typescript v6
We updated typescript to v6, which includes some
breaking changes. This commit adapts our code to
be compatible with the new version of typescript.
2026-05-05 10:53:38 +02:00
renovate[bot]
9231730bf0 ⬆️(dependencies) update js dependencies 2026-05-05 10:50:49 +02:00
Cyril
21100b986d 🐛(frontend) sanitize pasted and dropped content in document title
Prevent rich-text formatting when pasting or dropping into document title.
2026-05-04 16:14:39 +02:00
Manuel Raynaud
eaddbd83d7 🔖(major) release 5.0.0
Added

- (backend) create a dedicated endpoint to update document content
- ️(backend) stream s3 file content with a dedicated endpoint
- (backend) allow to use new ai feature using mistral sdk

Changed

- ♻️(backend) rename documents content endpoint in `formatted-content` (BC)
- 🚸(frontend) show Crisp from the help menu #2222
- ️(frontend) structure correctly 5xx error alerts #2128
- ️(frontend) make doc search result labels uniquely identifiable #2212
- ⬆️(backend) upgrade docspec to v3.0.x and adapt converter API #2220
- (backend) make forward auth request uri header configurable #2241
- ️(frontend) fix sidebar resize handle for screen readers #2122

Fixed

- 🚸(frontend) redirect on current url tab after 401 #2197
- 🐛(frontend) abort check media status unmount #2194
- (backend) order pinned documents by last updated at #2028
- 🐛(frontend) fix app shallow reload #2231
- 🐛(frontend) fix interlinking modal clipping #2213
- 🛂(frontend) fix cannot manage member on small screen #2226
- 🐛(backend) load jwks url when OIDC_RS_PRIVATE_KEY_STR is set
- 🐛(backend) Prevent moving document to its own descendant or self #2208
- 🐛(backend) return 400 when restoring a non-deleted document #2225
2026-05-04 11:59:35 +02:00
github-actions[bot]
22c587fdd0 🌐(i18n) update translated strings
update translated strings
2026-04-30 15:28:44 +02:00
Manuel Raynaud
9568d12f68 ♻️(backend) improve legacy AI translate prompt
The return of the legacy AI translate prompt is sometimes surrounded
with a html code block. We improve the prompt to not return this
surrounding code block.
2026-04-30 11:23:11 +02:00
Manuel Raynaud
33a9e99d54 (backend) manage langfuse with the mistral sdk
Langfuse was not working with the legacy client using the mistral sdk.
We want to add the support of langfuse for it.
2026-04-30 09:32:34 +02:00
Manuel Raynaud
6cfc8990b9 ♻️(backend) use mistral sdk with legacy ai feature
We also want to use the mistral sdk with the legacy AI feature when this
one is configured with the settings. In order to separate bot feature,
they now live in their own module.
2026-04-30 09:32:34 +02:00
Manuel Raynaud
8c84dbf39a ♻️(frontend) all dev origins when using the tilt environment
When developping using the tilt environment, we need to allow the domain
docs.127.0.0.1.nip.io in the next configuration.

Also the the customization is removed as it is not maintained.
2026-04-30 09:32:34 +02:00
Manuel Raynaud
b6efac3983 (backend) allow to use new ai feature using mistral sdk
We give the possibility, for the new ai feature, to choose between using
the OpenAI or Mistral sdk. For instances having access to the mistral
infrastructure, using it is mor appropriated than using the openai
compatible chat model.
2026-04-30 09:32:34 +02:00
Anthony LC
fa9d56d79b 🔒️(js) fix security warning
Critical:
- protobufjs CVE

High:
- lodash CVE
- picomatch CVE
- Vite CVE

Moderate:
- postcss CVE
- uuid CVE
- dompurify CVE
- follow-redirects CVE
2026-04-29 15:04:58 +02:00
Manuel Raynaud
4fe508bba1 ⬆️(docker) upgrade nginx image to last version
We want to upgrade the frontend image using the last nginx version
available in order to remove some fixed in version 1.29.7
2026-04-29 09:42:45 +02:00
Manuel Raynaud
487d0b12ca (backend) fix flaky search descendants test
One test about the search descendants test was flaky. It is because the
link_reach and link_role were used to test the
ancestors_link_(reach|role). The properties ancestors_link_reach and
ancestors_link_role should be used instead.
2026-04-29 09:18:05 +02:00
Anthony LC
9f1d4543e7 (e2e) adapt e2e test to fit translation upgrade
We upgraded djangorestframework, which includes a
change in the translation system. This change caused
the e2e test for the app impress language
to fail.
To fix this, we updated the test to work
with the new translation system.
2026-04-29 09:16:12 +02:00
renovate[bot]
c90280fb4d ⬆️(dependencies) update python dependencies 2026-04-29 09:16:12 +02:00
Cyril
a2860e8fe6 ️(frontend) fix sidebar resize handle for screen readers
Expose the handle as a slider so arrow keys work with NVDA
2026-04-29 07:12:10 +02:00
Mohamed El Amine BOUKERFA
cfd1fd00da 🐛(backend) Forbid restoring a non-deleted document
Catch RuntimeError raised by Document.restore() and translate it into a
DRF ValidationError so callers get a 400 instead of a 500, when trying
to restore a non-deleted document.
    
Signed-off-by: Mohamed El Amine BOUKERFA <boukerfa.ma@gmail.com>
2026-04-28 14:53:30 +00:00
Mohamed El Amine BOUKERFA
ed663f2e1e 🐛(backend) Prevent moving document to its own descendant or self
When attempting to move a document to itself or to any of its
descendants, the server would crash with a 500 Internal Server
Error.
    
Signed-off-by: Mohamed El Amine BOUKERFA <boukerfa.ma@gmail.com>
2026-04-28 14:13:42 +00:00
Mohamed El Amine BOUKERFA
99764b8e3e 🐛(backend) strip whitespace from media URLs in CORS proxy
When exporting a document to PDF, having whitespace before or after
the media URL causes the image to not be downloaded via the CORS proxy,
resulting in missing images in the exported PDF.
    
Signed-off-by: Mohamed El Amine BOUKERFA <boukerfa.ma@gmail.com>
2026-04-28 13:47:16 +00:00
Mohamed El Amine BOUKERFA
37091ca804 🐛(backend) enforce emoji validation for reactions
Validate emojis in ReactionSerializer (previously accepted
any string), preventing multiple emojis or text uploads in
a single reaction
    
Signed-off-by: Mohamed El Amine BOUKERFA <boukerfa.ma@gmail.com>
2026-04-28 13:10:04 +00:00
Erin
394fbc5537 (backend) make forward auth request uri header configurable
In deployment, Traefik is used, not nginx, as an ingress. Traefik
uses `X-Forwarded-Ur`i instead of `X-Original-Url`. This adds a setting
which lets users adapt Docs to their ingress proxy of choice
The settings name is MEDIA_AUTH_ORIGINAL_URL_HEADER

Signed-off-by: Erin Shepherd <erin.shepherd@e43.eu>
2026-04-28 08:57:19 +00:00
Anthony LC
7df5aba991 (e2e) fix uuid not formatted correctly in mocked document
We added a guard on the uuid format in our frontend
requests, this guard broke some of our e2e tests
because the mocked document id was not a valid uuid.
2026-04-28 09:08:30 +02:00
renovate[bot]
c464715158 ⬆️(dependencies) update uuid to v14 [SECURITY] 2026-04-27 21:21:59 +00:00
149 changed files with 8775 additions and 5099 deletions

View File

@@ -96,21 +96,20 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install Python
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13.3"
cache: "pip"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .[dev]
python-version-file: "src/backend/pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
run: uv run ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
run: uv run ruff check .
- name: Lint code with pylint
run: ~/.local/bin/pylint .
run: uv run pylint .
test-back:
runs-on: ubuntu-latest
@@ -192,14 +191,14 @@ jobs:
mc mb impress/impress-media-storage && \
mc version enable impress/impress-media-storage"
- name: Install Python
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13.3"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
python-version-file: "src/backend/pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install the project
run: uv sync --locked --all-extras
- name: Install gettext (required to compile messages) and MIME support
run: |
@@ -208,7 +207,7 @@ jobs:
sudo wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/conf/mime.types -O /etc/mime.types
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
run: uv run python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
run: uv run pytest -n 2

View File

@@ -8,8 +8,38 @@ and this project adheres to
### Added
- ✨(backend) create a dedicated endpoint to update document content
- ⚡️(backend) stream s3 file content with a dedicated endpoint
- ⚡️(frontend) add skeleton on content loading #2254
- ⚡️(frontend) close websocket connection when user change tab #2264
### Changed
- 🏗️(core) migrate from pip to uv
### Fixed
- 🐛(frontend) fix patch and comments #2273
- 🐛(frontend) interlinking are exported correctly in print mode #2269
- 💬(frontend) add missing link in onboarding description #2233
- 🐛(frontend) sanitize pasted and dropped content in document title #2210
- 🐛(frontend) Emoji menu doesn't display above comment box #2229
- 🐛(frontend) Block menu doesn't stay open on 1st line #2229
- 🐛(frontend) The "+" on the first line of a new doc doesn't work #2229
- 🐛(backend) manage race condition between GET and PATCH content #2271
- 🐛(backend) replace document creation table locks with retry strategy #2274
### Security
- 🔒️(frontend) sanitize color during collaboration #2270
## [v5.0.0] - 2026-04-08
### Added
- ✨(backend) create a dedicated endpoint to update document content #2171
- ⚡️(backend) stream s3 file content with a dedicated endpoint #2171
- ✨(backend) allow to use new ai feature using mistral sdk #2193
### Changed
@@ -18,6 +48,8 @@ and this project adheres to
- ♿️(frontend) structure correctly 5xx error alerts #2128
- ♿️(frontend) make doc search result labels uniquely identifiable #2212
- ⬆️(backend) upgrade docspec to v3.0.x and adapt converter API #2220
- ✨(backend) make forward auth request uri header configurable #2241
- ♿️(frontend) fix sidebar resize handle for screen readers #2122
### Fixed
@@ -28,11 +60,16 @@ and this project adheres to
- 🐛(frontend) fix interlinking modal clipping #2213
- 🛂(frontend) fix cannot manage member on small screen #2226
- 🐛(backend) load jwks url when OIDC_RS_PRIVATE_KEY_STR is set
- 🐛(backend) Prevent moving document to its own descendant or self #2208
- 🐛(backend) return 400 when restoring a non-deleted document #2225
- 🐛(backend) fix race condition in reconciliation requests CSV import #2153
- 🐛(backend) create_for_owner: add accesses before saving doc content #2124
- 🐛(backend) enforce emoji validation for reactions #1965
### Removed
- 🔥(backend) remove deprecated descendants endpoint #2243
- 🔥(backend) remove content in document responses
- 🔥(backend) remove content in document responses #2171
## [v4.8.6] - 2026-04-08
@@ -71,7 +108,6 @@ and this project adheres to
- ⚡️(frontend) add jitter to WS reconnection #2162
- 🐛(frontend) fix tree pagination #2145
- 🐛(nginx) add page reconciliation on nginx #2154
- 🐛(backend) fix race condition in reconciliation requests CSV import #2153
## [v4.8.4] - 2026-03-25
@@ -93,9 +129,6 @@ and this project adheres to
- 🐛(y-provider) destroy Y.Doc instances after each convert request #2129
- 🐛(backend) remove deleted sub documents in favorite_list endpoint #2083
### Fixed
- 🐛(backend) create_for_owner: add accesses before saving doc content #2124
## [v4.8.3] - 2026-03-23
@@ -1264,7 +1297,8 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.8.6...main
[unreleased]: https://github.com/suitenumerique/docs/compare/v5.0.0...main
[v5.0.0]: https://github.com/suitenumerique/docs/releases/v5.0.0
[v4.8.6]: https://github.com/suitenumerique/docs/releases/v4.8.6
[v4.8.5]: https://github.com/suitenumerique/docs/releases/v4.8.5
[v4.8.4]: https://github.com/suitenumerique/docs/releases/v4.8.4

View File

@@ -1,24 +1,37 @@
# Django impress
# ---- base image to inherit from ----
FROM python:3.13.3-alpine AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
FROM python:3.13.13-alpine AS base
# Upgrade system packages to install security updates
RUN apk update && apk upgrade --no-cache
# We must do that to avoid having an outdated pip version with security issues
RUN python -m pip install --upgrade pip
# ---- Back-end builder image ----
FROM base AS back-builder
WORKDIR /builder
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
# Copy required python dependencies
COPY ./src/backend /builder
# Disable Python downloads, because we want to use the system interpreter
# across both images. If using a managed Python version, it needs to be
# copied from the build image into the final image;
ENV UV_PYTHON_DOWNLOADS=0
RUN mkdir /install && \
pip install --prefix=/install .
# install uv
COPY --from=ghcr.io/astral-sh/uv:0.11.10 /uv /uvx /bin/
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY src/backend /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ----
@@ -41,14 +54,13 @@ RUN apk add --no-cache \
pango \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy impress application (see .dockerignore)
COPY ./src/backend /app/
# Copy the application from the builder
COPY --from=back-builder /app /app
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# collectstatic
RUN DJANGO_CONFIGURATION=Build \
python manage.py collectstatic --noinput
@@ -84,8 +96,12 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# docker user (see entrypoint).
RUN chmod g=u /etc/passwd
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy the application from the builder
COPY --from=back-builder /app /app
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# Link certifi certificate from a static path /cert/cacert.pem to avoid issues
# when python is upgraded and the path to the certificate changes.
@@ -95,14 +111,9 @@ RUN mkdir /cert && \
mv $path /cert/ && \
ln -s /cert/cacert.pem $path
# Copy impress application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages
python manage.py compilemessages --ignore=".venv/**/*"
# We wrap commands run in this container by the following entrypoint that
@@ -119,10 +130,9 @@ USER root:root
# Install psql
RUN apk add --no-cache postgresql-client
# Uninstall impress and re-install it in editable mode along with development
# dependencies
RUN pip uninstall -y impress
RUN pip install -e .[dev]
# Install development dependencies
RUN --mount=from=ghcr.io/astral-sh/uv:0.11.10,source=/uv,target=/bin/uv \
uv sync --all-extras --locked
# Restore the un-privileged user running the application
ARG DOCKER_USER

View File

@@ -72,7 +72,7 @@ data/static:
# -- Project
create-env-local-files: ## create env.local files in env.d/development
create-env-local-files:
create-env-local-files:
@touch env.d/development/crowdin.local
@touch env.d/development/common.local
@touch env.d/development/postgresql.local
@@ -141,7 +141,7 @@ else
@echo "$(RESET)"
@echo "$(GREEN)Starting bootstrap process...$(RESET)"
endif
@echo ""
@echo ""
.PHONY: pre-beautiful-bootstrap
post-beautiful-bootstrap: ## Display a success message after bootstrap
@@ -235,7 +235,7 @@ run-backend: ## Start only the backend application and all needed services
.PHONY: run-backend
run: ## start the wsgi (production) and development server
run:
run:
@$(MAKE) run-backend
@$(COMPOSE) up --force-recreate -d frontend-development
.PHONY: run
@@ -322,7 +322,7 @@ superuser: ## Create an admin superuser with password "admin"
.PHONY: superuser
back-i18n-compile: ## compile the gettext files
@$(MANAGE) compilemessages --ignore="venv/**/*"
@$(MANAGE) compilemessages --ignore=".venv/**/*"
.PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n

View File

@@ -16,6 +16,8 @@ the following command inside your docker container:
## [Unreleased]
### [5.0.0] - 2026-04-30
We made several changes around document content management leading to several breaking changes in the API.
- The endpoint `/api/v1.0/documents/{document_id}/content/` has been renamed in `/api/v1.0/documents/{document_id}/formatted-content/`
@@ -33,6 +35,9 @@ Other changes:
- The deprecated endpoint `/api/v1.0/documents/<document_id>/descendants` is removed. The search endpoint should be used instead.
- Upgrade docspec dependency to version >= 3.0.0
The docspec service has changed since version 3.0.0, we ware now compatible with this version and not with version 2.x.x anymore
- It is now possible to use the Mistral SDK instead of the OpenAI for the AI features. If your provider is compatible with the mistral API, we encourage you to use it.
- `AI_API_KEY` settings is renamed in `OPENAI_SDK_API_KEY` and is only used to congiure the OpenAi sdk
- `AI_BASE_URL` settings is renamed in `OPENAI_SDK_BASE_URL` and is only used to congiure the OpenAi sdk
## [4.6.0] - 2026-02-27

View File

@@ -80,6 +80,7 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- /app/.venv
depends_on:
postgresql:
condition: service_healthy
@@ -108,6 +109,7 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- /app/.venv
depends_on:
- app-dev

View File

@@ -9,14 +9,16 @@ These are the environment variables you can set for the `impress-backend` contai
| Option | Description | default |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | |
| AI_BOT | Information to give to the frontend about the AI bot | { "name": "Docs AI", "color": "#8bc6ff" }
| OPENAI_SDK_API_KEY | AI key to be used by the OpenAI python SDK | |
| OPENAI_SDK_BASE_URL | OpenAI compatible AI base url | |
| MISTRAL_SDK_API_KEY | AI key to be used by the Mistral python SDK /!\ Mistral sdk can be used only in async mode with uvicorn /!\ | |
| MISTRAL_SDK_BASE_URL | Mistral compatible AI base url | |
| AI_BOT | Information to give to the frontend about the AI bot | { "name": "Docs AI", "color": "#8bc6ff" } |
| AI_FEATURE_ENABLED | Enable AI options | false |
| AI_FEATURE_BLOCKNOTE_ENABLED | Enable Blocknote AI options | false |
| AI_FEATURE_LEGACY_ENABLED | Enable legacyAI options | true |
| AI_FEATURE_BLOCKNOTE_ENABLED | Enable Blocknote AI options | false |
| AI_FEATURE_LEGACY_ENABLED | Enable legacyAI options | true |
| AI_MODEL | AI Model to use | |
| AI_VERCEL_SDK_VERSION | The vercel AI SDK version used | 6 |
| AI_VERCEL_SDK_VERSION | The vercel AI SDK version used | 6 |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
@@ -32,6 +34,7 @@ These are the environment variables you can set for the `impress-backend` contai
| CACHES_DEFAULT_KEY_PREFIX | The prefix used to every cache keys. | docs |
| COLLABORATION_API_URL | Collaboration api host | |
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
| COLLABORATION_WS_INACTIVITY_TIMEOUT | Timeout (in seconds) after which the user is considered inactive when there is no activity. The WebSocket is closed after this inactivity period. `None` means disabled. | None |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
| COLLABORATION_WS_URL | Collaboration websocket url | |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
@@ -91,6 +94,7 @@ These are the environment variables you can set for the `impress-backend` contai
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
| MEDIA_BASE_URL | | |
| MEDIA_AUTH_ORIGINAL_URL_HEADER | Parameter containing the original request URL, as seen at the media auth endpoint, in CGI/WSGI form (HTTP_HEADER_NAME_ALL_CAPS_WITH_UNDERSCORES) | HTTP_X_ORIGINAL_URL |
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
@@ -131,6 +135,7 @@ These are the environment variables you can set for the `impress-backend` contai
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS | Number of attempts to create a document before failing. | 10 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| USER_ONBOARDING_DOCUMENTS | A list of documents IDs for which a read-only access will be created for new s | [] |
| USER_ONBOARDING_SANDBOX_DOCUMENT | ID of a template sandbox document that will be duplicated for new users | |

View File

@@ -71,14 +71,6 @@ OIDC_RS_ALLOWED_AUDIENCES=""
# User reconciliation
USER_RECONCILIATION_FORM_URL=http://localhost:3000
# AI
AI_FEATURE_ENABLED=true
AI_FEATURE_BLOCKNOTE_ENABLED=true
AI_FEATURE_LEGACY_ENABLED=true
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
# Collaboration
COLLABORATION_API_URL=http://y-provider-development:4444/collaboration/api/
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
@@ -86,6 +78,7 @@ COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
COLLABORATION_WS_INACTIVITY_TIMEOUT=15 # Seconds
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/

View File

@@ -7,22 +7,23 @@ from base64 import b64decode
from os.path import splitext
from django.conf import settings
from django.db import connection, transaction
from django.db.models import Q
from django.utils.functional import lazy
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
import emoji
import magic
from rest_framework import serializers
from core import choices, enums, models, validators
from core.services import mime_types
from core.services.ai_services import AI_ACTIONS
from core.services.ai_services.legacy import AI_ACTIONS
from core.services.converter_services import (
ConversionError,
Converter,
)
from core.utils.treebeard import create_tree_node_with_retry
class UserSerializer(serializers.ModelSerializer):
@@ -466,18 +467,12 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
{"content": ["Could not convert content"]}
) from err
with transaction.atomic():
# locks the table to ensure safe concurrent access
with connection.cursor() as cursor:
cursor.execute(
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
"IN SHARE ROW EXCLUSIVE MODE;"
)
document = models.Document.add_root(
document = create_tree_node_with_retry(
lambda: models.Document.add_root(
title=validated_data["title"],
creator=user,
)
)
if user:
# Associate the document with the pre-existing user
@@ -875,6 +870,12 @@ class ReactionSerializer(serializers.ModelSerializer):
]
read_only_fields = ["id", "created_at", "users"]
def validate_emoji(self, value):
"""Ensure the reaction is a single emoji."""
if not emoji.is_emoji(value):
raise serializers.ValidationError("Reaction must be a single valid emoji.")
return value
class CommentSerializer(serializers.ModelSerializer):
"""Serialize comments (nested under a thread) with reactions and abilities."""

View File

@@ -1,5 +1,6 @@
"""Util to generate S3 authorization headers for object storage access control"""
import datetime as dt
import time
from abc import ABC, abstractmethod
@@ -199,3 +200,31 @@ class AIUserRateThrottle(AIBaseRateThrottle):
def get_content_metadata_cache_key(document_id):
"""Return the cache key used to store content metadata."""
return f"docs:content-metadata:{document_id!s}"
def parse_http_conditional_headers(request):
"""Extract and normalize `If-None-Match` and `If-Modified-Since`.
The `W/` weak prefix is stripped from the ETag because reverse proxies
(e.g. nginx with gzip) rewrite strong ETags into weak ones, which would
otherwise break a strict equality check in production.
"""
if_none_match = request.META.get("HTTP_IF_NONE_MATCH")
if if_none_match and if_none_match.startswith("W/"):
if_none_match = if_none_match.removeprefix("W/")
if_modified_since_dt = None
if not (if_modified_since := request.META.get("HTTP_IF_MODIFIED_SINCE")):
return if_none_match, if_modified_since_dt
try:
if_modified_since_dt = dt.datetime.strptime(
if_modified_since, "%a, %d %b %Y %H:%M:%S %Z"
)
except ValueError:
if_modified_since_dt = None
else:
if not if_modified_since_dt.tzinfo:
if_modified_since_dt = if_modified_since_dt.replace(tzinfo=dt.timezone.utc)
return if_none_match, if_modified_since_dt

View File

@@ -44,11 +44,13 @@ from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView
from treebeard.exceptions import InvalidMoveToDescendant
from core import authentication, choices, enums, models
from core.api.filters import remove_accents
from core.services import mime_types
from core.services.ai_services import AIService
from core.services.ai_services.blocknote import AIService
from core.services.ai_services.legacy import get_legacy_ai_service
from core.services.collaboration_services import CollaborationService
from core.services.converter_services import (
ConversionError,
@@ -65,11 +67,10 @@ from core.services.search_indexers import (
get_visited_document_ids_of,
)
from core.tasks.mail import send_ask_for_access_mail
from core.utils import (
extract_attachments,
filter_descendants,
users_sharing_documents_with,
)
from core.utils.paths import filter_descendants
from core.utils.treebeard import create_tree_node_with_retry
from core.utils.users import users_sharing_documents_with
from core.utils.yjs import extract_attachments
from ..enums import FeatureFlag, SearchType
from . import permissions, serializers, utils
@@ -706,18 +707,12 @@ class DocumentViewSet(
{"file": ["Could not convert file content"]}
) from err
with transaction.atomic():
# locks the table to ensure safe concurrent access
with connection.cursor() as cursor:
cursor.execute(
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
"IN SHARE ROW EXCLUSIVE MODE;"
)
obj = models.Document.add_root(
obj = create_tree_node_with_retry(
lambda: models.Document.add_root(
creator=self.request.user,
**serializer.validated_data,
)
)
serializer.instance = obj
models.DocumentAccess.objects.create(
document=obj,
@@ -961,7 +956,13 @@ class DocumentViewSet(
status=status.HTTP_400_BAD_REQUEST,
)
document.move(target_document, pos=position)
try:
document.move(target_document, pos=position)
except InvalidMoveToDescendant:
return drf.response.Response(
{"target_document_id": "Cannot move a document to its own descendant."},
status=status.HTTP_400_BAD_REQUEST,
)
# Make sure we have at least one owner
if (
@@ -989,7 +990,10 @@ class DocumentViewSet(
Restore a soft-deleted document if it was deleted less than x days ago.
"""
document = self.get_object()
document.restore()
try:
document.restore()
except RuntimeError as err:
raise drf.exceptions.ValidationError({"detail": str(err)}) from err
return drf_response.Response(
{"detail": "Document has been successfully restored."},
@@ -1012,16 +1016,12 @@ class DocumentViewSet(
)
serializer.is_valid(raise_exception=True)
with transaction.atomic():
# "select_for_update" locks the table to ensure safe concurrent access
locked_parent = models.Document.objects.select_for_update().get(
pk=document.pk
)
child_document = locked_parent.add_child(
child_document = create_tree_node_with_retry(
lambda: document.add_child(
creator=request.user,
**serializer.validated_data,
)
)
# Set the created instance to the serializer
serializer.instance = child_document
@@ -1752,10 +1752,13 @@ class DocumentViewSet(
def _auth_get_original_url(self, request):
"""
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
Extracts and parses the original URL from the configured parameter header.
Raises PermissionDenied if the header is missing.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
The original url is passed by reverse proxy in the header specified by the
MEDIA_AUTH_ORIGINAL_URL_HEADER setting.
For nginx (the default) this is set to HTTP_X_ORIGINAL_URL.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
@@ -1766,9 +1769,14 @@ class DocumentViewSet(
reasons.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
if not original_url:
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
logger.debug(
"Missing %s header in subrequest. "
"Maybe you need to set MEDIA_AUTH_ORIGINAL_URL_HEADER correctly for your ingress"
" proxy.",
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
)
raise drf.exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url)
@@ -1922,11 +1930,12 @@ class DocumentViewSet(
Retrieve the raw content file from s3 and stream it.
We implement a HTTP cache based on the ETag and LastModified headers.
We retrieve the ETag and LastModified from the S3 head operation, save them in cache to
reuse them in future requests.
The ETag and LastModified are retrieved in the S3 get_object operation to be consistent with
the content Body retrieved at the same time. These metadata are saved in cache for
future requests.
We check in the request if the ETag is present in the If-None-Match header and if it's the
same as the one from the S3 head operation, we return a 304 response.
If the ETag is not present or not the same, we do the same check based on the LastModifed
same as the one from the S3 get_object, we return a 304 response.
If the ETag is not present or not the same, we do the same check based on the LastModified
value if present in the If-Modified-Since header.
"""
document = self.get_object()
@@ -1936,73 +1945,69 @@ class DocumentViewSet(
# the web-socket re-connection burst.
connection.close()
if not (
content_metadata := cache.get(
utils.get_content_metadata_cache_key(document.id)
if_none_match, if_modified_since_dt = utils.parse_http_conditional_headers(
request
)
# First check if a cache is existing to return earlier a 304 without reaching s3
# if etag or last_modified have not changed.
cache_key = utils.get_content_metadata_cache_key(document.id)
if content_metadata := cache.get(cache_key):
if (if_none_match and if_none_match == content_metadata.get("etag")) or (
if_modified_since_dt
and dt.datetime.fromisoformat(content_metadata.get("last_modified"))
<= if_modified_since_dt
):
return drf_response.Response(status=status.HTTP_304_NOT_MODIFIED)
# Prepare get_object S3 operation. The get_object manages ETag and last_modified
# headers will raise a 304 client error if one of them matches the value existing in
# S3.
get_object_kwargs = {
"Bucket": default_storage.bucket_name,
"Key": document.file_key,
}
if if_none_match:
get_object_kwargs["IfNoneMatch"] = if_none_match
if if_modified_since_dt:
get_object_kwargs["IfModifiedSince"] = if_modified_since_dt
try:
s3_response = default_storage.connection.meta.client.get_object(
**get_object_kwargs
)
):
try:
file_metadata = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=document.file_key
)
except ClientError:
return StreamingHttpResponse(
b"", content_type="text/plain", status=status.HTTP_200_OK
)
last_modified = file_metadata["LastModified"]
etag = file_metadata["ETag"]
size = file_metadata["ContentLength"]
cache.set(
utils.get_content_metadata_cache_key(document.id),
{
"last_modified": last_modified.isoformat(),
"etag": etag,
"size": size,
},
settings.CONTENT_METADATA_CACHE_TIMEOUT,
)
else:
last_modified = dt.datetime.fromisoformat(
content_metadata.get("last_modified")
)
etag = content_metadata.get("etag")
size = content_metadata.get("size")
# --- Check conditional headers from any client ---
if_none_match = request.META.get("HTTP_IF_NONE_MATCH") # contains ETag
if_modified_since = request.META.get("HTTP_IF_MODIFIED_SINCE")
# Strip the W/ weak prefix. Proxies (e.g. nginx with gzip) convert strong
# ETags to weak ones, so a strict equality check would fail on production
# even when unchanged.
if if_none_match and if_none_match.startswith("W/"):
if_none_match = if_none_match.removeprefix("W/")
if if_none_match and if_none_match == etag:
return drf_response.Response(status=status.HTTP_304_NOT_MODIFIED)
if if_modified_since:
try:
since = dt.datetime.strptime(
if_modified_since, "%a, %d %b %Y %H:%M:%S %Z"
)
except ValueError:
pass
else:
if not since.tzinfo:
since = since.replace(tzinfo=dt.timezone.utc)
if last_modified <= since:
except ClientError as exc:
code = exc.response["Error"]["Code"]
match code:
case "304" | "PreconditionFailed" | "NotModified":
return drf_response.Response(status=status.HTTP_304_NOT_MODIFIED)
case "NoSuchKey" | "404":
return StreamingHttpResponse(
b"", content_type="text/plain", status=200
)
case _:
raise
def _stream(file_key):
with default_storage.open(file_key, "rb") as f:
while chunk := f.read(8192):
yield chunk
last_modified = s3_response["LastModified"]
etag = s3_response["ETag"]
size = s3_response["ContentLength"]
# Refresh the metadata cache
cache.set(
cache_key,
{
"last_modified": last_modified.isoformat(),
"etag": etag,
},
settings.CONTENT_METADATA_CACHE_TIMEOUT,
)
def _stream(body):
yield from body.iter_chunks()
body.close()
response = StreamingHttpResponse(
streaming_content=_stream(document.file_key),
streaming_content=_stream(s3_response["Body"]),
content_type="text/plain",
status=status.HTTP_200_OK,
)
@@ -2115,13 +2120,16 @@ class DocumentViewSet(
# Check permissions first
self.get_object()
if not settings.AI_FEATURE_ENABLED or not settings.AI_FEATURE_LEGACY_ENABLED:
raise ValidationError("AI feature is not enabled.")
serializer = serializers.AITransformSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
text = serializer.validated_data["text"]
action = serializer.validated_data["action"]
response = AIService().transform(text, action)
response = get_legacy_ai_service().transform(text, action)
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
@@ -2143,13 +2151,16 @@ class DocumentViewSet(
# Check permissions first
self.get_object()
if not settings.AI_FEATURE_ENABLED or not settings.AI_FEATURE_LEGACY_ENABLED:
raise ValidationError("AI feature is not enabled.")
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
text = serializer.validated_data["text"]
language = serializer.validated_data["language"]
response = AIService().translate(text, language)
response = get_legacy_ai_service().translate(text, language)
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
@@ -2260,7 +2271,7 @@ class DocumentViewSet(
GET /api/v1.0/documents/<resource_id>/cors-proxy
Act like a proxy to fetch external resources and bypass CORS restrictions.
"""
url = request.query_params.get("url")
url = request.query_params.get("url", "").strip()
if not url:
return drf.response.Response(
{"detail": "Missing 'url' query parameter"},
@@ -2806,6 +2817,7 @@ class ConfigView(drf.views.APIView):
"API_USERS_SEARCH_QUERY_MIN_LENGTH",
"COLLABORATION_WS_URL",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY",
"COLLABORATION_WS_INACTIVITY_TIMEOUT",
"CONVERSION_FILE_EXTENSIONS_ALLOWED",
"CONVERSION_FILE_MAX_SIZE",
"CONVERSION_UPLOAD_ENABLED",

View File

@@ -231,9 +231,10 @@ class ReactionFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.Reaction
skip_postgeneration_save = True
comment = factory.SubFactory(CommentFactory)
emoji = "test"
emoji = factory.Faker("emoji")
@factory.post_generation
def users(self, create, extracted, **kwargs):

View File

@@ -9,7 +9,7 @@ from django.db import migrations, models
from botocore.exceptions import ClientError
import core.models
from core.utils import extract_attachments
from core.utils.yjs import extract_attachments
def populate_attachments_on_all_documents(apps, schema_editor):

View File

@@ -19,7 +19,7 @@ from django.core.cache import cache
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.mail import send_mail
from django.db import connection, models, transaction
from django.db import models, transaction
from django.db.models.functions import Left, Length
from django.template.loader import render_to_string
from django.utils import timezone
@@ -39,6 +39,7 @@ from core.choices import (
RoleChoices,
get_equivalent_link_definition,
)
from core.utils.treebeard import create_tree_node_with_retry
from core.validators import sub_validator
logger = getLogger(__name__)
@@ -265,8 +266,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
duplicate the sandbox document for the user
"""
if settings.USER_ONBOARDING_SANDBOX_DOCUMENT:
# transaction.atomic is used in a context manager to avoid a transaction if
# the settings USER_ONBOARDING_SANDBOX_DOCUMENT is unused
sandbox_id = settings.USER_ONBOARDING_SANDBOX_DOCUMENT
try:
template_document = Document.objects.get(id=sandbox_id)
@@ -276,20 +275,15 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
sandbox_id,
)
return
with transaction.atomic():
# locks the table to ensure safe concurrent access
with connection.cursor() as cursor:
cursor.execute(
f'LOCK TABLE "{Document._meta.db_table}" ' # noqa: SLF001
"IN SHARE ROW EXCLUSIVE MODE;"
sandbox_document = create_tree_node_with_retry(
lambda: Document.add_root(
title=template_document.title,
content=template_document.content,
attachments=template_document.attachments,
duplicated_from=template_document,
creator=self,
)
sandbox_document = Document.add_root(
title=template_document.title,
content=template_document.content,
attachments=template_document.attachments,
duplicated_from=template_document,
creator=self,
)
DocumentAccess.objects.create(

View File

@@ -7,15 +7,17 @@ import os
import queue
import threading
from collections.abc import AsyncIterator, Iterator
from functools import cache
from typing import Any, Dict, Union
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from langfuse import get_client
from langfuse.openai import OpenAI as OpenAI_Langfuse
from pydantic_ai import Agent, DeferredToolRequests
from pydantic_ai.models.mistral import MistralModel
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.mistral import MistralProvider
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.toolsets.external import ExternalToolset
@@ -24,13 +26,6 @@ from pydantic_ai.ui.vercel_ai import VercelAIAdapter
from pydantic_ai.ui.vercel_ai.request_types import RequestData, TextUIPart, UIMessage
from rest_framework.request import Request
from core import enums
if settings.LANGFUSE_PUBLIC_KEY:
OpenAI = OpenAI_Langfuse
else:
from openai import OpenAI
log = logging.getLogger(__name__)
BLOCKNOTE_TOOL_STRICT_PROMPT = """
@@ -64,50 +59,6 @@ IDs ALWAYS end with "$". Use ids EXACTLY as provided.
Return ONLY the JSON tool input. No prose, no markdown.
"""
AI_ACTIONS = {
"prompt": (
"Answer the prompt using markdown formatting for structure and emphasis. "
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
"Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"correct": (
"Correct grammar and spelling of the markdown text, "
"preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"rephrase": (
"Rephrase the given markdown text, "
"preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"summarize": (
"Summarize the markdown text, preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"beautify": (
"Add formatting to the text to make it more readable. "
"Do not provide any other information. "
"Preserve the language."
),
"emojify": (
"Add emojis to the important parts of the text. "
"Do not provide any other information. "
"Preserve the language."
),
}
AI_TRANSLATE = (
"Keep the same html structure and formatting. "
"Translate the content in the html to the specified language {language:s}. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
)
def convert_async_generator_to_sync(async_gen: AsyncIterator[str]) -> Iterator[str]:
"""Convert an async generator to a sync generator."""
@@ -143,46 +94,40 @@ def convert_async_generator_to_sync(async_gen: AsyncIterator[str]) -> Iterator[s
thread.join()
class AIService:
"""Service class for AI-related operations."""
def __init__(self):
"""Ensure that the AI configuration is set properly."""
if (
settings.AI_BASE_URL is None
or settings.AI_API_KEY is None
or settings.AI_MODEL is None
):
raise ImproperlyConfigured("AI configuration not set")
self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY)
def call_ai_api(self, system_content, text):
"""Helper method to call the OpenAI API and process the response."""
response = self.client.chat.completions.create(
model=settings.AI_MODEL,
messages=[
{"role": "system", "content": system_content},
{"role": "user", "content": text},
],
@cache
def configure_pydantic_model_provider() -> OpenAIChatModel | MistralModel:
"""Configure a pydantic Model and return it."""
if (
settings.OPENAI_SDK_API_KEY
and settings.OPENAI_SDK_BASE_URL
and settings.AI_MODEL
):
return OpenAIChatModel(
settings.AI_MODEL,
provider=OpenAIProvider(
api_key=settings.OPENAI_SDK_API_KEY,
base_url=settings.OPENAI_SDK_BASE_URL,
),
)
content = response.choices[0].message.content
if (
settings.MISTRAL_SDK_API_KEY
and settings.MISTRAL_SDK_BASE_URL
and settings.AI_MODEL
):
return MistralModel(
settings.AI_MODEL,
provider=MistralProvider(
api_key=settings.MISTRAL_SDK_API_KEY,
base_url=settings.MISTRAL_SDK_BASE_URL,
),
)
if not content:
raise RuntimeError("AI response does not contain an answer")
raise ImproperlyConfigured("AI configuration not set")
return {"answer": content}
def transform(self, text, action):
"""Transform text based on specified action."""
system_content = AI_ACTIONS[action]
return self.call_ai_api(system_content, text)
def translate(self, text, language):
"""Translate text to a specified language."""
language_display = enums.ALL_LANGUAGES.get(language, language)
system_content = AI_TRANSLATE.format(language=language_display)
return self.call_ai_api(system_content, text)
class AIService:
"""Service class for AI-related operations."""
@staticmethod
def inject_document_state_messages(
@@ -324,13 +269,9 @@ class AIService:
langfuse.auth_check()
Agent.instrument_all()
model = OpenAIChatModel(
settings.AI_MODEL,
provider=OpenAIProvider(
base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY
),
agent = Agent(
configure_pydantic_model_provider(), instrument=instrument_enabled
)
agent = Agent(model, instrument=instrument_enabled)
accept = request.META.get("HTTP_ACCEPT", SSE_CONTENT_TYPE)

View File

@@ -0,0 +1,201 @@
"""Module dedicated to the legacy ai services."""
import logging
from abc import ABC, abstractmethod
from functools import cache
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from langfuse import get_client, observe
from langfuse.openai import OpenAI as OpenAI_Langfuse
from mistralai import Mistral
from core import enums
if settings.LANGFUSE_PUBLIC_KEY:
OpenAI = OpenAI_Langfuse
else:
from openai import OpenAI
log = logging.getLogger(__name__)
AI_ACTIONS = {
"prompt": (
"Answer the prompt using markdown formatting for structure and emphasis. "
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
"Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"correct": (
"Correct grammar and spelling of the markdown text, "
"preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"rephrase": (
"Rephrase the given markdown text, "
"preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"summarize": (
"Summarize the markdown text, preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"beautify": (
"Add formatting to the text to make it more readable. "
"Do not provide any other information. "
"Preserve the language."
),
"emojify": (
"Add emojis to the important parts of the text. "
"Do not provide any other information. "
"Preserve the language."
),
}
AI_TRANSLATE = (
"Keep the same html structure and formatting. "
"Translate the content in the html to the specified language {language:s}. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information. "
"Return the content directly without wrapping it in code blocks or markdown delimiters."
)
class LegacyAiClient(ABC):
"""abstract class for legacy client."""
@abstractmethod
def call_ai_api(self, system_content, text) -> str:
"""Abstract method call_ai_api."""
class LegacyAiServiceMistralClient(LegacyAiClient):
"""ai_service using mistral sdk for the legacy ai feature."""
def __init__(self):
"""Configure mistral sdk"""
if (
not settings.MISTRAL_SDK_API_KEY
or not settings.MISTRAL_SDK_BASE_URL
or not settings.AI_MODEL
):
raise ImproperlyConfigured("Mistral sdk configuration not set")
self.client = Mistral(
api_key=settings.MISTRAL_SDK_API_KEY,
server_url=settings.MISTRAL_SDK_BASE_URL,
)
@observe(as_type="generation")
def call_ai_api(self, system_content, text) -> str:
langfuse = None
messages = [
{"role": "system", "content": system_content},
{"role": "user", "content": text},
]
if settings.LANGFUSE_PUBLIC_KEY:
langfuse = get_client()
langfuse.auth_check()
langfuse.update_current_generation(
input=messages,
model=settings.AI_MODEL,
)
response = self.client.chat.complete(
model=settings.AI_MODEL,
messages=messages,
stream=False,
)
if langfuse:
langfuse.update_current_generation(
usage_details={
"input": response.usage.prompt_tokens,
"output": response.usage.completion_tokens,
},
output=response.choices[0].message.content,
)
return response.choices[0].message.content
class LegacyAiServiceOpenAiClient(LegacyAiClient):
"""ai_service using OpenAI client for the legacy ai feature."""
def __init__(self):
"""configure OpenAI client."""
if (
not settings.OPENAI_SDK_BASE_URL
or not settings.OPENAI_SDK_API_KEY
or not settings.AI_MODEL
):
raise ImproperlyConfigured("OpenAI configuration not set")
self.client = OpenAI(
base_url=settings.OPENAI_SDK_BASE_URL, api_key=settings.OPENAI_SDK_API_KEY
)
def call_ai_api(self, system_content, text) -> str:
response = self.client.chat.completions.create(
model=settings.AI_MODEL,
messages=[
{"role": "system", "content": system_content},
{"role": "user", "content": text},
],
)
return response.choices[0].message.content
class LegacyAIService:
"""Legacy ai service used by transform and translate actions."""
def __init__(self, ai_client: LegacyAiClient):
"""Assign client to the service."""
self.ai_client = ai_client
def call_ai_api(self, system_content, text):
"""Helper method to call the OpenAI API and process the response."""
content = self.ai_client.call_ai_api(system_content, text)
if not content:
raise RuntimeError("AI response does not contain an answer")
return {"answer": content}
def transform(self, text, action):
"""Transform text based on specified action."""
system_content = AI_ACTIONS[action]
return self.call_ai_api(system_content, text)
def translate(self, text, language):
"""Translate text to a specified language."""
language_display = enums.ALL_LANGUAGES.get(language, language)
system_content = AI_TRANSLATE.format(language=language_display)
return self.call_ai_api(system_content, text)
@cache
def get_legacy_ai_service() -> LegacyAIService:
"""Helper responsible to correctly instantiate and configure legacy ai service."""
ai_client = None
if settings.MISTRAL_SDK_API_KEY:
ai_client = LegacyAiServiceMistralClient()
if settings.OPENAI_SDK_API_KEY:
ai_client = LegacyAiServiceOpenAiClient()
if not ai_client:
raise ImproperlyConfigured(
"trying to configure legacy ai_service but missing client configuration."
)
return LegacyAIService(ai_client)

View File

@@ -12,8 +12,11 @@ from django.utils.module_loading import import_string
import requests
from core import models, utils
from core import models
from core.enums import SearchType
from core.utils.dicts import get_value_by_pattern
from core.utils.paths import get_ancestor_to_descendants_map
from core.utils.yjs import base64_yjs_to_text
logger = logging.getLogger(__name__)
@@ -44,7 +47,7 @@ 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(
ancestor_map = get_ancestor_to_descendants_map(
paths, steplen=models.Document.steplen
)
ancestor_paths = list(ancestor_map.keys())
@@ -297,7 +300,7 @@ class FindDocumentIndexer(BaseDocumentIndexer):
>>> get_title({"id": 1})
""
"""
titles = utils.get_value_by_pattern(source, r"^title\.")
titles = get_value_by_pattern(source, r"^title\.")
for title in titles:
if title:
return title
@@ -318,7 +321,7 @@ class FindDocumentIndexer(BaseDocumentIndexer):
"""
doc_path = document.path
doc_content = document.content
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
text_content = base64_yjs_to_text(doc_content) if doc_content else ""
return {
"id": str(document.id),

View File

@@ -11,7 +11,7 @@ from django.dispatch import receiver
from core import models
from core.tasks.search import trigger_batch_document_indexer
from core.utils import get_users_sharing_documents_with_cache_key
from core.utils.users import get_users_sharing_documents_with_cache_key
@receiver(signals.post_save, sender=models.Document)

View File

@@ -11,6 +11,7 @@ import pytest
from rest_framework.test import APIClient
from core import factories
from core.services.ai_services.blocknote import configure_pydantic_model_provider
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@@ -20,13 +21,14 @@ pytestmark = pytest.mark.django_db
def ai_settings(settings):
"""Fixture to set AI settings."""
settings.AI_MODEL = "llama"
settings.AI_BASE_URL = "http://localhost-ai:12345/"
settings.AI_API_KEY = "test-key"
settings.OPENAI_SDK_BASE_URL = "http://localhost-ai:12345/"
settings.OPENAI_SDK_API_KEY = "test-key"
settings.AI_FEATURE_ENABLED = True
settings.AI_FEATURE_BLOCKNOTE_ENABLED = True
settings.AI_FEATURE_LEGACY_ENABLED = True
settings.LANGFUSE_PUBLIC_KEY = None
settings.AI_VERCEL_SDK_VERSION = 6
configure_pydantic_model_provider.cache_clear()
@override_settings(
@@ -65,7 +67,7 @@ def test_api_documents_ai_proxy_anonymous_forbidden(reach, role):
@override_settings(AI_ALLOW_REACH_FROM="public")
@patch("core.services.ai_services.AIService.stream")
@patch("core.services.ai_services.blocknote.AIService.stream")
def test_api_documents_ai_proxy_anonymous_success(mock_stream):
"""
Anonymous users should be able to request AI proxy to a document
@@ -149,7 +151,7 @@ def test_api_documents_ai_proxy_authenticated_forbidden(reach, role):
("public", "editor"),
],
)
@patch("core.services.ai_services.AIService.stream")
@patch("core.services.ai_services.blocknote.AIService.stream")
def test_api_documents_ai_proxy_authenticated_success(mock_stream, reach, role):
"""
Authenticated users should be able to request AI proxy to a document
@@ -205,7 +207,7 @@ def test_api_documents_ai_proxy_reader(via, mock_user_teams):
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
@patch("core.services.ai_services.AIService.stream")
@patch("core.services.ai_services.blocknote.AIService.stream")
def test_api_documents_ai_proxy_success(mock_stream, via, role, mock_user_teams):
"""Users with sufficient permissions should be able to request AI proxy."""
user = factories.UserFactory()
@@ -266,7 +268,7 @@ def test_api_documents_ai_proxy_ai_feature_disabled(settings, setting_to_disable
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("core.services.ai_services.AIService.stream")
@patch("core.services.ai_services.blocknote.AIService.stream")
def test_api_documents_ai_proxy_throttling_document(mock_stream):
"""
Throttling per document should be triggered on the AI proxy endpoint.
@@ -304,7 +306,7 @@ def test_api_documents_ai_proxy_throttling_document(mock_stream):
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("core.services.ai_services.AIService.stream")
@patch("core.services.ai_services.blocknote.AIService.stream")
def test_api_documents_ai_proxy_throttling_user(mock_stream):
"""
Throttling per user should be triggered on the AI proxy endpoint.
@@ -339,7 +341,7 @@ def test_api_documents_ai_proxy_throttling_user(mock_stream):
}
@patch("core.services.ai_services.AIService.stream")
@patch("core.services.ai_services.blocknote.AIService.stream")
def test_api_documents_ai_proxy_returns_streaming_response(mock_stream):
"""AI proxy should return a StreamingHttpResponse with correct headers."""
user = factories.UserFactory()

View File

@@ -2,47 +2,62 @@
Test AI transform API endpoint for users in impress's core app.
"""
import random
from unittest.mock import MagicMock, patch
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories
from core.services.ai_services.legacy import get_legacy_ai_service
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture
def ai_settings():
def ai_settings(settings):
"""Fixture to set AI settings."""
with override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama"
):
yield
settings.AI_FEATURE_ENABLED = True
settings.AI_FEATURE_LEGACY_ENABLED = True
settings.OPENAI_SDK_BASE_URL = "http://example.com"
settings.OPENAI_SDK_API_KEY = "test-key"
settings.AI_MODEL = "llama"
@pytest.fixture(autouse=True)
def clear_openai_client_config():
"""Clear the _configure_legacy_openai_client cache"""
get_legacy_ai_service.cache_clear()
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"reach, role",
"reach, role, ai_allow_reach_from",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
("restricted", "reader", "public"),
("restricted", "reader", "authenticated"),
("restricted", "reader", "restricted"),
("restricted", "editor", "public"),
("restricted", "editor", "authenticated"),
("restricted", "editor", "restricted"),
("authenticated", "reader", "public"),
("authenticated", "reader", "authenticated"),
("authenticated", "reader", "restricted"),
("authenticated", "editor", "public"),
("authenticated", "editor", "authenticated"),
("authenticated", "editor", "restricted"),
("public", "reader", "public"),
("public", "reader", "authenticated"),
("public", "reader", "restricted"),
],
)
def test_api_documents_ai_transform_anonymous_forbidden(reach, role):
def test_api_documents_ai_transform_anonymous_forbidden(
reach, role, ai_allow_reach_from, settings
):
"""
Anonymous users should not be able to request AI transform if the link reach
and role don't allow it.
"""
settings.AI_ALLOW_REACH_FROM = ai_allow_reach_from
document = factories.DocumentFactory(link_reach=reach, link_role=role)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
@@ -54,14 +69,14 @@ def test_api_documents_ai_transform_anonymous_forbidden(reach, role):
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_success(mock_create):
def test_api_documents_ai_transform_anonymous_success(mock_create, settings):
"""
Anonymous users should be able to request AI transform to a document
if the link reach and role permit it.
"""
settings.AI_ALLOW_REACH_FROM = "public"
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_create.return_value = MagicMock(
@@ -88,14 +103,17 @@ def test_api_documents_ai_transform_anonymous_success(mock_create):
)
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
@pytest.mark.usefixtures("ai_settings")
@pytest.mark.parametrize("ai_allow_reach_from", ["authenticated", "restricted"])
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_limited_by_setting(mock_create):
def test_api_documents_ai_transform_anonymous_limited_by_setting(
mock_create, ai_allow_reach_from, settings
):
"""
Anonymous users should be able to request AI transform to a document
if the link reach and role permit it.
"""
settings.AI_ALLOW_REACH_FROM = ai_allow_reach_from
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
@@ -176,8 +194,8 @@ def test_api_documents_ai_transform_authenticated_success(mock_create, reach, ro
"role": "system",
"content": (
"Answer the prompt using markdown formatting for structure and emphasis. "
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
"Preserve the language and markdown formatting. "
"Return the content directly without wrapping it in code blocks or markdown "
"delimiters. Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
@@ -253,8 +271,8 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
"role": "system",
"content": (
"Answer the prompt using markdown formatting for structure and emphasis. "
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
"Preserve the language and markdown formatting. "
"Return the content directly without wrapping it in code blocks or markdown "
"delimiters. Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
@@ -264,6 +282,7 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
)
@pytest.mark.usefixtures("ai_settings")
def test_api_documents_ai_transform_empty_text():
"""The text should not be empty when requesting AI transform."""
user = factories.UserFactory()
@@ -280,6 +299,7 @@ def test_api_documents_ai_transform_empty_text():
assert response.json() == {"text": ["This field may not be blank."]}
@pytest.mark.usefixtures("ai_settings")
def test_api_documents_ai_transform_invalid_action():
"""The action should valid when requesting AI transform."""
user = factories.UserFactory()
@@ -296,14 +316,14 @@ def test_api_documents_ai_transform_invalid_action():
assert response.json() == {"action": ['"invalid" is not a valid choice.']}
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_throttling_document(mock_create):
def test_api_documents_ai_transform_throttling_document(mock_create, settings):
"""
Throttling per document should be triggered on the AI transform endpoint.
For full throttle class test see: `test_api_utils_ai_document_rate_throttles`
"""
settings.AI_DOCUMENT_RATE_THROTTLE_RATES = {"minute": 3, "hour": 6, "day": 10}
client = APIClient()
document = factories.DocumentFactory(link_reach="public", link_role="editor")
@@ -329,14 +349,14 @@ def test_api_documents_ai_transform_throttling_document(mock_create):
}
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_throttling_user(mock_create):
def test_api_documents_ai_transform_throttling_user(mock_create, settings):
"""
Throttling per user should be triggered on the AI transform endpoint.
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
"""
settings.AI_USER_RATE_THROTTLE_RATES = {"minute": 3, "hour": 6, "day": 10}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)

View File

@@ -2,27 +2,32 @@
Test AI translate API endpoint for users in impress's core app.
"""
import random
from unittest.mock import MagicMock, patch
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories
from core.services.ai_services.legacy import get_legacy_ai_service
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture
def ai_settings():
def ai_settings(settings):
"""Fixture to set AI settings."""
with override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama"
):
yield
settings.AI_FEATURE_ENABLED = True
settings.AI_FEATURE_LEGACY_ENABLED = True
settings.OPENAI_SDK_BASE_URL = "http://example.com"
settings.OPENAI_SDK_API_KEY = "test-key"
settings.AI_MODEL = "llama"
@pytest.fixture(autouse=True)
def clear_openai_client_config():
"clear the configure_legacy_openai_client cache"
get_legacy_ai_service.cache_clear()
def test_api_documents_ai_translate_viewset_options_metadata():
@@ -45,24 +50,34 @@ def test_api_documents_ai_translate_viewset_options_metadata():
}
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"reach, role",
"reach, role, ai_allow_reach_from",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
("restricted", "reader", "public"),
("restricted", "reader", "authenticated"),
("restricted", "reader", "restricted"),
("restricted", "editor", "public"),
("restricted", "editor", "authenticated"),
("restricted", "editor", "restricted"),
("authenticated", "reader", "public"),
("authenticated", "reader", "authenticated"),
("authenticated", "reader", "restricted"),
("authenticated", "editor", "public"),
("authenticated", "editor", "authenticated"),
("authenticated", "editor", "restricted"),
("public", "reader", "public"),
("public", "reader", "authenticated"),
("public", "reader", "restricted"),
],
)
def test_api_documents_ai_translate_anonymous_forbidden(reach, role):
def test_api_documents_ai_translate_anonymous_forbidden(
reach, role, ai_allow_reach_from, settings
):
"""
Anonymous users should not be able to request AI translate if the link reach
and role don't allow it.
"""
settings.AI_ALLOW_REACH_FROM = ai_allow_reach_from
document = factories.DocumentFactory(link_reach=reach, link_role=role)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
@@ -74,14 +89,14 @@ def test_api_documents_ai_translate_anonymous_forbidden(reach, role):
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_success(mock_create):
def test_api_documents_ai_translate_anonymous_success(mock_create, settings):
"""
Anonymous users should be able to request AI translate to a document
if the link reach and role permit it.
"""
settings.AI_ALLOW_REACH_FROM = "public"
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_create.return_value = MagicMock(
@@ -102,7 +117,9 @@ def test_api_documents_ai_translate_anonymous_success(mock_create):
"Keep the same html structure and formatting. "
"Translate the content in the html to the specified language Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
"Do not provide any other information. "
"Return the content directly without wrapping it in code blocks or markdown "
"delimiters."
),
},
{"role": "user", "content": "Hello"},
@@ -110,14 +127,17 @@ def test_api_documents_ai_translate_anonymous_success(mock_create):
)
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
@pytest.mark.usefixtures("ai_settings")
@pytest.mark.parametrize("ai_allow_reach_from", ["authenticated", "restricted"])
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_limited_by_setting(mock_create):
def test_api_documents_ai_translate_anonymous_limited_by_setting(
mock_create, ai_allow_reach_from, settings
):
"""
Anonymous users should be able to request AI translate to a document
if the link reach and role permit it.
"""
settings.AI_ALLOW_REACH_FROM = ai_allow_reach_from
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
@@ -201,7 +221,9 @@ def test_api_documents_ai_translate_authenticated_success(mock_create, reach, ro
"Translate the content in the html to the "
"specified language Colombian Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
"Do not provide any other information. "
"Return the content directly without wrapping it in code blocks or markdown "
"delimiters."
),
},
{"role": "user", "content": "Hello"},
@@ -278,7 +300,9 @@ def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_te
"Translate the content in the html to the "
"specified language Colombian Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
"Do not provide any other information. "
"Return the content directly without wrapping it in code blocks or markdown "
"delimiters."
),
},
{"role": "user", "content": "Hello"},
@@ -286,6 +310,7 @@ def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_te
)
@pytest.mark.usefixtures("ai_settings")
def test_api_documents_ai_translate_empty_text():
"""The text should not be empty when requesting AI translate."""
user = factories.UserFactory()
@@ -302,6 +327,7 @@ def test_api_documents_ai_translate_empty_text():
assert response.json() == {"text": ["This field may not be blank."]}
@pytest.mark.usefixtures("ai_settings")
def test_api_documents_ai_translate_invalid_action():
"""The action should valid when requesting AI translate."""
user = factories.UserFactory()
@@ -318,14 +344,14 @@ def test_api_documents_ai_translate_invalid_action():
assert response.json() == {"language": ['"invalid" is not a valid choice.']}
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_throttling_document(mock_create):
def test_api_documents_ai_translate_throttling_document(mock_create, settings):
"""
Throttling per document should be triggered on the AI translate endpoint.
For full throttle class test see: `test_api_utils_ai_document_rate_throttles`
"""
settings.AI_DOCUMENT_RATE_THROTTLE_RATES = {"minute": 3, "hour": 6, "day": 10}
client = APIClient()
document = factories.DocumentFactory(link_reach="public", link_role="editor")
@@ -351,14 +377,14 @@ def test_api_documents_ai_translate_throttling_document(mock_create):
}
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_throttling_user(mock_create):
def test_api_documents_ai_translate_throttling_user(mock_create, settings):
"""
Throttling per user should be triggered on the AI translate endpoint.
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
"""
settings.AI_USER_RATE_THROTTLE_RATES = {"minute": 3, "hour": 6, "day": 10}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)

View File

@@ -644,11 +644,13 @@ def test_create_reaction_anonymous_user_public_document(link_role):
document = factories.DocumentFactory(link_reach="public", link_role=link_role)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
{"emoji": reaction.emoji},
)
assert response.status_code == 401
@@ -664,12 +666,14 @@ def test_create_reaction_authenticated_user_public_document():
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
{"emoji": reaction.emoji},
)
assert response.status_code == 403
@@ -684,17 +688,19 @@ def test_create_reaction_authenticated_user_accessible_public_document():
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
{"emoji": reaction.emoji},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
comment=comment, emoji=reaction.emoji, users__in=[user]
).exists()
@@ -709,12 +715,14 @@ def test_create_reaction_authenticated_user_connected_document_link_role_reader(
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
{"emoji": reaction.emoji},
)
assert response.status_code == 403
@@ -737,17 +745,19 @@ def test_create_reaction_authenticated_user_connected_document(link_role):
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
{"emoji": reaction.emoji},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
comment=comment, emoji=reaction.emoji, users__in=[user]
).exists()
@@ -760,12 +770,14 @@ def test_create_reaction_authenticated_user_restricted_accessible_document():
document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
{"emoji": reaction.emoji},
)
assert response.status_code == 403
@@ -781,12 +793,14 @@ def test_create_reaction_authenticated_user_restricted_accessible_document_role_
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
{"emoji": reaction.emoji},
)
assert response.status_code == 403
@@ -806,26 +820,70 @@ def test_create_reaction_authenticated_user_restricted_accessible_document_role_
document = factories.DocumentFactory(link_reach="restricted", users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
{"emoji": reaction.emoji},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
comment=comment, emoji=reaction.emoji, users__in=[user]
).exists()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": reaction.emoji},
)
assert response.status_code == 400
assert response.json() == {"user_already_reacted": True}
def test_create_reaction_invalid_emoji():
"""Users should not be able to submit non-emojis as reactions."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.RoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 400
assert response.json() == {"user_already_reacted": True}
assert "Reaction must be a single valid emoji." in str(response.json())
def test_create_reaction_multiple_emojis():
"""Users should not be able to submit multiple emojis as a single reaction."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.RoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "🐛🐛"},
)
assert response.status_code == 400
assert "Reaction must be a single valid emoji." in str(response.json())
# Delete reaction

View File

@@ -55,6 +55,31 @@ def test_api_docs_cors_proxy_valid_url(mock_getaddrinfo):
assert response.streaming_content
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_url_with_surrounding_whitespace(mock_getaddrinfo):
"""
URLs with leading or trailing whitespace must still be proxied successfully,
otherwise images whose `src` has stray whitespace are missing from the PDF export.
"""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url= {url_to_fetch} "
)
assert response.status_code == 200
assert response.headers["Content-Type"] == "image/png"
assert response.streaming_content
def test_api_docs_cors_proxy_without_url_query_string():
"""Test the CORS proxy API for documents without a URL query string."""
document = factories.DocumentFactory(link_reach="public")

View File

@@ -6,7 +6,6 @@ from io import BytesIO
from urllib.parse import urlparse
from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
@@ -37,7 +36,7 @@ def test_api_documents_media_auth_unkown_document():
assert models.Document.objects.exists() is False
def test_api_documents_media_auth_anonymous_public():
def test_api_documents_media_auth_anonymous_public(settings):
"""Anonymous users should be able to retrieve attachments linked to a public document"""
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
@@ -139,7 +138,7 @@ def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
assert "Authorization" not in response
def test_api_documents_media_auth_anonymous_attachments():
def test_api_documents_media_auth_anonymous_attachments(settings):
"""
Declaring a media key as original attachment on a document to which
a user has access should give them access to the attachment file
@@ -202,7 +201,9 @@ def test_api_documents_media_auth_anonymous_attachments():
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
def test_api_documents_media_auth_authenticated_public_or_authenticated(
reach, settings
):
"""
Authenticated users who are not related to a document should be able to retrieve
attachments related to a document with public or authenticated link reach.
@@ -284,7 +285,7 @@ def test_api_documents_media_auth_authenticated_restricted():
@pytest.mark.parametrize("via", VIA)
def test_api_documents_media_auth_related(via, mock_user_teams):
def test_api_documents_media_auth_related(via, mock_user_teams, settings):
"""
Users who have a specific access to a document, whatever the role, should be able to
retrieve related attachments.
@@ -368,7 +369,7 @@ def test_api_documents_media_auth_not_ready_status():
assert response.status_code == 403
def test_api_documents_media_auth_missing_status_metadata():
def test_api_documents_media_auth_missing_status_metadata(settings):
"""Attachments without status metadata should be considered as ready"""
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
@@ -412,3 +413,51 @@ def test_api_documents_media_auth_missing_status_metadata():
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
def test_api_documents_media_auth_anonymous_public_custom_origin_header(settings):
"""Changing the setting MEDIA_AUTH_ORIGINAL_URL_HEADER to match other header should work"""
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER = "HTTP_X_FORWARDED_URI"
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
key = f"{document_id!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
Metadata={"status": DocumentAttachmentStatus.READY},
)
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
original_url = f"http://localhost/media/{key:s}"
now = timezone.now()
with freeze_time(now):
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_FORWARDED_URI=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"

View File

@@ -438,3 +438,92 @@ def test_api_documents_move_authenticated_deleted_target_as_sibling(position):
# Verify that the document has not moved
document.refresh_from_db()
assert document.is_root() is True
@pytest.mark.parametrize("position", enums.MoveNodePositionChoices.values)
def test_api_documents_move_to_descendant(position):
"""
Moving a document to one of its descendants should return a validation error.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create a hierarchy: parent -> child -> grandchild
parent = factories.DocumentFactory(users=[(user, "owner")])
child = factories.DocumentFactory(parent=parent, users=[(user, "owner")])
grandchild = factories.DocumentFactory(parent=child, users=[(user, "owner")])
# Try moving parent to child (descendant)
response = client.post(
f"/api/v1.0/documents/{parent.id!s}/move/",
data={"target_document_id": str(child.id), "position": position},
)
assert response.status_code == 400
assert response.json() == {
"target_document_id": "Cannot move a document to its own descendant."
}
# Try moving parent to grandchild
response = client.post(
f"/api/v1.0/documents/{parent.id!s}/move/",
data={"target_document_id": str(grandchild.id), "position": position},
)
assert response.status_code == 400
assert response.json() == {
"target_document_id": "Cannot move a document to its own descendant."
}
# Try moving child to grandchild (still descendant)
response = client.post(
f"/api/v1.0/documents/{child.id!s}/move/",
data={"target_document_id": str(grandchild.id), "position": position},
)
assert response.status_code == 400
assert response.json() == {
"target_document_id": "Cannot move a document to its own descendant."
}
# Ensure documents have not moved
parent.refresh_from_db()
child.refresh_from_db()
grandchild.refresh_from_db()
assert parent.is_root() is True
assert child.is_child_of(parent) is True
assert grandchild.is_child_of(child) is True
@pytest.mark.parametrize(
"position",
[
enums.MoveNodePositionChoices.FIRST_CHILD,
enums.MoveNodePositionChoices.LAST_CHILD,
],
)
def test_api_documents_move_to_self(position):
"""
Moving a document to itself should return a validation error.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
# Try moving document to itself
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(document.id), "position": position},
)
assert response.status_code == 400
assert response.json() == {
"target_document_id": "Cannot move a document to its own descendant."
}
# Ensure document has not moved
document.refresh_from_db()
assert document.is_root() is True

View File

@@ -124,3 +124,22 @@ def test_api_documents_restore_authenticated_owner_expired():
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_documents_restore_authenticated_owner_not_deleted():
"""Restoring a document that is not deleted should return a 400 error."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/")
assert response.status_code == 400
assert response.json() == {"detail": "This document is not deleted."}
document.refresh_from_db()
assert document.deleted_at is None
assert document.ancestors_deleted_at is None

View File

@@ -68,8 +68,8 @@ def test_api_documents_search_descendants_list_anonymous_public_standalone():
},
{
"abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": document.link_reach,
"ancestors_link_role": document.link_role,
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
@@ -91,10 +91,8 @@ def test_api_documents_search_descendants_list_anonymous_public_standalone():
},
{
"abilities": grand_child.get_abilities(AnonymousUser()),
"ancestors_link_reach": document.link_reach,
"ancestors_link_role": document.link_role
if (child1.link_reach == "public" and child1.link_role == "editor")
else document.link_role,
"ancestors_link_reach": grand_child.ancestors_link_reach,
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": "public",
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
@@ -116,8 +114,8 @@ def test_api_documents_search_descendants_list_anonymous_public_standalone():
},
{
"abilities": child2.get_abilities(AnonymousUser()),
"ancestors_link_reach": document.link_reach,
"ancestors_link_role": document.link_role,
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": "public",
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
@@ -180,7 +178,7 @@ def test_api_documents_search_descendants_list_anonymous_public_parent():
# the search should include the parent document itself
"abilities": document.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_parent.link_role,
"ancestors_link_role": document.ancestors_link_role,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
@@ -203,7 +201,7 @@ def test_api_documents_search_descendants_list_anonymous_public_parent():
{
"abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_parent.link_role,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
@@ -249,7 +247,7 @@ def test_api_documents_search_descendants_list_anonymous_public_parent():
{
"abilities": child2.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_parent.link_role,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": "public",
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
@@ -327,7 +325,7 @@ def test_api_documents_search_descendants_list_authenticated_unrelated_public_or
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
@@ -350,7 +348,7 @@ def test_api_documents_search_descendants_list_authenticated_unrelated_public_or
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
@@ -373,7 +371,7 @@ def test_api_documents_search_descendants_list_authenticated_unrelated_public_or
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
@@ -437,7 +435,7 @@ def test_api_documents_search_descendants_list_authenticated_public_or_authentic
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
@@ -460,7 +458,7 @@ def test_api_documents_search_descendants_list_authenticated_public_or_authentic
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
@@ -483,7 +481,7 @@ def test_api_documents_search_descendants_list_authenticated_public_or_authentic
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),

View File

@@ -14,6 +14,7 @@ import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.services.ai_services.legacy import get_legacy_ai_service
from core.tests.documents.test_api_documents_ai_proxy import ( # pylint: disable=unused-import
ai_settings,
)
@@ -23,6 +24,12 @@ pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
@pytest.fixture(autouse=True)
def clear_openai_client_config():
"""Clear the configure_legacy_openai_client cache."""
get_legacy_ai_service.cache_clear()
def test_external_api_documents_ai_transform_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
@@ -219,7 +226,9 @@ def test_external_api_documents_ai_translate_can_be_allowed(
"Translate the content in the html to the "
"specified language Colombian Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
"Do not provide any other information. "
"Return the content directly without wrapping it in code blocks or markdown "
"delimiters."
),
},
{"role": "user", "content": "Hello"},
@@ -241,7 +250,7 @@ def test_external_api_documents_ai_translate_can_be_allowed(
}
)
@pytest.mark.usefixtures("ai_settings")
@patch("core.services.ai_services.AIService.stream")
@patch("core.services.ai_services.blocknote.AIService.stream")
def test_external_api_documents_ai_proxy_can_be_allowed(
mock_stream, user_token, resource_server_backend, user_specific_sub
):

View File

@@ -26,6 +26,7 @@ pytestmark = pytest.mark.django_db
API_USERS_SEARCH_QUERY_MIN_LENGTH=6,
COLLABORATION_WS_URL="http://testcollab/",
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True,
COLLABORATION_WS_INACTIVITY_TIMEOUT=300,
CONVERSION_UPLOAD_ENABLED=False,
CRISP_WEBSITE_ID="123",
FRONTEND_CSS_URL="http://testcss/",
@@ -55,6 +56,7 @@ def test_api_config(is_authenticated):
"API_USERS_SEARCH_QUERY_MIN_LENGTH": 6,
"COLLABORATION_WS_URL": "http://testcollab/",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True,
"COLLABORATION_WS_INACTIVITY_TIMEOUT": 300,
"CONVERSION_FILE_EXTENSIONS_ALLOWED": [".docx", ".md"],
"CONVERSION_FILE_MAX_SIZE": 20971520,
"CONVERSION_UPLOAD_ENABLED": False,

View File

@@ -0,0 +1,52 @@
"""
Unit tests for the parse_http_conditional_headers utility function.
"""
import datetime as dt
import pytest
from rest_framework.test import APIRequestFactory
from core.api.utils import parse_http_conditional_headers
@pytest.fixture(name="prepare_request")
def fixture_prepare_request(request):
"""
Fixture returning a request with headers configured from the indirect parametrize parameters.
"""
return APIRequestFactory().get("/", headers=request.param)
@pytest.mark.parametrize(
"prepare_request, expected_if_none_match, expected_if_modified_since",
[
({}, None, None),
({"if-none-match": '"abc123"'}, '"abc123"', None),
({"if-none-match": 'W/"abc123"'}, '"abc123"', None),
(
{"if-modified-since": "Wed, 21 Oct 2015 07:28:00 GMT"},
None,
dt.datetime(2015, 10, 21, 7, 28, 0, tzinfo=dt.timezone.utc),
),
({"if-modified-since": "not-a-date"}, None, None),
(
{
"if-none-match": 'W/"deadbeef"',
"if-modified-since": "Wed, 21 Oct 2015 07:28:00 GMT",
},
'"deadbeef"',
dt.datetime(2015, 10, 21, 7, 28, 0, tzinfo=dt.timezone.utc),
),
],
indirect=["prepare_request"],
)
def test_api_utils_parse_http_conditional_headers(
prepare_request, expected_if_none_match, expected_if_modified_since
):
"""Test parse_http_conditional_headers utils."""
if_none_match, if_modified_since_dt = parse_http_conditional_headers(
prepare_request
)
assert if_none_match == expected_if_none_match
assert if_modified_since_dt == expected_if_modified_since

View File

@@ -10,14 +10,23 @@ from django.core.exceptions import ImproperlyConfigured
from django.test.utils import override_settings
import pytest
from openai import OpenAIError
from mistralai import Mistral
from openai import OpenAI, OpenAIError
from pydantic_ai.models.mistral import MistralModel
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.ui.vercel_ai.request_types import TextUIPart, UIMessage
from core.services.ai_services import (
from core.services.ai_services.blocknote import (
BLOCKNOTE_TOOL_STRICT_PROMPT,
AIService,
configure_pydantic_model_provider,
convert_async_generator_to_sync,
)
from core.services.ai_services.legacy import (
LegacyAiServiceMistralClient,
LegacyAiServiceOpenAiClient,
get_legacy_ai_service,
)
pytestmark = pytest.mark.django_db
@@ -26,35 +35,129 @@ pytestmark = pytest.mark.django_db
def ai_settings(settings):
"""Fixture to set AI settings."""
settings.AI_MODEL = "llama"
settings.AI_BASE_URL = "http://example.com"
settings.AI_API_KEY = "test-key"
settings.OPENAI_SDK_BASE_URL = "http://example.com"
settings.OPENAI_SDK_API_KEY = "test-key"
settings.AI_FEATURE_ENABLED = True
settings.AI_FEATURE_BLOCKNOTE_ENABLED = True
settings.AI_FEATURE_LEGACY_ENABLED = True
settings.LANGFUSE_PUBLIC_KEY = None
settings.AI_VERCEL_SDK_VERSION = 6
yield
configure_pydantic_model_provider.cache_clear()
get_legacy_ai_service.cache_clear()
# -- AIService.__init__ --
# -- AIService configure sdk--
@pytest.mark.parametrize(
"setting_name, setting_value",
[
("AI_BASE_URL", None),
("AI_API_KEY", None),
("OPENAI_SDK_BASE_URL", None),
("OPENAI_SDK_API_KEY", None),
("AI_MODEL", None),
],
)
def test_services_ai_setting_missing(setting_name, setting_value, settings):
"""Setting should be set"""
def test_ai_services_configure_open_ai_leagcy_client_missing_settings(
setting_name, setting_value, settings
):
"""
An exception must be raised if an expected settings is missing to configure the openai sdk.
"""
setattr(settings, setting_name, setting_value)
with pytest.raises(
ImproperlyConfigured,
match="AI configuration not set",
):
AIService()
LegacyAiServiceOpenAiClient()
def test_ai_services_configure_open_ai_leagcy_client(settings):
"""With all required settings the OpenAi legacy client should be configured."""
settings.AI_MODEL = "llama"
settings.OPENAI_SDK_BASE_URL = "http://example.com"
settings.OPENAI_SDK_API_KEY = "test-key"
legacy_openai_client = LegacyAiServiceOpenAiClient()
assert isinstance(legacy_openai_client.client, OpenAI)
@pytest.mark.parametrize(
"setting_name, setting_value",
[
("MISTRAL_SDK_BASE_URL", None),
("MISTRAL_SDK_API_KEY", None),
("AI_MODEL", None),
],
)
def test_ai_services_configure_mistral_sdk_leagcy_client_missing_settings(
setting_name, setting_value, settings
):
"""
An exception must be raised if an expected settings is missing to configure the openai sdk.
"""
settings.OPENAI_SDK_BASE_URL = None
settings.OPENAI_SDK_API_KEY = None
setattr(settings, setting_name, setting_value)
with pytest.raises(
ImproperlyConfigured,
match="Mistral sdk configuration not set",
):
LegacyAiServiceMistralClient()
def test_ai_services_configure_mistral_sdk_legacy_client(settings):
"""With all required settings the Mistral sdk legacy client should be configured."""
settings.AI_MODEL = "llama"
settings.OPENAI_SDK_BASE_URL = None
settings.OPENAI_SDK_API_KEY = None
settings.MISTRAL_SDK_API_KEY = "mistreal-sdk-key"
settings.MISTRAL_SDK_BASE_URL = "https://mistral.base-url.com"
legacy_mistral_client = LegacyAiServiceMistralClient()
assert isinstance(legacy_mistral_client.client, Mistral)
def test_ai_services_configure_pydantic_ai_model_openai(settings):
"""When openai sdk settings are configured it should return an OpenAiChatModel."""
settings.AI_MODEL = "llama"
settings.OPENAI_SDK_BASE_URL = "http://example.com"
settings.OPENAI_SDK_API_KEY = "test-key"
pydantic_ai_model = configure_pydantic_model_provider()
assert isinstance(pydantic_ai_model, OpenAIChatModel)
def test_ai_services_configure_pydantic_ai_model_mistral(settings):
"""When mistral sdk settings are configured is should return a MistralModel."""
settings.AI_MODEL = "llama"
settings.OPENAI_SDK_BASE_URL = None
settings.OPENAI_SDK_API_KEY = None
settings.MISTRAL_SDK_API_KEY = "mistreal-sdk-key"
settings.MISTRAL_SDK_BASE_URL = "https://mistral.base-url.com"
pydantic_ai_model = configure_pydantic_model_provider()
assert isinstance(pydantic_ai_model, MistralModel)
def test_ai_services_configure_pydantic_ai_model_no_settings(settings):
"""When no settings are configured for a ai sdk it should raises an exception."""
settings.AI_MODEL = None
settings.OPENAI_SDK_BASE_URL = None
settings.OPENAI_SDK_API_KEY = None
settings.MISTRAL_SDK_API_KEY = None
settings.MISTRAL_SDK_BASE_URL = None
with pytest.raises(
ImproperlyConfigured,
match="AI configuration not set",
):
configure_pydantic_model_provider()
# -- AIService.transform --
@@ -73,7 +176,7 @@ def test_services_ai_client_error(mock_create):
OpenAIError,
match="Mocked client error",
):
AIService().transform("hello", "prompt")
get_legacy_ai_service().transform("hello", "prompt")
@override_settings(
@@ -91,7 +194,7 @@ def test_services_ai_client_invalid_response(mock_create):
RuntimeError,
match="AI response does not contain an answer",
):
AIService().transform("hello", "prompt")
get_legacy_ai_service().transform("hello", "prompt")
@override_settings(
@@ -105,7 +208,7 @@ def test_services_ai_success(mock_create):
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
response = AIService().transform("hello", "prompt")
response = get_legacy_ai_service().transform("hello", "prompt")
assert response == {"answer": "Salut"}
@@ -121,7 +224,7 @@ def test_services_ai_translate_success(mock_create):
choices=[MagicMock(message=MagicMock(content="Bonjour"))]
)
response = AIService().translate("<p>Hello</p>", "fr")
response = get_legacy_ai_service().translate("<p>Hello</p>", "fr")
assert response == {"answer": "Bonjour"}
call_args = mock_create.call_args
@@ -137,7 +240,7 @@ def test_services_ai_translate_unknown_language(mock_create):
choices=[MagicMock(message=MagicMock(content="Translated"))]
)
response = AIService().translate("<p>Hello</p>", "xx-unknown")
response = get_legacy_ai_service().translate("<p>Hello</p>", "xx-unknown")
assert response == {"answer": "Translated"}
call_args = mock_create.call_args
@@ -448,7 +551,7 @@ def test_services_ai_stream_defaults_to_sync(mock_build, monkeypatch):
# -- AIService._build_async_stream --
@patch("core.services.ai_services.VercelAIAdapter")
@patch("core.services.ai_services.blocknote.VercelAIAdapter")
def test_services_ai_build_async_stream(mock_adapter_cls):
"""_build_async_stream should build the pydantic-ai streaming pipeline."""
@@ -477,7 +580,7 @@ def test_services_ai_build_async_stream(mock_adapter_cls):
mock_adapter_instance.encode_stream.assert_called_once()
@patch("core.services.ai_services.VercelAIAdapter")
@patch("core.services.ai_services.blocknote.VercelAIAdapter")
def test_services_ai_build_async_stream_with_tool_definitions(mock_adapter_cls):
"""_build_async_stream should build an ExternalToolset when
toolDefinitions are present in the request."""
@@ -514,7 +617,7 @@ def test_services_ai_build_async_stream_with_tool_definitions(mock_adapter_cls):
assert len(call_kwargs["toolsets"]) == 1
@patch("core.services.ai_services.VercelAIAdapter")
@patch("core.services.ai_services.blocknote.VercelAIAdapter")
def test_services_ai_build_async_stream_with_tool_definitions_required_system_prompt(
mock_adapter_cls,
):
@@ -557,8 +660,8 @@ def test_services_ai_build_async_stream_with_tool_definitions_required_system_pr
assert mock_run_input.messages[0].parts[0].text == BLOCKNOTE_TOOL_STRICT_PROMPT
@patch("core.services.ai_services.Agent")
@patch("core.services.ai_services.VercelAIAdapter")
@patch("core.services.ai_services.blocknote.Agent")
@patch("core.services.ai_services.blocknote.VercelAIAdapter")
def test_services_ai_build_async_stream_langfuse_enabled(
mock_adapter_cls, mock_agent_cls, settings
):

View File

@@ -12,13 +12,14 @@ import pytest
import responses
from requests import HTTPError
from core import factories, models, utils
from core import factories, models
from core.services.search_indexers import (
BaseDocumentIndexer,
FindDocumentIndexer,
get_document_indexer,
get_visited_document_ids_of,
)
from core.utils.yjs import base64_yjs_to_text
pytestmark = pytest.mark.django_db
@@ -199,7 +200,7 @@ def test_services_search_indexers_serialize_document_returns_expected_json():
"depth": 1,
"path": document.path,
"numchild": 1,
"content": utils.base64_yjs_to_text(document.content),
"content": base64_yjs_to_text(document.content),
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
"reach": document.link_reach,

View File

@@ -8,7 +8,18 @@ from django.core.cache import cache
import pycrdt
import pytest
from core import factories, utils
from core import factories
from core.utils.dicts import get_value_by_pattern
from core.utils.paths import get_ancestor_to_descendants_map
from core.utils.users import (
get_users_sharing_documents_with_cache_key,
users_sharing_documents_with,
)
from core.utils.yjs import (
base64_yjs_to_text,
base64_yjs_to_xml,
extract_attachments,
)
pytestmark = pytest.mark.django_db
@@ -34,12 +45,12 @@ TEST_BASE64_STRING = (
def test_utils_base64_yjs_to_text():
"""Test extract text from saved yjs document"""
assert utils.base64_yjs_to_text(TEST_BASE64_STRING) == "Hello w or ld"
assert base64_yjs_to_text(TEST_BASE64_STRING) == "Hello w or ld"
def test_utils_base64_yjs_to_xml():
"""Test extract xml from saved yjs document"""
content = utils.base64_yjs_to_xml(TEST_BASE64_STRING)
content = base64_yjs_to_xml(TEST_BASE64_STRING)
assert (
'<heading textAlignment="left" level="1"><italic>Hello</italic></heading>'
in content
@@ -79,13 +90,13 @@ def test_utils_extract_attachments():
update = ydoc.get_update()
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]
assert 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)
result = get_ancestor_to_descendants_map(paths, steplen=4)
assert result == {
"0001": {"000100020005"},
@@ -97,7 +108,7 @@ def test_utils_get_ancestor_to_descendants_map_single_path():
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)
result = get_ancestor_to_descendants_map(paths, steplen=4)
assert result == {
"0001": {"000100020005", "00010003"},
@@ -119,10 +130,10 @@ def test_utils_users_sharing_documents_with_cache_miss():
factories.UserDocumentAccessFactory(user=user2, document=doc1)
factories.UserDocumentAccessFactory(user=user3, document=doc2)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache_key = get_users_sharing_documents_with_cache_key(user1)
cache.delete(cache_key)
result = utils.users_sharing_documents_with(user1)
result = users_sharing_documents_with(user1)
assert user2.id in result
@@ -139,12 +150,12 @@ def test_utils_users_sharing_documents_with_cache_hit():
factories.UserDocumentAccessFactory(user=user1, document=doc1)
factories.UserDocumentAccessFactory(user=user2, document=doc1)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache_key = get_users_sharing_documents_with_cache_key(user1)
test_cached_data = {user2.id: "2025-02-10"}
cache.set(cache_key, test_cached_data, 86400)
result = utils.users_sharing_documents_with(user1)
result = users_sharing_documents_with(user1)
assert result == test_cached_data
@@ -156,7 +167,7 @@ def test_utils_users_sharing_documents_with_cache_invalidation_on_create():
doc1 = factories.DocumentFactory()
# Pre-populate cache
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache_key = get_users_sharing_documents_with_cache_key(user1)
cache.set(cache_key, {}, 86400)
# Verify cache exists
@@ -182,7 +193,7 @@ def test_utils_users_sharing_documents_with_cache_invalidation_on_delete():
doc_access = factories.UserDocumentAccessFactory(user=user1, document=doc1)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache_key = get_users_sharing_documents_with_cache_key(user1)
cache.set(cache_key, {user2.id: "2025-02-10"}, 86400)
assert cache.get(cache_key) is not None
@@ -196,10 +207,10 @@ def test_utils_users_sharing_documents_with_empty_result():
"""Test when user is not sharing any documents."""
user1 = factories.UserFactory()
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache_key = get_users_sharing_documents_with_cache_key(user1)
cache.delete(cache_key)
result = utils.users_sharing_documents_with(user1)
result = users_sharing_documents_with(user1)
assert result == {}
@@ -210,7 +221,7 @@ def test_utils_users_sharing_documents_with_empty_result():
def test_utils_get_value_by_pattern_matching_key():
"""Test extracting value from a dictionary with a matching key pattern."""
data = {"title.extension": "Bonjour", "id": 1, "content": "test"}
result = utils.get_value_by_pattern(data, r"^title\.")
result = get_value_by_pattern(data, r"^title\.")
assert set(result) == {"Bonjour"}
@@ -218,7 +229,7 @@ def test_utils_get_value_by_pattern_matching_key():
def test_utils_get_value_by_pattern_multiple_matches():
"""Test that all matching keys are returned."""
data = {"title.extension_1": "Bonjour", "title.extension_2": "Hello", "id": 1}
result = utils.get_value_by_pattern(data, r"^title\.")
result = get_value_by_pattern(data, r"^title\.")
assert set(result) == {
"Bonjour",
@@ -229,7 +240,7 @@ def test_utils_get_value_by_pattern_multiple_matches():
def test_utils_get_value_by_pattern_multiple_extensions():
"""Test that all matching keys are returned."""
data = {"title.extension_1.extension_2": "Bonjour", "id": 1}
result = utils.get_value_by_pattern(data, r"^title\.")
result = get_value_by_pattern(data, r"^title\.")
assert set(result) == {"Bonjour"}
@@ -237,6 +248,6 @@ def test_utils_get_value_by_pattern_multiple_extensions():
def test_utils_get_value_by_pattern_no_match():
"""Test that empty list is returned when no key matches the pattern."""
data = {"name": "Test", "id": 1}
result = utils.get_value_by_pattern(data, r"^title\.")
result = get_value_by_pattern(data, r"^title\.")
assert result == []

View File

@@ -0,0 +1,89 @@
"""Tests for the create_tree_node_with_retry utils."""
from unittest import mock
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import IntegrityError
import pytest
from core.factories import UserFactory
from core.models import Document
from core.utils.treebeard import _is_tree_path_collision, create_tree_node_with_retry
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"exc",
[
DjangoValidationError({"path": "not unique"}),
IntegrityError("impress_document_path_key"),
],
)
def test_utils_create_tree_node_with_retry_exceed_max_attempts(settings, exc):
"""Test exceeding the max attempts should reraise the exception."""
settings.TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS = 2
create_fn = mock.MagicMock()
create_fn.side_effect = exc
with (
pytest.raises(exc.__class__),
mock.patch(
"core.utils.treebeard._is_tree_path_collision"
) as mock__is_tree_path_collision,
):
mock__is_tree_path_collision.side_effect = _is_tree_path_collision
create_tree_node_with_retry(create_fn)
mock__is_tree_path_collision.assert_called()
assert mock__is_tree_path_collision.call_count == 2
assert create_fn.call_count == 2
@pytest.mark.parametrize(
"exc",
[
DjangoValidationError({"foo": "bar"}),
IntegrityError("not handled"),
],
)
def test_utils_create_tree_node_with_retry_exceed_exception_not_handled(settings, exc):
"""Test with an exception not handled should return reraise it immediately."""
settings.TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS = 2
create_fn = mock.MagicMock()
create_fn.side_effect = exc
with (
pytest.raises(exc.__class__),
mock.patch(
"core.utils.treebeard._is_tree_path_collision"
) as mock__is_tree_path_collision,
):
mock__is_tree_path_collision.side_effect = _is_tree_path_collision
create_tree_node_with_retry(create_fn)
mock__is_tree_path_collision.assert_called()
assert mock__is_tree_path_collision.call_count == 1
assert create_fn.call_count == 1
def test_utils_create_tree_node_with_retry_success():
"""Test executing successfully the create_fn callback."""
user = UserFactory()
document = create_tree_node_with_retry(
lambda: Document.add_root(
creator=user,
title="success",
)
)
assert isinstance(document, Document)
assert document.title == "success"
assert document.path is not None

View File

@@ -2,7 +2,7 @@
Unit tests for the filter_root_paths utility function.
"""
from core.utils import filter_descendants
from core.utils.paths import filter_descendants
def test_utils_filter_descendants_success():

View File

@@ -4,7 +4,8 @@ from django.utils import timezone
import pytest
from core import factories, utils
from core import factories
from core.utils.users import users_sharing_documents_with
pytestmark = pytest.mark.django_db
@@ -54,7 +55,7 @@ def test_utils_users_sharing_documents_with():
doc_3_pierre_2.created_at = yesterday
doc_3_pierre_2.save()
shared_map = utils.users_sharing_documents_with(user)
shared_map = users_sharing_documents_with(user)
assert shared_map == {
pierre_1.id: last_week,

View File

@@ -1,170 +0,0 @@
"""Utils for the core app."""
import base64
import logging
import re
import time
from collections import defaultdict
from django.core.cache import cache
from django.db import models as db
from django.db.models import Subquery
import pycrdt
from bs4 import BeautifulSoup
from core import enums, models
logger = logging.getLogger(__name__)
def get_value_by_pattern(data, pattern):
"""
Get all values from keys matching a regex pattern in a dictionary.
Args:
data (dict): Source dictionary to search
pattern (str): Regex pattern to match against keys
Returns:
list: List of values for all matching keys, empty list if no matches
Example:
>>> get_value_by_pattern({"title.fr": "Bonjour", "id": 1}, r"^title\\.")
["Bonjour"]
>>> get_value_by_pattern({"title.fr": "Bonjour", "title.en": "Hello"}, r"^title\\.")
["Bonjour", "Hello"]
"""
regex = re.compile(pattern)
return [value for key, value in data.items() if regex.match(key)]
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.
A path is considered a descendant of a root path if it starts with the root path.
If `skip_sorting` is not set to True, the function will sort both lists before
processing because both `paths` and `root_paths` need to be in lexicographic order
before going through the algorithm.
Args:
paths (iterable of str): List of paths to be filtered.
root_paths (iterable of str): List of paths to check as potential prefixes.
skip_sorting (bool): If True, assumes both `paths` and `root_paths` are already sorted.
Returns:
list of str: A list of sorted paths that are descendants of any path in `root_paths`.
"""
results = []
i = 0
n = len(root_paths)
if not skip_sorting:
paths.sort()
root_paths.sort()
for path in paths:
# Try to find a matching prefix in the sorted accessible paths
while i < n:
if path.startswith(root_paths[i]):
results.append(path)
break
if root_paths[i] < path:
i += 1
else:
# If paths[i] > path, no need to keep searching
break
return results
def base64_yjs_to_xml(base64_string):
"""Extract xml from base64 yjs document."""
decoded_bytes = base64.b64decode(base64_string)
# uint8_array = bytearray(decoded_bytes)
doc = pycrdt.Doc()
doc.apply_update(decoded_bytes)
return str(doc.get("document-store", type=pycrdt.XmlFragment))
def base64_yjs_to_text(base64_string):
"""Extract text from base64 yjs document."""
blocknote_structure = base64_yjs_to_xml(base64_string)
soup = BeautifulSoup(blocknote_structure, "lxml-xml")
return soup.get_text(separator=" ", strip=True)
def extract_attachments(content):
"""Helper method to extract media paths from a document's content."""
if not content:
return []
xml_content = base64_yjs_to_xml(content)
return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content)
def get_users_sharing_documents_with_cache_key(user):
"""Generate a unique cache key for each user."""
return f"users_sharing_documents_with_{user.id}"
def users_sharing_documents_with(user):
"""
Returns a map of users sharing documents with the given user,
sorted by last shared date.
"""
start_time = time.time()
cache_key = get_users_sharing_documents_with_cache_key(user)
cached_result = cache.get(cache_key)
if cached_result is not None:
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache hit for user %s (took %.3fs)",
user.id,
elapsed,
)
return cached_result
user_docs_qs = models.DocumentAccess.objects.filter(user=user).values_list(
"document_id", flat=True
)
shared_qs = (
models.DocumentAccess.objects.filter(document_id__in=Subquery(user_docs_qs))
.exclude(user=user)
.values("user")
.annotate(last_shared=db.Max("created_at"))
)
result = {item["user"]: item["last_shared"] for item in shared_qs}
cache.set(cache_key, result, 86400) # Cache for 1 day
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache miss for user %s (took %.3fs)",
user.id,
elapsed,
)
return result

View File

@@ -0,0 +1 @@
"""Core utilities package."""

View File

@@ -0,0 +1,24 @@
"""Dictionary utility functions."""
import re
def get_value_by_pattern(data, pattern):
"""
Get all values from keys matching a regex pattern in a dictionary.
Args:
data (dict): Source dictionary to search
pattern (str): Regex pattern to match against keys
Returns:
list: List of values for all matching keys, empty list if no matches
Example:
>>> get_value_by_pattern({"title.fr": "Bonjour", "id": 1}, r"^title\\.")
["Bonjour"]
>>> get_value_by_pattern({"title.fr": "Bonjour", "title.en": "Hello"}, r"^title\\.")
["Bonjour", "Hello"]
"""
regex = re.compile(pattern)
return [value for key, value in data.items() if regex.match(key)]

View File

@@ -0,0 +1,63 @@
"""Path and tree structure utilities."""
from collections import defaultdict
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.
A path is considered a descendant of a root path if it starts with the root path.
If `skip_sorting` is not set to True, the function will sort both lists before
processing because both `paths` and `root_paths` need to be in lexicographic order
before going through the algorithm.
Args:
paths (iterable of str): List of paths to be filtered.
root_paths (iterable of str): List of paths to check as potential prefixes.
skip_sorting (bool): If True, assumes both `paths` and `root_paths` are already sorted.
Returns:
list of str: A list of sorted paths that are descendants of any path in `root_paths`.
"""
results = []
i = 0
n = len(root_paths)
if not skip_sorting:
paths.sort()
root_paths.sort()
for path in paths:
# Try to find a matching prefix in the sorted accessible paths
while i < n:
if path.startswith(root_paths[i]):
results.append(path)
break
if root_paths[i] < path:
i += 1
else:
# If paths[i] > path, no need to keep searching
break
return results

View File

@@ -0,0 +1,62 @@
"""Treebeard path collision handling utilities."""
import logging
import time
from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import IntegrityError, transaction
logger = logging.getLogger(__name__)
def _is_tree_path_collision(exc):
"""Return True when `exc` is caused by a Document.path uniqueness conflict.
Treebeard computes the materialized path by reading the current siblings;
under concurrency two callers may compute the same value. Depending on
timing this surfaces either as:
- `django.core.exceptions.ValidationError` raised by `full_clean()` /
`validate_unique()` before the INSERT (BaseModel.save calls full_clean),
with this message `{'path': ['Document with this Path already exists.']}`
- or `IntegrityError` from the database unique index when the validate
step misses the conflict. With this message:
duplicate key value violates unique constraint "impress_document_path_key"
DETAIL: Key (path)=(0000001) already exists.
"""
if isinstance(exc, DjangoValidationError):
message_dict = getattr(exc, "message_dict", None)
if message_dict is not None:
return "path" in message_dict
return "path" in str(exc).lower()
# search in the IntegrityError exception
return "impress_document_path_key" in str(exc).lower()
def create_tree_node_with_retry(create_fn):
"""Run `create_fn` in a fresh atomic block, retrying on path collisions.
The Document.path field carries a unique constraint, which is the source of
truth that prevents duplicate paths. On collision we let the failed
transaction roll back, and call `create_fn` again so treebeard recomputes
the path from the latest state.
"""
max_attempts = settings.TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS
for attempt in range(max_attempts):
try:
with transaction.atomic():
return create_fn()
except (IntegrityError, DjangoValidationError) as exc:
if not _is_tree_path_collision(exc) or attempt == max_attempts - 1:
raise
logger.info(
"tree path collision on attempt %d/%d, retrying",
attempt + 1,
max_attempts,
)
time.sleep(attempt * 0.1)
raise RuntimeError("create_tree_node_with_retry exited without result")

View File

@@ -0,0 +1,55 @@
"""User sharing cache utilities."""
import logging
import time
from django.core.cache import cache
from django.db import models as db
from django.db.models import Subquery
from core import models
logger = logging.getLogger(__name__)
def get_users_sharing_documents_with_cache_key(user):
"""Generate a unique cache key for each user."""
return f"users_sharing_documents_with_{user.id}"
def users_sharing_documents_with(user):
"""
Returns a map of users sharing documents with the given user,
sorted by last shared date.
"""
start_time = time.time()
cache_key = get_users_sharing_documents_with_cache_key(user)
cached_result = cache.get(cache_key)
if cached_result is not None:
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache hit for user %s (took %.3fs)",
user.id,
elapsed,
)
return cached_result
user_docs_qs = models.DocumentAccess.objects.filter(user=user).values_list(
"document_id", flat=True
)
shared_qs = (
models.DocumentAccess.objects.filter(document_id__in=Subquery(user_docs_qs))
.exclude(user=user)
.values("user")
.annotate(last_shared=db.Max("created_at"))
)
result = {item["user"]: item["last_shared"] for item in shared_qs}
cache.set(cache_key, result, 86400) # Cache for 1 day
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache miss for user %s (took %.3fs)",
user.id,
elapsed,
)
return result

View File

@@ -0,0 +1,36 @@
"""Yjs document conversion utilities."""
import base64
import re
import pycrdt
from bs4 import BeautifulSoup
from core import enums
def base64_yjs_to_xml(base64_string):
"""Extract xml from base64 yjs document."""
decoded_bytes = base64.b64decode(base64_string)
doc = pycrdt.Doc()
doc.apply_update(decoded_bytes)
return str(doc.get("document-store", type=pycrdt.XmlFragment))
def base64_yjs_to_text(base64_string):
"""Extract text from base64 yjs document."""
blocknote_structure = base64_yjs_to_xml(base64_string)
soup = BeautifulSoup(blocknote_structure, "lxml-xml")
return soup.get_text(separator=" ", strip=True)
def extract_attachments(content):
"""Helper method to extract media paths from a document's content."""
if not content:
return []
xml_content = base64_yjs_to_xml(content)
return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content)

View File

@@ -161,7 +161,8 @@
},
"onboarding": {
"enabled": true,
"learn_more_url": ""
"learn_more_url": "",
"ready_template_url": ""
},
"help": {
"documentation_url": ""

View File

@@ -130,6 +130,12 @@ class Base(Configuration):
default=50, environ_name="SEARCH_INDEXER_QUERY_LIMIT", environ_prefix=None
)
MEDIA_AUTH_ORIGINAL_URL_HEADER = values.Value(
default="HTTP_X_ORIGINAL_URL",
environ_name="MEDIA_AUTH_ORIGINAL_URL_HEADER",
environ_prefix=None,
)
# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(DATA_DIR, "static")
@@ -501,6 +507,11 @@ class Base(Configuration):
environ_name="COLLABORATION_WS_NOT_CONNECTED_READY_ONLY",
environ_prefix=None,
)
COLLABORATION_WS_INACTIVITY_TIMEOUT = values.IntegerValue(
None,
environ_name="COLLABORATION_WS_INACTIVITY_TIMEOUT",
environ_prefix=None,
)
# Frontend
FRONTEND_THEME = values.Value(
@@ -802,8 +813,30 @@ class Base(Configuration):
environ_name="AI_ALLOW_REACH_FROM",
environ_prefix=None,
)
AI_API_KEY = SecretFileValue(None, environ_name="AI_API_KEY", environ_prefix=None)
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
MISTRAL_SDK_BASE_URL = values.Value(
None, environ_name="MISTRAL_SDK_BASE_URL", environ_prefix=None
)
MISTRAL_SDK_API_KEY = SecretFileValue(
None, environ_name="MISTRAL_SDK_API_KEY", environ_prefix=None
)
OPENAI_SDK_API_KEY = SecretFileValue(
default=SecretFileValue( # retrocompatibility
None,
environ_name="AI_API_KEY",
environ_prefix=None,
),
environ_name="OPENAI_SDK_API_KEY",
environ_prefix=None,
)
OPENAI_SDK_BASE_URL = values.Value(
default=values.Value( # retrocompatibility
None, environ_name="AI_BASE_URL", environ_prefix=None
),
environ_name="OPENAI_SDK_BASE_URL",
environ_prefix=None,
)
AI_BOT = values.DictValue(
default={
"name": _("Docs AI"),
@@ -1053,6 +1086,12 @@ class Base(Configuration):
60 * 60 * 24, environ_name="CONTENT_METADATA_CACHE_TIMEOUT", environ_prefix=None
)
TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS = values.IntegerValue(
10,
environ_name="TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -1143,6 +1182,11 @@ class Base(Configuration):
}
)
if cls.OPENAI_SDK_API_KEY and cls.MISTRAL_SDK_API_KEY:
raise ValueError(
"Both OPENAI_SDK and MISTRAL_SDK parameters can not be set simultaneously."
)
class Build(Base):
"""Settings used when the application is built.

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
"PO-Revision-Date: 2026-04-08 13:28\n"
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
"PO-Revision-Date: 2026-04-30 13:05\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
@@ -17,28 +17,28 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr "Titouroù personel"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
#: core/admin.py:166
msgid "Permissions"
msgstr "Aotreoù"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "Deiziadoù a-bouez"
#: build/lib/core/admin.py:112 core/admin.py:112
#: build/lib/core/admin.py:117 core/admin.py:117
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
#: build/lib/core/admin.py:121 core/admin.py:121
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:176 core/admin.py:176
msgid "Tree structure"
msgstr "Gwezennadur"
@@ -62,24 +62,24 @@ msgstr "Kuzhet"
msgid "Favorite"
msgstr "Sinedoù"
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
msgid "A new document was created on your behalf!"
msgstr "Ur restr nevez a zo bet krouet ganeoc'h!"
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
msgid "You have been granted ownership of a new document:"
msgstr "C'hwi zo bet disklaeriet perc'henn ur restr nevez:"
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
msgid "This field is required."
msgstr "Ar vaezienn-mañ a zo rekis."
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
#, python-brace-format
msgid "copy of {title}"
msgstr "eilenn {title}"
@@ -375,151 +375,151 @@ msgstr "Restr"
msgid "Documents"
msgstr "Restroù"
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
#: core/models.py:940 core/models.py:1345
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
#: core/models.py:940 core/models.py:1347
msgid "Untitled Document"
msgstr "Restr hep titl"
#: build/lib/core/models.py:1346 core/models.py:1346
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Open"
msgstr "Digeriñ"
#: build/lib/core/models.py:1381 core/models.py:1381
#: build/lib/core/models.py:1383 core/models.py:1383
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} en deus rannet ur restr ganeoc'h!"
#: build/lib/core/models.py:1385 core/models.py:1385
#: build/lib/core/models.py:1387 core/models.py:1387
#, 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:1391 core/models.py:1391
#: build/lib/core/models.py:1393 core/models.py:1393
#, 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:1492 core/models.py:1492
#: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link trace"
msgstr "Roud liamm ar restr/an implijer"
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:1495 core/models.py:1495
msgid "Document/user link traces"
msgstr "Roudoù liamm ar restr/an implijer"
#: build/lib/core/models.py:1499 core/models.py:1499
#: build/lib/core/models.py:1501 core/models.py:1501
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:1522 core/models.py:1522
#: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorite"
msgstr "Restr muiañ-karet"
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1525 core/models.py:1525
msgid "Document favorites"
msgstr "Restroù muiañ-karet"
#: build/lib/core/models.py:1529 core/models.py:1529
#: build/lib/core/models.py:1531 core/models.py:1531
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:1551 core/models.py:1551
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relation"
msgstr "Liamm restr/implijer"
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1554 core/models.py:1554
msgid "Document/user relations"
msgstr "Liammoù restr/implijer"
#: build/lib/core/models.py:1558 core/models.py:1558
#: build/lib/core/models.py:1560 core/models.py:1560
msgid "This user is already in this document."
msgstr "An implijer-mañ a zo dija er restr-mañ."
#: build/lib/core/models.py:1564 core/models.py:1564
#: build/lib/core/models.py:1566 core/models.py:1566
msgid "This team is already in this document."
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
#: build/lib/core/models.py:1570 core/models.py:1570
#: build/lib/core/models.py:1572 core/models.py:1572
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:1721 core/models.py:1721
#: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for access"
msgstr "Goulenn tizhout ar restr"
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1724 core/models.py:1724
msgid "Document ask for accesses"
msgstr "Goulennoù tizhout ar restr"
#: build/lib/core/models.py:1728 core/models.py:1728
#: build/lib/core/models.py:1730 core/models.py:1730
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:1785 core/models.py:1785
#: build/lib/core/models.py:1787 core/models.py:1787
#, 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:1789 core/models.py:1789
#: build/lib/core/models.py:1791 core/models.py:1791
#, 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:1795 core/models.py:1795
#: build/lib/core/models.py:1797 core/models.py:1797
#, 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:1837 core/models.py:1837
#: build/lib/core/models.py:1839 core/models.py:1839
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1840 core/models.py:1840
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
#: core/models.py:1841 core/models.py:1893
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
#: core/models.py:1843 core/models.py:1895
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1888 core/models.py:1888
#: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1891 core/models.py:1891
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1938 core/models.py:1938
#: build/lib/core/models.py:1940 core/models.py:1940
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1942 core/models.py:1942
#: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1945 core/models.py:1945
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1953 core/models.py:1953
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "email address"
msgstr "postel"
#: build/lib/core/models.py:1972 core/models.py:1972
#: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitation"
msgstr "Pedadenn d'ur restr"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1975 core/models.py:1975
msgid "Document invitations"
msgstr "Pedadennoù d'ur restr"
#: build/lib/core/models.py:1993 core/models.py:1993
#: build/lib/core/models.py:1995 core/models.py:1995
msgid "This email is already associated to a registered user."
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."
#: build/lib/impress/settings.py:808 impress/settings.py:808
#: build/lib/impress/settings.py:837 impress/settings.py:837
msgid "Docs AI"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
"PO-Revision-Date: 2026-04-08 13:28\n"
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
"PO-Revision-Date: 2026-04-30 13:05\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -17,28 +17,28 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr "Persönliche Daten"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
#: core/admin.py:166
msgid "Permissions"
msgstr "Berechtigungen"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "Wichtige Termine"
#: build/lib/core/admin.py:112 core/admin.py:112
#: build/lib/core/admin.py:117 core/admin.py:117
msgid "Import job created and queued."
msgstr "Import-Job erstellt und in der Warteschlange."
#: build/lib/core/admin.py:116 core/admin.py:116
#: build/lib/core/admin.py:121 core/admin.py:121
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:176 core/admin.py:176
msgid "Tree structure"
msgstr "Baumstruktur"
@@ -62,24 +62,24 @@ msgstr "Maskiert"
msgid "Favorite"
msgstr "Favorit"
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
msgid "A new document was created on your behalf!"
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
msgid "You have been granted ownership of a new document:"
msgstr "Sie sind Besitzer eines neuen Dokuments:"
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
msgid "This field is required."
msgstr "Dies ist ein Pflichtfeld."
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Der Zugriff auf den Link '%(link_reach)s' ist aufgrund der Konfiguration übergeordneter Dokumente nicht erlaubt."
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
#, python-brace-format
msgid "copy of {title}"
msgstr "Kopie von {title}"
@@ -149,15 +149,15 @@ msgstr "Rechts"
#: build/lib/core/models.py:80 core/models.py:80
msgid "id"
msgstr "id"
msgstr "ID"
#: build/lib/core/models.py:81 core/models.py:81
msgid "primary key for the record as UUID"
msgstr "primärer Schlüssel für den Datensatz als UUID"
msgstr "Primärschlüssel für den Datensatz als UUID"
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr "Erstellt"
msgstr "Erstellt am"
#: build/lib/core/models.py:88 core/models.py:88
msgid "date and time at which a record was created"
@@ -375,151 +375,151 @@ msgstr "Dokument"
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
#: core/models.py:940 core/models.py:1345
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
#: core/models.py:940 core/models.py:1347
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:1346 core/models.py:1346
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Open"
msgstr "Öffnen"
#: build/lib/core/models.py:1381 core/models.py:1381
#: build/lib/core/models.py:1383 core/models.py:1383
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:1385 core/models.py:1385
#: build/lib/core/models.py:1387 core/models.py:1387
#, 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:1391 core/models.py:1391
#: build/lib/core/models.py:1393 core/models.py:1393
#, 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:1492 core/models.py:1492
#: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:1495 core/models.py:1495
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:1499 core/models.py:1499
#: build/lib/core/models.py:1501 core/models.py:1501
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:1522 core/models.py:1522
#: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1525 core/models.py:1525
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:1529 core/models.py:1529
#: build/lib/core/models.py:1531 core/models.py:1531
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:1551 core/models.py:1551
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1554 core/models.py:1554
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:1558 core/models.py:1558
#: build/lib/core/models.py:1560 core/models.py:1560
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1564 core/models.py:1564
#: build/lib/core/models.py:1566 core/models.py:1566
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1570 core/models.py:1570
#: build/lib/core/models.py:1572 core/models.py:1572
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:1721 core/models.py:1721
#: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for access"
msgstr "Dokument um Zugriff bitten"
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1724 core/models.py:1724
msgid "Document ask for accesses"
msgstr "Dokumentenabfragen"
#: build/lib/core/models.py:1728 core/models.py:1728
#: build/lib/core/models.py:1730 core/models.py:1730
msgid "This user has already asked for access to this document."
msgstr "Dieser Benutzer hat bereits um Zugang zu diesem Dokument gebeten."
#: build/lib/core/models.py:1785 core/models.py:1785
#: build/lib/core/models.py:1787 core/models.py:1787
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} möchte Zugriff auf ein Dokument erhalten!"
#: build/lib/core/models.py:1789 core/models.py:1789
#: build/lib/core/models.py:1791 core/models.py:1791
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} möchte auf das folgende Dokument zugreifen:"
#: build/lib/core/models.py:1795 core/models.py:1795
#: build/lib/core/models.py:1797 core/models.py:1797
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} bittet um Zugang zum Dokument: {title}"
#: build/lib/core/models.py:1837 core/models.py:1837
#: build/lib/core/models.py:1839 core/models.py:1839
msgid "Thread"
msgstr "Thread"
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1840 core/models.py:1840
msgid "Threads"
msgstr "Threads"
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
#: core/models.py:1841 core/models.py:1893
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
#: core/models.py:1843 core/models.py:1895
msgid "Anonymous"
msgstr "Gast"
#: build/lib/core/models.py:1888 core/models.py:1888
#: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comment"
msgstr "Kommentar"
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1891 core/models.py:1891
msgid "Comments"
msgstr "Kommentare"
#: build/lib/core/models.py:1938 core/models.py:1938
#: build/lib/core/models.py:1940 core/models.py:1940
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1942 core/models.py:1942
#: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reaction"
msgstr "Reaktion"
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1945 core/models.py:1945
msgid "Reactions"
msgstr "Reaktionen"
#: build/lib/core/models.py:1953 core/models.py:1953
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1972 core/models.py:1972
#: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1975 core/models.py:1975
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1993 core/models.py:1993
#: build/lib/core/models.py:1995 core/models.py:1995
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
#: build/lib/impress/settings.py:808 impress/settings.py:808
#: build/lib/impress/settings.py:837 impress/settings.py:837
msgid "Docs AI"
msgstr "Docs AI"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
"PO-Revision-Date: 2026-04-08 13:28\n"
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
"PO-Revision-Date: 2026-04-30 13:05\n"
"Last-Translator: \n"
"Language-Team: Greek\n"
"Language: el_GR\n"
@@ -17,28 +17,28 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr "Προσωπικές πληροφορίες"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
#: core/admin.py:166
msgid "Permissions"
msgstr "Δικαιώματα"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "Σημαντικές ημερομηνίες"
#: build/lib/core/admin.py:112 core/admin.py:112
#: build/lib/core/admin.py:117 core/admin.py:117
msgid "Import job created and queued."
msgstr "Η εργασία εισαγωγής δημιουργήθηκε και μπήκε στην ουρά."
#: build/lib/core/admin.py:116 core/admin.py:116
#: build/lib/core/admin.py:121 core/admin.py:121
msgid "Process selected user reconciliations"
msgstr "Επεξεργασία επιλεγμένων συμφωνιών χρηστών"
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:176 core/admin.py:176
msgid "Tree structure"
msgstr "Δομή δέντρου"
@@ -62,24 +62,24 @@ msgstr "Με κάλυψη"
msgid "Favorite"
msgstr "Αγαπημένο"
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
msgid "A new document was created on your behalf!"
msgstr "Ένα νέο έγγραφο δημιουργήθηκε εκ μέρους σας!"
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
msgid "You have been granted ownership of a new document:"
msgstr "Σας παραχωρήθηκε η ιδιοκτησία ενός νέου εγγράφου:"
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
msgid "This field is required."
msgstr "Αυτό το πεδίο είναι υποχρεωτικό."
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Η εμβέλεια συνδέσμου '%(link_reach)s' δεν επιτρέπεται βάσει της διαμόρφωσης του γονικού εγγράφου."
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
#, python-brace-format
msgid "copy of {title}"
msgstr "αντίγραφο του {title}"
@@ -382,151 +382,151 @@ msgstr "Έγγραφο"
msgid "Documents"
msgstr "Έγγραφα"
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
#: core/models.py:940 core/models.py:1345
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
#: core/models.py:940 core/models.py:1347
msgid "Untitled Document"
msgstr "Έγγραφο χωρίς τίτλο"
#: build/lib/core/models.py:1346 core/models.py:1346
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Open"
msgstr "Άνοιγμα"
#: build/lib/core/models.py:1381 core/models.py:1381
#: build/lib/core/models.py:1383 core/models.py:1383
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "Ο/Η {name} μοιράστηκε ένα έγγραφο μαζί σας!"
#: build/lib/core/models.py:1385 core/models.py:1385
#: build/lib/core/models.py:1387 core/models.py:1387
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "Ο/Η {name} σας προσκάλεσε με τον ρόλο \"{role}\" στο ακόλουθο έγγραφο:"
#: build/lib/core/models.py:1391 core/models.py:1391
#: build/lib/core/models.py:1393 core/models.py:1393
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "Ο/Η {name} μοιράστηκε ένα έγγραφο μαζί σας: {title}"
#: build/lib/core/models.py:1492 core/models.py:1492
#: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link trace"
msgstr "Ίχνος συνδέσμου εγγράφου/χρήστη"
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:1495 core/models.py:1495
msgid "Document/user link traces"
msgstr "Ίχνη συνδέσμου εγγράφου/χρήστη"
#: build/lib/core/models.py:1499 core/models.py:1499
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "A link trace already exists for this document/user."
msgstr "Ένα ίχνος συνδέσμου υπάρχει ήδη για αυτό το έγγραφο/χρήστη."
#: build/lib/core/models.py:1522 core/models.py:1522
#: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorite"
msgstr "Αγαπημένο έγγραφο"
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1525 core/models.py:1525
msgid "Document favorites"
msgstr "Αγαπημένα έγγραφα"
#: build/lib/core/models.py:1529 core/models.py:1529
#: build/lib/core/models.py:1531 core/models.py:1531
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Αυτό το έγγραφο στοχεύεται ήδη από μια σχέση αγαπημένου για τον ίδιο χρήστη."
#: build/lib/core/models.py:1551 core/models.py:1551
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relation"
msgstr "Σχέση εγγράφου/χρήστη"
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1554 core/models.py:1554
msgid "Document/user relations"
msgstr "Σχέσεις εγγράφου/χρήστη"
#: build/lib/core/models.py:1558 core/models.py:1558
#: build/lib/core/models.py:1560 core/models.py:1560
msgid "This user is already in this document."
msgstr "Αυτός ο χρήστης συμμετέχει ήδη σε αυτό το έγγραφο."
#: build/lib/core/models.py:1564 core/models.py:1564
#: build/lib/core/models.py:1566 core/models.py:1566
msgid "This team is already in this document."
msgstr "Αυτή η ομάδα συμμετέχει ήδη σε αυτό το έγγραφο."
#: build/lib/core/models.py:1570 core/models.py:1570
#: build/lib/core/models.py:1572 core/models.py:1572
msgid "Either user or team must be set, not both."
msgstr "Πρέπει να οριστεί είτε χρήστης είτε ομάδα, όχι και τα δύο."
#: build/lib/core/models.py:1721 core/models.py:1721
#: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for access"
msgstr "Αίτημα πρόσβασης σε έγγραφο"
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1724 core/models.py:1724
msgid "Document ask for accesses"
msgstr "Αιτήματα πρόσβασης σε έγγραφα"
#: build/lib/core/models.py:1728 core/models.py:1728
#: build/lib/core/models.py:1730 core/models.py:1730
msgid "This user has already asked for access to this document."
msgstr "Αυτός ο χρήστης έχει ήδη ζητήσει πρόσβαση σε αυτό το έγγραφο."
#: build/lib/core/models.py:1785 core/models.py:1785
#: build/lib/core/models.py:1787 core/models.py:1787
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "Ο/Η {name} θα ήθελε πρόσβαση σε ένα έγγραφο!"
#: build/lib/core/models.py:1789 core/models.py:1789
#: build/lib/core/models.py:1791 core/models.py:1791
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "Ο/Η {name} θα ήθελε πρόσβαση στο ακόλουθο έγγραφο:"
#: build/lib/core/models.py:1795 core/models.py:1795
#: build/lib/core/models.py:1797 core/models.py:1797
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "Ο/Η {name} ζητά πρόσβαση στο έγγραφο: {title}"
#: build/lib/core/models.py:1837 core/models.py:1837
#: build/lib/core/models.py:1839 core/models.py:1839
msgid "Thread"
msgstr "Νήμα"
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1840 core/models.py:1840
msgid "Threads"
msgstr "Νήματα"
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
#: core/models.py:1841 core/models.py:1893
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
#: core/models.py:1843 core/models.py:1895
msgid "Anonymous"
msgstr "Ανώνυμος"
#: build/lib/core/models.py:1888 core/models.py:1888
#: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comment"
msgstr "Σχόλιο"
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1891 core/models.py:1891
msgid "Comments"
msgstr "Σχόλια"
#: build/lib/core/models.py:1938 core/models.py:1938
#: build/lib/core/models.py:1940 core/models.py:1940
msgid "This emoji has already been reacted to this comment."
msgstr "Αυτό το emoji έχει χρησιμοποιηθεί ήδη ως αντίδραση σε αυτό το σχόλιο."
#: build/lib/core/models.py:1942 core/models.py:1942
#: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reaction"
msgstr "Αντίδραση"
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1945 core/models.py:1945
msgid "Reactions"
msgstr "Αντιδράσεις"
#: build/lib/core/models.py:1953 core/models.py:1953
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "email address"
msgstr "διεύθυνση email"
#: build/lib/core/models.py:1972 core/models.py:1972
#: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitation"
msgstr "Πρόσκληση σε έγγραφο"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1975 core/models.py:1975
msgid "Document invitations"
msgstr "Προσκλήσεις εγγράφου"
#: build/lib/core/models.py:1993 core/models.py:1993
#: build/lib/core/models.py:1995 core/models.py:1995
msgid "This email is already associated to a registered user."
msgstr "Αυτό το email σχετίζεται ήδη με έναν εγγεγραμμένο χρήστη."
#: build/lib/impress/settings.py:808 impress/settings.py:808
#: build/lib/impress/settings.py:837 impress/settings.py:837
msgid "Docs AI"
msgstr "Τεχνητή Νοημοσύνη (AI) Docs"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
"PO-Revision-Date: 2026-04-08 13:28\n"
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
"PO-Revision-Date: 2026-04-30 13:05\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -17,28 +17,28 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
#: core/admin.py:166
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:112 core/admin.py:112
#: build/lib/core/admin.py:117 core/admin.py:117
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
#: build/lib/core/admin.py:121 core/admin.py:121
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:176 core/admin.py:176
msgid "Tree structure"
msgstr ""
@@ -62,24 +62,24 @@ msgstr ""
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -375,151 +375,151 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
#: core/models.py:940 core/models.py:1345
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
#: core/models.py:940 core/models.py:1347
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:1346 core/models.py:1346
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Open"
msgstr ""
#: build/lib/core/models.py:1381 core/models.py:1381
#: build/lib/core/models.py:1383 core/models.py:1383
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:1385 core/models.py:1385
#: build/lib/core/models.py:1387 core/models.py:1387
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:1391 core/models.py:1391
#: build/lib/core/models.py:1393 core/models.py:1393
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:1492 core/models.py:1492
#: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:1495 core/models.py:1495
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1499 core/models.py:1499
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1522 core/models.py:1522
#: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1525 core/models.py:1525
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1529 core/models.py:1529
#: build/lib/core/models.py:1531 core/models.py:1531
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1551 core/models.py:1551
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1554 core/models.py:1554
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1558 core/models.py:1558
#: build/lib/core/models.py:1560 core/models.py:1560
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1564 core/models.py:1564
#: build/lib/core/models.py:1566 core/models.py:1566
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1570 core/models.py:1570
#: build/lib/core/models.py:1572 core/models.py:1572
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1721 core/models.py:1721
#: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1724 core/models.py:1724
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1728 core/models.py:1728
#: build/lib/core/models.py:1730 core/models.py:1730
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1785 core/models.py:1785
#: build/lib/core/models.py:1787 core/models.py:1787
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1789 core/models.py:1789
#: build/lib/core/models.py:1791 core/models.py:1791
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1795 core/models.py:1795
#: build/lib/core/models.py:1797 core/models.py:1797
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1837 core/models.py:1837
#: build/lib/core/models.py:1839 core/models.py:1839
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1840 core/models.py:1840
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
#: core/models.py:1841 core/models.py:1893
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
#: core/models.py:1843 core/models.py:1895
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1888 core/models.py:1888
#: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1891 core/models.py:1891
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1938 core/models.py:1938
#: build/lib/core/models.py:1940 core/models.py:1940
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1942 core/models.py:1942
#: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1945 core/models.py:1945
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1953 core/models.py:1953
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1972 core/models.py:1972
#: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1975 core/models.py:1975
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1993 core/models.py:1993
#: build/lib/core/models.py:1995 core/models.py:1995
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:808 impress/settings.py:808
#: build/lib/impress/settings.py:837 impress/settings.py:837
msgid "Docs AI"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
"PO-Revision-Date: 2026-04-08 13:28\n"
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
"PO-Revision-Date: 2026-04-30 13:05\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -17,28 +17,28 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr "Información Personal"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
#: core/admin.py:166
msgid "Permissions"
msgstr "Permisos"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "Fechas importantes"
#: build/lib/core/admin.py:112 core/admin.py:112
#: build/lib/core/admin.py:117 core/admin.py:117
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
#: build/lib/core/admin.py:121 core/admin.py:121
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:176 core/admin.py:176
msgid "Tree structure"
msgstr "Estructura en árbol"
@@ -62,24 +62,24 @@ msgstr "Enmascarado"
msgid "Favorite"
msgstr "Favorito"
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
msgid "A new document was created on your behalf!"
msgstr "¡Un nuevo documento se ha creado por ti!"
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
msgid "You have been granted ownership of a new document:"
msgstr "Se le ha concedido la propiedad de un nuevo documento :"
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
#, python-brace-format
msgid "copy of {title}"
msgstr "copia de {title}"
@@ -375,151 +375,151 @@ msgstr "Documento"
msgid "Documents"
msgstr "Documentos"
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
#: core/models.py:940 core/models.py:1345
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
#: core/models.py:940 core/models.py:1347
msgid "Untitled Document"
msgstr "Documento sin título"
#: build/lib/core/models.py:1346 core/models.py:1346
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Open"
msgstr "Abrir"
#: build/lib/core/models.py:1381 core/models.py:1381
#: build/lib/core/models.py:1383 core/models.py:1383
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "¡{name} ha compartido un documento contigo!"
#: build/lib/core/models.py:1385 core/models.py:1385
#: build/lib/core/models.py:1387 core/models.py:1387
#, 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:1391 core/models.py:1391
#: build/lib/core/models.py:1393 core/models.py:1393
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha compartido un documento contigo: {title}"
#: build/lib/core/models.py:1492 core/models.py:1492
#: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link trace"
msgstr "Traza del enlace de documento/usuario"
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:1495 core/models.py:1495
msgid "Document/user link traces"
msgstr "Trazas del enlace de documento/usuario"
#: build/lib/core/models.py:1499 core/models.py:1499
#: build/lib/core/models.py:1501 core/models.py:1501
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:1522 core/models.py:1522
#: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorite"
msgstr "Documento favorito"
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1525 core/models.py:1525
msgid "Document favorites"
msgstr "Documentos favoritos"
#: build/lib/core/models.py:1529 core/models.py:1529
#: build/lib/core/models.py:1531 core/models.py:1531
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:1551 core/models.py:1551
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relation"
msgstr "Relación documento/usuario"
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1554 core/models.py:1554
msgid "Document/user relations"
msgstr "Relaciones documento/usuario"
#: build/lib/core/models.py:1558 core/models.py:1558
#: build/lib/core/models.py:1560 core/models.py:1560
msgid "This user is already in this document."
msgstr "Este usuario ya forma parte del documento."
#: build/lib/core/models.py:1564 core/models.py:1564
#: build/lib/core/models.py:1566 core/models.py:1566
msgid "This team is already in this document."
msgstr "Este equipo ya forma parte del documento."
#: build/lib/core/models.py:1570 core/models.py:1570
#: build/lib/core/models.py:1572 core/models.py:1572
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:1721 core/models.py:1721
#: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for access"
msgstr "Solicitud de acceso"
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1724 core/models.py:1724
msgid "Document ask for accesses"
msgstr "Solicitud de accesos"
#: build/lib/core/models.py:1728 core/models.py:1728
#: build/lib/core/models.py:1730 core/models.py:1730
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:1785 core/models.py:1785
#: build/lib/core/models.py:1787 core/models.py:1787
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "¡{name} desea acceder a un documento!"
#: build/lib/core/models.py:1789 core/models.py:1789
#: build/lib/core/models.py:1791 core/models.py:1791
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} desea acceso al siguiente documento:"
#: build/lib/core/models.py:1795 core/models.py:1795
#: build/lib/core/models.py:1797 core/models.py:1797
#, 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:1837 core/models.py:1837
#: build/lib/core/models.py:1839 core/models.py:1839
msgid "Thread"
msgstr "Thread"
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1840 core/models.py:1840
msgid "Threads"
msgstr "Threads"
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
#: core/models.py:1841 core/models.py:1893
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
#: core/models.py:1843 core/models.py:1895
msgid "Anonymous"
msgstr "Anónimo"
#: build/lib/core/models.py:1888 core/models.py:1888
#: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comment"
msgstr "Comentario"
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1891 core/models.py:1891
msgid "Comments"
msgstr "Comentarios"
#: build/lib/core/models.py:1938 core/models.py:1938
#: build/lib/core/models.py:1940 core/models.py:1940
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1942 core/models.py:1942
#: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reaction"
msgstr "Reacción"
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1945 core/models.py:1945
msgid "Reactions"
msgstr "Reacciones"
#: build/lib/core/models.py:1953 core/models.py:1953
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "email address"
msgstr "dirección de correo electrónico"
#: build/lib/core/models.py:1972 core/models.py:1972
#: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitation"
msgstr "Invitación al documento"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1975 core/models.py:1975
msgid "Document invitations"
msgstr "Invitaciones a documentos"
#: build/lib/core/models.py:1993 core/models.py:1993
#: build/lib/core/models.py:1995 core/models.py:1995
msgid "This email is already associated to a registered user."
msgstr "Este correo electrónico está asociado a un usuario registrado."
#: build/lib/impress/settings.py:808 impress/settings.py:808
#: build/lib/impress/settings.py:837 impress/settings.py:837
msgid "Docs AI"
msgstr "Docs AI"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
"PO-Revision-Date: 2026-04-08 13:28\n"
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
"PO-Revision-Date: 2026-04-30 13:05\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -17,28 +17,28 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr "Infos Personnelles"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
#: core/admin.py:166
msgid "Permissions"
msgstr "Permissions"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/core/admin.py:112 core/admin.py:112
#: build/lib/core/admin.py:117 core/admin.py:117
msgid "Import job created and queued."
msgstr "Tâche d'importation créée et mise en file d'attente."
#: build/lib/core/admin.py:116 core/admin.py:116
#: build/lib/core/admin.py:121 core/admin.py:121
msgid "Process selected user reconciliations"
msgstr "Traiter les rapprochements de l'utilisateur sélectionné"
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:176 core/admin.py:176
msgid "Tree structure"
msgstr "Arborescence"
@@ -62,24 +62,24 @@ msgstr "Masqué"
msgid "Favorite"
msgstr "Favoris"
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
msgid "A new document was created on your behalf!"
msgstr "Un nouveau document a été créé pour vous !"
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
msgid "You have been granted ownership of a new document:"
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
msgid "This field is required."
msgstr "Ce champ est obligatoire."
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "La portée du lien '%(link_reach)s' n'est pas autorisée en fonction de la configuration du document parent."
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
#, python-brace-format
msgid "copy of {title}"
msgstr "copie de {title}"
@@ -382,151 +382,151 @@ msgstr "Document"
msgid "Documents"
msgstr "Documents"
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
#: core/models.py:940 core/models.py:1345
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
#: core/models.py:940 core/models.py:1347
msgid "Untitled Document"
msgstr "Document sans titre"
#: build/lib/core/models.py:1346 core/models.py:1346
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Open"
msgstr "Ouvrir"
#: build/lib/core/models.py:1381 core/models.py:1381
#: build/lib/core/models.py:1383 core/models.py:1383
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:1385 core/models.py:1385
#: build/lib/core/models.py:1387 core/models.py:1387
#, 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:1391 core/models.py:1391
#: build/lib/core/models.py:1393 core/models.py:1393
#, 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:1492 core/models.py:1492
#: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link trace"
msgstr "Trace du lien document/utilisateur"
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:1495 core/models.py:1495
msgid "Document/user link traces"
msgstr "Traces du lien document/utilisateur"
#: build/lib/core/models.py:1499 core/models.py:1499
#: build/lib/core/models.py:1501 core/models.py:1501
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:1522 core/models.py:1522
#: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorite"
msgstr "Document favori"
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1525 core/models.py:1525
msgid "Document favorites"
msgstr "Documents favoris"
#: build/lib/core/models.py:1529 core/models.py:1529
#: build/lib/core/models.py:1531 core/models.py:1531
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:1551 core/models.py:1551
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relation"
msgstr "Relation document/utilisateur"
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1554 core/models.py:1554
msgid "Document/user relations"
msgstr "Relations document/utilisateur"
#: build/lib/core/models.py:1558 core/models.py:1558
#: build/lib/core/models.py:1560 core/models.py:1560
msgid "This user is already in this document."
msgstr "Cet utilisateur est déjà dans ce document."
#: build/lib/core/models.py:1564 core/models.py:1564
#: build/lib/core/models.py:1566 core/models.py:1566
msgid "This team is already in this document."
msgstr "Cette équipe est déjà dans ce document."
#: build/lib/core/models.py:1570 core/models.py:1570
#: build/lib/core/models.py:1572 core/models.py:1572
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:1721 core/models.py:1721
#: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for access"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1724 core/models.py:1724
msgid "Document ask for accesses"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1728 core/models.py:1728
#: build/lib/core/models.py:1730 core/models.py:1730
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:1785 core/models.py:1785
#: build/lib/core/models.py:1787 core/models.py:1787
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} souhaiterait accéder au document suivant !"
#: build/lib/core/models.py:1789 core/models.py:1789
#: build/lib/core/models.py:1791 core/models.py:1791
#, 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:1795 core/models.py:1795
#: build/lib/core/models.py:1797 core/models.py:1797
#, 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:1837 core/models.py:1837
#: build/lib/core/models.py:1839 core/models.py:1839
msgid "Thread"
msgstr "Conversation"
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1840 core/models.py:1840
msgid "Threads"
msgstr "Conversations"
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
#: core/models.py:1841 core/models.py:1893
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
#: core/models.py:1843 core/models.py:1895
msgid "Anonymous"
msgstr "Anonyme"
#: build/lib/core/models.py:1888 core/models.py:1888
#: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comment"
msgstr "Commentaire"
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1891 core/models.py:1891
msgid "Comments"
msgstr "Commentaires"
#: build/lib/core/models.py:1938 core/models.py:1938
#: build/lib/core/models.py:1940 core/models.py:1940
msgid "This emoji has already been reacted to this comment."
msgstr "Cet émoji a déjà été réagi à ce commentaire."
#: build/lib/core/models.py:1942 core/models.py:1942
#: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reaction"
msgstr "Réaction"
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1945 core/models.py:1945
msgid "Reactions"
msgstr "Réactions"
#: build/lib/core/models.py:1953 core/models.py:1953
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "email address"
msgstr "adresse e-mail"
#: build/lib/core/models.py:1972 core/models.py:1972
#: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitation"
msgstr "Invitation à un document"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1975 core/models.py:1975
msgid "Document invitations"
msgstr "Invitations à un document"
#: build/lib/core/models.py:1993 core/models.py:1993
#: build/lib/core/models.py:1995 core/models.py:1995
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."
#: build/lib/impress/settings.py:808 impress/settings.py:808
#: build/lib/impress/settings.py:837 impress/settings.py:837
msgid "Docs AI"
msgstr "Docs IA"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
"PO-Revision-Date: 2026-04-08 13:28\n"
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
"PO-Revision-Date: 2026-04-30 13:05\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -17,28 +17,28 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr "Informazioni personali"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
#: core/admin.py:166
msgid "Permissions"
msgstr "Permessi"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "Date importanti"
#: build/lib/core/admin.py:112 core/admin.py:112
#: build/lib/core/admin.py:117 core/admin.py:117
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
#: build/lib/core/admin.py:121 core/admin.py:121
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:176 core/admin.py:176
msgid "Tree structure"
msgstr "Struttura ad albero"
@@ -62,24 +62,24 @@ msgstr ""
msgid "Favorite"
msgstr "Preferiti"
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
msgid "A new document was created on your behalf!"
msgstr "Un nuovo documento è stato creato a tuo nome!"
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
msgid "You have been granted ownership of a new document:"
msgstr "Sei ora proprietario di un nuovo documento:"
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
#, python-brace-format
msgid "copy of {title}"
msgstr "copia di {title}"
@@ -375,151 +375,151 @@ msgstr "Documento"
msgid "Documents"
msgstr "Documenti"
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
#: core/models.py:940 core/models.py:1345
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
#: core/models.py:940 core/models.py:1347
msgid "Untitled Document"
msgstr "Documento senza titolo"
#: build/lib/core/models.py:1346 core/models.py:1346
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Open"
msgstr "Apri"
#: build/lib/core/models.py:1381 core/models.py:1381
#: build/lib/core/models.py:1383 core/models.py:1383
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ha condiviso un documento con te!"
#: build/lib/core/models.py:1385 core/models.py:1385
#: build/lib/core/models.py:1387 core/models.py:1387
#, 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:1391 core/models.py:1391
#: build/lib/core/models.py:1393 core/models.py:1393
#, 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:1492 core/models.py:1492
#: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:1495 core/models.py:1495
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1499 core/models.py:1499
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1522 core/models.py:1522
#: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorite"
msgstr "Documento preferito"
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1525 core/models.py:1525
msgid "Document favorites"
msgstr "Documenti preferiti"
#: build/lib/core/models.py:1529 core/models.py:1529
#: build/lib/core/models.py:1531 core/models.py:1531
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1551 core/models.py:1551
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1554 core/models.py:1554
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1558 core/models.py:1558
#: build/lib/core/models.py:1560 core/models.py:1560
msgid "This user is already in this document."
msgstr "Questo utente è già presente in questo documento."
#: build/lib/core/models.py:1564 core/models.py:1564
#: build/lib/core/models.py:1566 core/models.py:1566
msgid "This team is already in this document."
msgstr "Questo team è già presente in questo documento."
#: build/lib/core/models.py:1570 core/models.py:1570
#: build/lib/core/models.py:1572 core/models.py:1572
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1721 core/models.py:1721
#: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1724 core/models.py:1724
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1728 core/models.py:1728
#: build/lib/core/models.py:1730 core/models.py:1730
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1785 core/models.py:1785
#: build/lib/core/models.py:1787 core/models.py:1787
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1789 core/models.py:1789
#: build/lib/core/models.py:1791 core/models.py:1791
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1795 core/models.py:1795
#: build/lib/core/models.py:1797 core/models.py:1797
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1837 core/models.py:1837
#: build/lib/core/models.py:1839 core/models.py:1839
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1840 core/models.py:1840
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
#: core/models.py:1841 core/models.py:1893
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
#: core/models.py:1843 core/models.py:1895
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1888 core/models.py:1888
#: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1891 core/models.py:1891
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1938 core/models.py:1938
#: build/lib/core/models.py:1940 core/models.py:1940
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1942 core/models.py:1942
#: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1945 core/models.py:1945
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1953 core/models.py:1953
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "email address"
msgstr "indirizzo e-mail"
#: build/lib/core/models.py:1972 core/models.py:1972
#: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitation"
msgstr "Invito al documento"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1975 core/models.py:1975
msgid "Document invitations"
msgstr "Inviti al documento"
#: build/lib/core/models.py:1993 core/models.py:1993
#: build/lib/core/models.py:1995 core/models.py:1995
msgid "This email is already associated to a registered user."
msgstr "Questa email è già associata a un utente registrato."
#: build/lib/impress/settings.py:808 impress/settings.py:808
#: build/lib/impress/settings.py:837 impress/settings.py:837
msgid "Docs AI"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
"PO-Revision-Date: 2026-04-08 13:28\n"
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
"PO-Revision-Date: 2026-04-30 13:05\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -17,28 +17,28 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
#: core/admin.py:166
msgid "Permissions"
msgstr "Machtigingen"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "Belangrijke data"
#: build/lib/core/admin.py:112 core/admin.py:112
#: build/lib/core/admin.py:117 core/admin.py:117
msgid "Import job created and queued."
msgstr "Import taak gemaakt en in de wachtrij geplaatst."
#: build/lib/core/admin.py:116 core/admin.py:116
#: build/lib/core/admin.py:121 core/admin.py:121
msgid "Process selected user reconciliations"
msgstr "Verwerk geselecteerde gebruikers samenvoeging"
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:176 core/admin.py:176
msgid "Tree structure"
msgstr "Boomstructuur"
@@ -62,24 +62,24 @@ msgstr "Gemaskeerd"
msgid "Favorite"
msgstr "Favoriet"
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
msgid "A new document was created on your behalf!"
msgstr "Een nieuw document is namens u gemaakt!"
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
msgid "You have been granted ownership of a new document:"
msgstr "U heeft eigenaarschap van een nieuw document gekregen:"
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
msgid "This field is required."
msgstr "Dit veld is verplicht."
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Link bereik '%(link_reach)s' is niet toegestaan op basis van bovenliggende documentconfiguratie."
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
#, python-brace-format
msgid "copy of {title}"
msgstr "kopie van {title}"
@@ -382,151 +382,151 @@ msgstr "Document"
msgid "Documents"
msgstr "Documenten"
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
#: core/models.py:940 core/models.py:1345
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
#: core/models.py:940 core/models.py:1347
msgid "Untitled Document"
msgstr "Naamloos Document"
#: build/lib/core/models.py:1346 core/models.py:1346
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Open"
msgstr "Open"
#: build/lib/core/models.py:1381 core/models.py:1381
#: build/lib/core/models.py:1383 core/models.py:1383
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} heeft een document met u gedeeld!"
#: build/lib/core/models.py:1385 core/models.py:1385
#: build/lib/core/models.py:1387 core/models.py:1387
#, 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:1391 core/models.py:1391
#: build/lib/core/models.py:1393 core/models.py:1393
#, 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:1492 core/models.py:1492
#: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link trace"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:1495 core/models.py:1495
msgid "Document/user link traces"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:1499 core/models.py:1499
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "A link trace already exists for this document/user."
msgstr "Een link bestaat al voor dit document/deze gebruiker."
#: build/lib/core/models.py:1522 core/models.py:1522
#: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorite"
msgstr "Document favoriet"
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1525 core/models.py:1525
msgid "Document favorites"
msgstr "Document favorieten"
#: build/lib/core/models.py:1529 core/models.py:1529
#: build/lib/core/models.py:1531 core/models.py:1531
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dit document is al in gebruik als favoriet door dezelfde gebruiker."
#: build/lib/core/models.py:1551 core/models.py:1551
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relation"
msgstr "Document/gebruiker relatie"
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1554 core/models.py:1554
msgid "Document/user relations"
msgstr "Document/gebruiker relaties"
#: build/lib/core/models.py:1558 core/models.py:1558
#: build/lib/core/models.py:1560 core/models.py:1560
msgid "This user is already in this document."
msgstr "De gebruiker bestaat al in dit document."
#: build/lib/core/models.py:1564 core/models.py:1564
#: build/lib/core/models.py:1566 core/models.py:1566
msgid "This team is already in this document."
msgstr "Dit team bestaat al in dit document."
#: build/lib/core/models.py:1570 core/models.py:1570
#: build/lib/core/models.py:1572 core/models.py:1572
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:1721 core/models.py:1721
#: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for access"
msgstr "Document verzoekt om toegang"
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1724 core/models.py:1724
msgid "Document ask for accesses"
msgstr "Document verzoekt om toegangen"
#: build/lib/core/models.py:1728 core/models.py:1728
#: build/lib/core/models.py:1730 core/models.py:1730
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:1785 core/models.py:1785
#: build/lib/core/models.py:1787 core/models.py:1787
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} verzoekt toegang tot een document!"
#: build/lib/core/models.py:1789 core/models.py:1789
#: build/lib/core/models.py:1791 core/models.py:1791
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} verzoekt toegang tot het volgende document:"
#: build/lib/core/models.py:1795 core/models.py:1795
#: build/lib/core/models.py:1797 core/models.py:1797
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} verzoekt toegang tot het document: {title}"
#: build/lib/core/models.py:1837 core/models.py:1837
#: build/lib/core/models.py:1839 core/models.py:1839
msgid "Thread"
msgstr "Kanaal"
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1840 core/models.py:1840
msgid "Threads"
msgstr "Kanalen"
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
#: core/models.py:1841 core/models.py:1893
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
#: core/models.py:1843 core/models.py:1895
msgid "Anonymous"
msgstr "Anoniem"
#: build/lib/core/models.py:1888 core/models.py:1888
#: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comment"
msgstr "Reactie"
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1891 core/models.py:1891
msgid "Comments"
msgstr "Reacties"
#: build/lib/core/models.py:1938 core/models.py:1938
#: build/lib/core/models.py:1940 core/models.py:1940
msgid "This emoji has already been reacted to this comment."
msgstr "Deze emoji is al op deze opmerking gereageerd."
#: build/lib/core/models.py:1942 core/models.py:1942
#: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reaction"
msgstr "Reactie"
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1945 core/models.py:1945
msgid "Reactions"
msgstr "Reacties"
#: build/lib/core/models.py:1953 core/models.py:1953
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "email address"
msgstr "e-mailadres"
#: build/lib/core/models.py:1972 core/models.py:1972
#: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitation"
msgstr "Document uitnodiging"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1975 core/models.py:1975
msgid "Document invitations"
msgstr "Document uitnodigingen"
#: build/lib/core/models.py:1993 core/models.py:1993
#: build/lib/core/models.py:1995 core/models.py:1995
msgid "This email is already associated to a registered user."
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
#: build/lib/impress/settings.py:808 impress/settings.py:808
#: build/lib/impress/settings.py:837 impress/settings.py:837
msgid "Docs AI"
msgstr "Docs AI"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
"PO-Revision-Date: 2026-04-08 13:28\n"
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
"PO-Revision-Date: 2026-04-30 13:05\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
@@ -17,28 +17,28 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr "Informações Pessoais"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
#: core/admin.py:166
msgid "Permissions"
msgstr "Permissões"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "Datas importantes"
#: build/lib/core/admin.py:112 core/admin.py:112
#: build/lib/core/admin.py:117 core/admin.py:117
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
#: build/lib/core/admin.py:121 core/admin.py:121
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:176 core/admin.py:176
msgid "Tree structure"
msgstr "Estrutura de árvore"
@@ -62,24 +62,24 @@ msgstr ""
msgid "Favorite"
msgstr "Favorito"
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
msgid "A new document was created on your behalf!"
msgstr "Um novo documento foi criado em seu nome!"
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
msgid "You have been granted ownership of a new document:"
msgstr "A propriedade de um novo documento foi concedida a você:"
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
#, python-brace-format
msgid "copy of {title}"
msgstr "cópia de {title}"
@@ -375,151 +375,151 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
#: core/models.py:940 core/models.py:1345
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
#: core/models.py:940 core/models.py:1347
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:1346 core/models.py:1346
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Open"
msgstr "Abrir"
#: build/lib/core/models.py:1381 core/models.py:1381
#: build/lib/core/models.py:1383 core/models.py:1383
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:1385 core/models.py:1385
#: build/lib/core/models.py:1387 core/models.py:1387
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:1391 core/models.py:1391
#: build/lib/core/models.py:1393 core/models.py:1393
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:1492 core/models.py:1492
#: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:1495 core/models.py:1495
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1499 core/models.py:1499
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1522 core/models.py:1522
#: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1525 core/models.py:1525
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1529 core/models.py:1529
#: build/lib/core/models.py:1531 core/models.py:1531
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1551 core/models.py:1551
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1554 core/models.py:1554
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1558 core/models.py:1558
#: build/lib/core/models.py:1560 core/models.py:1560
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1564 core/models.py:1564
#: build/lib/core/models.py:1566 core/models.py:1566
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1570 core/models.py:1570
#: build/lib/core/models.py:1572 core/models.py:1572
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1721 core/models.py:1721
#: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1724 core/models.py:1724
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1728 core/models.py:1728
#: build/lib/core/models.py:1730 core/models.py:1730
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1785 core/models.py:1785
#: build/lib/core/models.py:1787 core/models.py:1787
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1789 core/models.py:1789
#: build/lib/core/models.py:1791 core/models.py:1791
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1795 core/models.py:1795
#: build/lib/core/models.py:1797 core/models.py:1797
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1837 core/models.py:1837
#: build/lib/core/models.py:1839 core/models.py:1839
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1840 core/models.py:1840
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
#: core/models.py:1841 core/models.py:1893
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
#: core/models.py:1843 core/models.py:1895
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1888 core/models.py:1888
#: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1891 core/models.py:1891
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1938 core/models.py:1938
#: build/lib/core/models.py:1940 core/models.py:1940
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1942 core/models.py:1942
#: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1945 core/models.py:1945
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1953 core/models.py:1953
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1972 core/models.py:1972
#: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1975 core/models.py:1975
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1993 core/models.py:1993
#: build/lib/core/models.py:1995 core/models.py:1995
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:808 impress/settings.py:808
#: build/lib/impress/settings.py:837 impress/settings.py:837
msgid "Docs AI"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
"PO-Revision-Date: 2026-04-08 13:28\n"
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
"PO-Revision-Date: 2026-04-30 13:05\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -17,28 +17,28 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr "Личная информация"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
#: core/admin.py:166
msgid "Permissions"
msgstr "Разрешения"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "Важные даты"
#: build/lib/core/admin.py:112 core/admin.py:112
#: build/lib/core/admin.py:117 core/admin.py:117
msgid "Import job created and queued."
msgstr "Задание по импорту создано и поставлено в очередь."
#: build/lib/core/admin.py:116 core/admin.py:116
#: build/lib/core/admin.py:121 core/admin.py:121
msgid "Process selected user reconciliations"
msgstr "Обработка выбранных пользовательских сверок"
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:176 core/admin.py:176
msgid "Tree structure"
msgstr "Древовидная структура"
@@ -62,24 +62,24 @@ msgstr "Скрытый"
msgid "Favorite"
msgstr "Избранное"
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
msgid "A new document was created on your behalf!"
msgstr "Новый документ был создан от вашего имени!"
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
msgid "You have been granted ownership of a new document:"
msgstr "Вы назначены владельцем для нового документа:"
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
msgid "This field is required."
msgstr "Это поле обязательное."
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Доступ по ссылке '%(link_reach)s' запрещён в соответствии с настройками родительского документа."
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
#, python-brace-format
msgid "copy of {title}"
msgstr "копия {title}"
@@ -382,151 +382,151 @@ msgstr "Документ"
msgid "Documents"
msgstr "Документы"
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
#: core/models.py:940 core/models.py:1345
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
#: core/models.py:940 core/models.py:1347
msgid "Untitled Document"
msgstr "Безымянный документ"
#: build/lib/core/models.py:1346 core/models.py:1346
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Open"
msgstr "Открыть"
#: build/lib/core/models.py:1381 core/models.py:1381
#: build/lib/core/models.py:1383 core/models.py:1383
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} делится с вами документом!"
#: build/lib/core/models.py:1385 core/models.py:1385
#: build/lib/core/models.py:1387 core/models.py:1387
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
#: build/lib/core/models.py:1391 core/models.py:1391
#: build/lib/core/models.py:1393 core/models.py:1393
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} делится с вами документом: {title}"
#: build/lib/core/models.py:1492 core/models.py:1492
#: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link trace"
msgstr "Трассировка связи документ/пользователь"
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:1495 core/models.py:1495
msgid "Document/user link traces"
msgstr "Трассировка связей документ/пользователь"
#: build/lib/core/models.py:1499 core/models.py:1499
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "A link trace already exists for this document/user."
msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
#: build/lib/core/models.py:1522 core/models.py:1522
#: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorite"
msgstr "Избранный документ"
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1525 core/models.py:1525
msgid "Document favorites"
msgstr "Избранные документы"
#: build/lib/core/models.py:1529 core/models.py:1529
#: build/lib/core/models.py:1531 core/models.py:1531
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Этот документ уже помечен как избранный для этого пользователя."
#: build/lib/core/models.py:1551 core/models.py:1551
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relation"
msgstr "Отношение документ/пользователь"
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1554 core/models.py:1554
msgid "Document/user relations"
msgstr "Отношения документ/пользователь"
#: build/lib/core/models.py:1558 core/models.py:1558
#: build/lib/core/models.py:1560 core/models.py:1560
msgid "This user is already in this document."
msgstr "Этот пользователь уже имеет доступ к этому документу."
#: build/lib/core/models.py:1564 core/models.py:1564
#: build/lib/core/models.py:1566 core/models.py:1566
msgid "This team is already in this document."
msgstr "Эта команда уже имеет доступ к этому документу."
#: build/lib/core/models.py:1570 core/models.py:1570
#: build/lib/core/models.py:1572 core/models.py:1572
msgid "Either user or team must be set, not both."
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
#: build/lib/core/models.py:1721 core/models.py:1721
#: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for access"
msgstr "Документ запрашивает доступ"
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1724 core/models.py:1724
msgid "Document ask for accesses"
msgstr "Документ запрашивает доступы"
#: build/lib/core/models.py:1728 core/models.py:1728
#: build/lib/core/models.py:1730 core/models.py:1730
msgid "This user has already asked for access to this document."
msgstr "Этот пользователь уже запросил доступ к этому документу."
#: build/lib/core/models.py:1785 core/models.py:1785
#: build/lib/core/models.py:1787 core/models.py:1787
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хочет получить доступ к документу!"
#: build/lib/core/models.py:1789 core/models.py:1789
#: build/lib/core/models.py:1791 core/models.py:1791
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} хочет получить доступ к следующему документу:"
#: build/lib/core/models.py:1795 core/models.py:1795
#: build/lib/core/models.py:1797 core/models.py:1797
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запрашивает доступ к документу: {title}"
#: build/lib/core/models.py:1837 core/models.py:1837
#: build/lib/core/models.py:1839 core/models.py:1839
msgid "Thread"
msgstr "Обсуждение"
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1840 core/models.py:1840
msgid "Threads"
msgstr "Обсуждения"
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
#: core/models.py:1841 core/models.py:1893
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
#: core/models.py:1843 core/models.py:1895
msgid "Anonymous"
msgstr "Аноним"
#: build/lib/core/models.py:1888 core/models.py:1888
#: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comment"
msgstr "Комментарий"
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1891 core/models.py:1891
msgid "Comments"
msgstr "Комментарии"
#: build/lib/core/models.py:1938 core/models.py:1938
#: build/lib/core/models.py:1940 core/models.py:1940
msgid "This emoji has already been reacted to this comment."
msgstr "Этот эмодзи уже использован в этом комментарии."
#: build/lib/core/models.py:1942 core/models.py:1942
#: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reaction"
msgstr "Реакция"
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1945 core/models.py:1945
msgid "Reactions"
msgstr "Реакции"
#: build/lib/core/models.py:1953 core/models.py:1953
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "email address"
msgstr "адрес электронной почты"
#: build/lib/core/models.py:1972 core/models.py:1972
#: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitation"
msgstr "Приглашение для документа"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1975 core/models.py:1975
msgid "Document invitations"
msgstr "Приглашения для документов"
#: build/lib/core/models.py:1993 core/models.py:1993
#: build/lib/core/models.py:1995 core/models.py:1995
msgid "This email is already associated to a registered user."
msgstr "Этот адрес уже связан с зарегистрированным пользователем."
#: build/lib/impress/settings.py:808 impress/settings.py:808
#: build/lib/impress/settings.py:837 impress/settings.py:837
msgid "Docs AI"
msgstr "Docs ИИ"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
"PO-Revision-Date: 2026-04-08 13:28\n"
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
"PO-Revision-Date: 2026-04-30 13:05\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
@@ -17,28 +17,28 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr "Osebni podatki"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
#: core/admin.py:166
msgid "Permissions"
msgstr "Dovoljenja"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "Pomembni datumi"
#: build/lib/core/admin.py:112 core/admin.py:112
#: build/lib/core/admin.py:117 core/admin.py:117
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
#: build/lib/core/admin.py:121 core/admin.py:121
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:176 core/admin.py:176
msgid "Tree structure"
msgstr "Drevesna struktura"
@@ -62,24 +62,24 @@ msgstr ""
msgid "Favorite"
msgstr "Priljubljena"
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
msgid "A new document was created on your behalf!"
msgstr "Nov dokument je bil ustvarjen v vašem imenu!"
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
msgid "You have been granted ownership of a new document:"
msgstr "Dodeljeno vam je bilo lastništvo nad novim dokumentom:"
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -375,151 +375,151 @@ msgstr "Dokument"
msgid "Documents"
msgstr "Dokumenti"
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
#: core/models.py:940 core/models.py:1345
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
#: core/models.py:940 core/models.py:1347
msgid "Untitled Document"
msgstr "Dokument brez naslova"
#: build/lib/core/models.py:1346 core/models.py:1346
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Open"
msgstr "Odpri"
#: build/lib/core/models.py:1381 core/models.py:1381
#: build/lib/core/models.py:1383 core/models.py:1383
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} je delil dokument z vami!"
#: build/lib/core/models.py:1385 core/models.py:1385
#: build/lib/core/models.py:1387 core/models.py:1387
#, 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:1391 core/models.py:1391
#: build/lib/core/models.py:1393 core/models.py:1393
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} je delil dokument z vami: {title}"
#: build/lib/core/models.py:1492 core/models.py:1492
#: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link trace"
msgstr "Dokument/sled povezave uporabnika"
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:1495 core/models.py:1495
msgid "Document/user link traces"
msgstr "Sledi povezav dokumenta/uporabnika"
#: build/lib/core/models.py:1499 core/models.py:1499
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "A link trace already exists for this document/user."
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
#: build/lib/core/models.py:1522 core/models.py:1522
#: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorite"
msgstr "Priljubljeni dokument"
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1525 core/models.py:1525
msgid "Document favorites"
msgstr "Priljubljeni dokumenti"
#: build/lib/core/models.py:1529 core/models.py:1529
#: build/lib/core/models.py:1531 core/models.py:1531
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:1551 core/models.py:1551
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relation"
msgstr "Odnos dokument/uporabnik"
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1554 core/models.py:1554
msgid "Document/user relations"
msgstr "Odnosi dokument/uporabnik"
#: build/lib/core/models.py:1558 core/models.py:1558
#: build/lib/core/models.py:1560 core/models.py:1560
msgid "This user is already in this document."
msgstr "Ta uporabnik je že v tem dokumentu."
#: build/lib/core/models.py:1564 core/models.py:1564
#: build/lib/core/models.py:1566 core/models.py:1566
msgid "This team is already in this document."
msgstr "Ta ekipa je že v tem dokumentu."
#: build/lib/core/models.py:1570 core/models.py:1570
#: build/lib/core/models.py:1572 core/models.py:1572
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:1721 core/models.py:1721
#: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1724 core/models.py:1724
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1728 core/models.py:1728
#: build/lib/core/models.py:1730 core/models.py:1730
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1785 core/models.py:1785
#: build/lib/core/models.py:1787 core/models.py:1787
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1789 core/models.py:1789
#: build/lib/core/models.py:1791 core/models.py:1791
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1795 core/models.py:1795
#: build/lib/core/models.py:1797 core/models.py:1797
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1837 core/models.py:1837
#: build/lib/core/models.py:1839 core/models.py:1839
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1840 core/models.py:1840
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
#: core/models.py:1841 core/models.py:1893
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
#: core/models.py:1843 core/models.py:1895
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1888 core/models.py:1888
#: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1891 core/models.py:1891
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1938 core/models.py:1938
#: build/lib/core/models.py:1940 core/models.py:1940
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1942 core/models.py:1942
#: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1945 core/models.py:1945
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1953 core/models.py:1953
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "email address"
msgstr "elektronski naslov"
#: build/lib/core/models.py:1972 core/models.py:1972
#: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitation"
msgstr "Vabilo na dokument"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1975 core/models.py:1975
msgid "Document invitations"
msgstr "Vabila na dokument"
#: build/lib/core/models.py:1993 core/models.py:1993
#: build/lib/core/models.py:1995 core/models.py:1995
msgid "This email is already associated to a registered user."
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."
#: build/lib/impress/settings.py:808 impress/settings.py:808
#: build/lib/impress/settings.py:837 impress/settings.py:837
msgid "Docs AI"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
"PO-Revision-Date: 2026-04-08 13:28\n"
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
"PO-Revision-Date: 2026-04-30 13:05\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
@@ -17,28 +17,28 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr "Personuppgifter"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
#: core/admin.py:166
msgid "Permissions"
msgstr "Behörigheter"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "Viktiga datum"
#: build/lib/core/admin.py:112 core/admin.py:112
#: build/lib/core/admin.py:117 core/admin.py:117
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
#: build/lib/core/admin.py:121 core/admin.py:121
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:176 core/admin.py:176
msgid "Tree structure"
msgstr ""
@@ -62,24 +62,24 @@ msgstr ""
msgid "Favorite"
msgstr "Favoriter"
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
msgid "A new document was created on your behalf!"
msgstr "Ett nytt dokument skapades åt dig!"
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
msgid "You have been granted ownership of a new document:"
msgstr "Du har beviljats äganderätt till ett nytt dokument:"
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -375,151 +375,151 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
#: core/models.py:940 core/models.py:1345
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
#: core/models.py:940 core/models.py:1347
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:1346 core/models.py:1346
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Open"
msgstr "Öppna"
#: build/lib/core/models.py:1381 core/models.py:1381
#: build/lib/core/models.py:1383 core/models.py:1383
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:1385 core/models.py:1385
#: build/lib/core/models.py:1387 core/models.py:1387
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:1391 core/models.py:1391
#: build/lib/core/models.py:1393 core/models.py:1393
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:1492 core/models.py:1492
#: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:1495 core/models.py:1495
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1499 core/models.py:1499
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1522 core/models.py:1522
#: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1525 core/models.py:1525
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1529 core/models.py:1529
#: build/lib/core/models.py:1531 core/models.py:1531
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1551 core/models.py:1551
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1554 core/models.py:1554
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1558 core/models.py:1558
#: build/lib/core/models.py:1560 core/models.py:1560
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1564 core/models.py:1564
#: build/lib/core/models.py:1566 core/models.py:1566
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1570 core/models.py:1570
#: build/lib/core/models.py:1572 core/models.py:1572
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1721 core/models.py:1721
#: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1724 core/models.py:1724
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1728 core/models.py:1728
#: build/lib/core/models.py:1730 core/models.py:1730
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1785 core/models.py:1785
#: build/lib/core/models.py:1787 core/models.py:1787
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1789 core/models.py:1789
#: build/lib/core/models.py:1791 core/models.py:1791
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1795 core/models.py:1795
#: build/lib/core/models.py:1797 core/models.py:1797
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1837 core/models.py:1837
#: build/lib/core/models.py:1839 core/models.py:1839
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1840 core/models.py:1840
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
#: core/models.py:1841 core/models.py:1893
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
#: core/models.py:1843 core/models.py:1895
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1888 core/models.py:1888
#: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1891 core/models.py:1891
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1938 core/models.py:1938
#: build/lib/core/models.py:1940 core/models.py:1940
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1942 core/models.py:1942
#: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1945 core/models.py:1945
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1953 core/models.py:1953
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "email address"
msgstr "e-postadress"
#: build/lib/core/models.py:1972 core/models.py:1972
#: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitation"
msgstr "Bjud in dokument"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1975 core/models.py:1975
msgid "Document invitations"
msgstr "Inbjudningar dokument"
#: build/lib/core/models.py:1993 core/models.py:1993
#: build/lib/core/models.py:1995 core/models.py:1995
msgid "This email is already associated to a registered user."
msgstr "Denna e-postadress är redan associerad med en registrerad användare."
#: build/lib/impress/settings.py:808 impress/settings.py:808
#: build/lib/impress/settings.py:837 impress/settings.py:837
msgid "Docs AI"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
"PO-Revision-Date: 2026-04-08 13:28\n"
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
"PO-Revision-Date: 2026-04-30 13:05\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
@@ -17,28 +17,28 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
#: core/admin.py:166
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:112 core/admin.py:112
#: build/lib/core/admin.py:117 core/admin.py:117
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
#: build/lib/core/admin.py:121 core/admin.py:121
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:176 core/admin.py:176
msgid "Tree structure"
msgstr ""
@@ -62,24 +62,24 @@ msgstr ""
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -375,151 +375,151 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
#: core/models.py:940 core/models.py:1345
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
#: core/models.py:940 core/models.py:1347
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:1346 core/models.py:1346
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Open"
msgstr ""
#: build/lib/core/models.py:1381 core/models.py:1381
#: build/lib/core/models.py:1383 core/models.py:1383
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:1385 core/models.py:1385
#: build/lib/core/models.py:1387 core/models.py:1387
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:1391 core/models.py:1391
#: build/lib/core/models.py:1393 core/models.py:1393
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:1492 core/models.py:1492
#: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:1495 core/models.py:1495
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1499 core/models.py:1499
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1522 core/models.py:1522
#: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1525 core/models.py:1525
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1529 core/models.py:1529
#: build/lib/core/models.py:1531 core/models.py:1531
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1551 core/models.py:1551
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1554 core/models.py:1554
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1558 core/models.py:1558
#: build/lib/core/models.py:1560 core/models.py:1560
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1564 core/models.py:1564
#: build/lib/core/models.py:1566 core/models.py:1566
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1570 core/models.py:1570
#: build/lib/core/models.py:1572 core/models.py:1572
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1721 core/models.py:1721
#: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1724 core/models.py:1724
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1728 core/models.py:1728
#: build/lib/core/models.py:1730 core/models.py:1730
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1785 core/models.py:1785
#: build/lib/core/models.py:1787 core/models.py:1787
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1789 core/models.py:1789
#: build/lib/core/models.py:1791 core/models.py:1791
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1795 core/models.py:1795
#: build/lib/core/models.py:1797 core/models.py:1797
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1837 core/models.py:1837
#: build/lib/core/models.py:1839 core/models.py:1839
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1840 core/models.py:1840
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
#: core/models.py:1841 core/models.py:1893
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
#: core/models.py:1843 core/models.py:1895
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1888 core/models.py:1888
#: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1891 core/models.py:1891
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1938 core/models.py:1938
#: build/lib/core/models.py:1940 core/models.py:1940
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1942 core/models.py:1942
#: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1945 core/models.py:1945
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1953 core/models.py:1953
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1972 core/models.py:1972
#: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1975 core/models.py:1975
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1993 core/models.py:1993
#: build/lib/core/models.py:1995 core/models.py:1995
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:808 impress/settings.py:808
#: build/lib/impress/settings.py:837 impress/settings.py:837
msgid "Docs AI"
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
"PO-Revision-Date: 2026-04-08 13:28\n"
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
"PO-Revision-Date: 2026-04-30 13:05\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
@@ -17,28 +17,28 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr "Особисті дані"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
#: core/admin.py:166
msgid "Permissions"
msgstr "Дозволи"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "Важливі дати"
#: build/lib/core/admin.py:112 core/admin.py:112
#: build/lib/core/admin.py:117 core/admin.py:117
msgid "Import job created and queued."
msgstr "Завдання імпорту створено і поставлено в чергу."
#: build/lib/core/admin.py:116 core/admin.py:116
#: build/lib/core/admin.py:121 core/admin.py:121
msgid "Process selected user reconciliations"
msgstr "Обробити обрані узгодження користувача"
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:176 core/admin.py:176
msgid "Tree structure"
msgstr "Ієрархічна структура"
@@ -62,24 +62,24 @@ msgstr "Приховано"
msgid "Favorite"
msgstr "Обране"
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
msgid "A new document was created on your behalf!"
msgstr "Новий документ був створений від вашого імені!"
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
msgid "You have been granted ownership of a new document:"
msgstr "Ви тепер є власником нового документа:"
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
msgid "This field is required."
msgstr "Це поле є обов’язковим."
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Доступ до посилання '%(link_reach)s' заборонено на основі конфігурації батьківського документа."
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
#, python-brace-format
msgid "copy of {title}"
msgstr "копія {title}"
@@ -382,151 +382,151 @@ msgstr "Документ"
msgid "Documents"
msgstr "Документи"
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
#: core/models.py:940 core/models.py:1345
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
#: core/models.py:940 core/models.py:1347
msgid "Untitled Document"
msgstr "Документ без назви"
#: build/lib/core/models.py:1346 core/models.py:1346
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Open"
msgstr "Відкрити"
#: build/lib/core/models.py:1381 core/models.py:1381
#: build/lib/core/models.py:1383 core/models.py:1383
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ділиться з вами документом!"
#: build/lib/core/models.py:1385 core/models.py:1385
#: build/lib/core/models.py:1387 core/models.py:1387
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
#: build/lib/core/models.py:1391 core/models.py:1391
#: build/lib/core/models.py:1393 core/models.py:1393
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ділиться з вами документом: {title}"
#: build/lib/core/models.py:1492 core/models.py:1492
#: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link trace"
msgstr "Трасування посилання Документ/користувач"
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:1495 core/models.py:1495
msgid "Document/user link traces"
msgstr "Трасування посилань Документ/користувач"
#: build/lib/core/models.py:1499 core/models.py:1499
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "A link trace already exists for this document/user."
msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
#: build/lib/core/models.py:1522 core/models.py:1522
#: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorite"
msgstr "Обраний документ"
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1525 core/models.py:1525
msgid "Document favorites"
msgstr "Обрані документи"
#: build/lib/core/models.py:1529 core/models.py:1529
#: build/lib/core/models.py:1531 core/models.py:1531
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Цей документ вже вказаний як обраний для одного користувача."
#: build/lib/core/models.py:1551 core/models.py:1551
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relation"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1554 core/models.py:1554
msgid "Document/user relations"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1558 core/models.py:1558
#: build/lib/core/models.py:1560 core/models.py:1560
msgid "This user is already in this document."
msgstr "Цей користувач вже має доступ до цього документу."
#: build/lib/core/models.py:1564 core/models.py:1564
#: build/lib/core/models.py:1566 core/models.py:1566
msgid "This team is already in this document."
msgstr "Ця команда вже має доступ до цього документа."
#: build/lib/core/models.py:1570 core/models.py:1570
#: build/lib/core/models.py:1572 core/models.py:1572
msgid "Either user or team must be set, not both."
msgstr "Вкажіть користувача або команду, а не обох."
#: build/lib/core/models.py:1721 core/models.py:1721
#: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for access"
msgstr "Запит доступу до документа"
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1724 core/models.py:1724
msgid "Document ask for accesses"
msgstr "Запит доступу для документа"
#: build/lib/core/models.py:1728 core/models.py:1728
#: build/lib/core/models.py:1730 core/models.py:1730
msgid "This user has already asked for access to this document."
msgstr "Цей користувач вже попросив доступ до цього документа."
#: build/lib/core/models.py:1785 core/models.py:1785
#: build/lib/core/models.py:1787 core/models.py:1787
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хоче отримати доступ до документа!"
#: build/lib/core/models.py:1789 core/models.py:1789
#: build/lib/core/models.py:1791 core/models.py:1791
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} бажає отримати доступ до наступного документа:"
#: build/lib/core/models.py:1795 core/models.py:1795
#: build/lib/core/models.py:1797 core/models.py:1797
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запитує доступ до документа: {title}"
#: build/lib/core/models.py:1837 core/models.py:1837
#: build/lib/core/models.py:1839 core/models.py:1839
msgid "Thread"
msgstr "Обговорення"
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1840 core/models.py:1840
msgid "Threads"
msgstr "Обговорення"
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
#: core/models.py:1841 core/models.py:1893
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
#: core/models.py:1843 core/models.py:1895
msgid "Anonymous"
msgstr "Анонім"
#: build/lib/core/models.py:1888 core/models.py:1888
#: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comment"
msgstr "Коментар"
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1891 core/models.py:1891
msgid "Comments"
msgstr "Коментарі"
#: build/lib/core/models.py:1938 core/models.py:1938
#: build/lib/core/models.py:1940 core/models.py:1940
msgid "This emoji has already been reacted to this comment."
msgstr "Цим емодзі вже відреагували на цей коментар."
#: build/lib/core/models.py:1942 core/models.py:1942
#: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reaction"
msgstr "Реакція"
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1945 core/models.py:1945
msgid "Reactions"
msgstr "Реакції"
#: build/lib/core/models.py:1953 core/models.py:1953
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "email address"
msgstr "електронна адреса"
#: build/lib/core/models.py:1972 core/models.py:1972
#: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitation"
msgstr "Запрошення до редагування документа"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1975 core/models.py:1975
msgid "Document invitations"
msgstr "Запрошення до редагування документів"
#: build/lib/core/models.py:1993 core/models.py:1993
#: build/lib/core/models.py:1995 core/models.py:1995
msgid "This email is already associated to a registered user."
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."
#: build/lib/impress/settings.py:808 impress/settings.py:808
#: build/lib/impress/settings.py:837 impress/settings.py:837
msgid "Docs AI"
msgstr "Docs ШІ"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
"PO-Revision-Date: 2026-04-08 13:28\n"
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
"PO-Revision-Date: 2026-04-30 13:05\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
@@ -17,28 +17,28 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:30 core/admin.py:30
#: build/lib/core/admin.py:33 core/admin.py:33
msgid "Personal info"
msgstr "個人資訊"
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
#: core/admin.py:166
msgid "Permissions"
msgstr "權限"
#: build/lib/core/admin.py:55 core/admin.py:55
#: build/lib/core/admin.py:58 core/admin.py:58
msgid "Important dates"
msgstr "重要日期"
#: build/lib/core/admin.py:112 core/admin.py:112
#: build/lib/core/admin.py:117 core/admin.py:117
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
#: build/lib/core/admin.py:121 core/admin.py:121
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
#: build/lib/core/admin.py:176 core/admin.py:176
msgid "Tree structure"
msgstr "樹狀結構"
@@ -62,24 +62,24 @@ msgstr "已隱藏"
msgid "Favorite"
msgstr "我的最愛"
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
msgid "A new document was created on your behalf!"
msgstr "已代表您建立新文件!"
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
msgid "You have been granted ownership of a new document:"
msgstr "您已獲得新文件的所有權:"
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
msgid "This field is required."
msgstr "此欄位為必填。"
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "根據父文件設定,不允許連結範圍「%(link_reach)s」。"
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
#, python-brace-format
msgid "copy of {title}"
msgstr "{title} 的副本"
@@ -375,151 +375,151 @@ msgstr "文件"
msgid "Documents"
msgstr "文件"
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
#: core/models.py:940 core/models.py:1345
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
#: core/models.py:940 core/models.py:1347
msgid "Untitled Document"
msgstr "未命名文件"
#: build/lib/core/models.py:1346 core/models.py:1346
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Open"
msgstr "開啟"
#: build/lib/core/models.py:1381 core/models.py:1381
#: build/lib/core/models.py:1383 core/models.py:1383
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} 與您分享了一份文件!"
#: build/lib/core/models.py:1385 core/models.py:1385
#: build/lib/core/models.py:1387 core/models.py:1387
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} 邀請您以「{role}」角色參與以下文件:"
#: build/lib/core/models.py:1391 core/models.py:1391
#: build/lib/core/models.py:1393 core/models.py:1393
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} 與您分享了一份文件:{title}"
#: build/lib/core/models.py:1492 core/models.py:1492
#: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link trace"
msgstr "文件/使用者連結追蹤"
#: build/lib/core/models.py:1493 core/models.py:1493
#: build/lib/core/models.py:1495 core/models.py:1495
msgid "Document/user link traces"
msgstr "文件/使用者連結追蹤"
#: build/lib/core/models.py:1499 core/models.py:1499
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "A link trace already exists for this document/user."
msgstr "此文件/使用者已存在連結追蹤。"
#: build/lib/core/models.py:1522 core/models.py:1522
#: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorite"
msgstr "文件收藏"
#: build/lib/core/models.py:1523 core/models.py:1523
#: build/lib/core/models.py:1525 core/models.py:1525
msgid "Document favorites"
msgstr "文件收藏"
#: build/lib/core/models.py:1529 core/models.py:1529
#: build/lib/core/models.py:1531 core/models.py:1531
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "此使用者已將此文件加入收藏。"
#: build/lib/core/models.py:1551 core/models.py:1551
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relation"
msgstr "文件/使用者關聯"
#: build/lib/core/models.py:1552 core/models.py:1552
#: build/lib/core/models.py:1554 core/models.py:1554
msgid "Document/user relations"
msgstr "文件/使用者關聯"
#: build/lib/core/models.py:1558 core/models.py:1558
#: build/lib/core/models.py:1560 core/models.py:1560
msgid "This user is already in this document."
msgstr "此使用者已在此文件中。"
#: build/lib/core/models.py:1564 core/models.py:1564
#: build/lib/core/models.py:1566 core/models.py:1566
msgid "This team is already in this document."
msgstr "此團隊已在此文件中。"
#: build/lib/core/models.py:1570 core/models.py:1570
#: build/lib/core/models.py:1572 core/models.py:1572
msgid "Either user or team must be set, not both."
msgstr "必須設定使用者或團隊其中之一,不能同時設定兩者。"
#: build/lib/core/models.py:1721 core/models.py:1721
#: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for access"
msgstr "要求文件存取權"
#: build/lib/core/models.py:1722 core/models.py:1722
#: build/lib/core/models.py:1724 core/models.py:1724
msgid "Document ask for accesses"
msgstr "要求文件存取權"
#: build/lib/core/models.py:1728 core/models.py:1728
#: build/lib/core/models.py:1730 core/models.py:1730
msgid "This user has already asked for access to this document."
msgstr "此使用者已要求過存取此文件的權限。"
#: build/lib/core/models.py:1785 core/models.py:1785
#: build/lib/core/models.py:1787 core/models.py:1787
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} 想要存取文件!"
#: build/lib/core/models.py:1789 core/models.py:1789
#: build/lib/core/models.py:1791 core/models.py:1791
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} 想要存取以下文件:"
#: build/lib/core/models.py:1795 core/models.py:1795
#: build/lib/core/models.py:1797 core/models.py:1797
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} 正要求存取文件:{title}"
#: build/lib/core/models.py:1837 core/models.py:1837
#: build/lib/core/models.py:1839 core/models.py:1839
msgid "Thread"
msgstr "對話串"
#: build/lib/core/models.py:1838 core/models.py:1838
#: build/lib/core/models.py:1840 core/models.py:1840
msgid "Threads"
msgstr "對話串"
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
#: core/models.py:1841 core/models.py:1893
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
#: core/models.py:1843 core/models.py:1895
msgid "Anonymous"
msgstr "匿名"
#: build/lib/core/models.py:1888 core/models.py:1888
#: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comment"
msgstr "評論"
#: build/lib/core/models.py:1889 core/models.py:1889
#: build/lib/core/models.py:1891 core/models.py:1891
msgid "Comments"
msgstr "評論"
#: build/lib/core/models.py:1938 core/models.py:1938
#: build/lib/core/models.py:1940 core/models.py:1940
msgid "This emoji has already been reacted to this comment."
msgstr "此評論已標記過此表情符號。"
#: build/lib/core/models.py:1942 core/models.py:1942
#: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reaction"
msgstr "回應"
#: build/lib/core/models.py:1943 core/models.py:1943
#: build/lib/core/models.py:1945 core/models.py:1945
msgid "Reactions"
msgstr "回應"
#: build/lib/core/models.py:1953 core/models.py:1953
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "email address"
msgstr "電子郵件地址"
#: build/lib/core/models.py:1972 core/models.py:1972
#: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitation"
msgstr "文件邀請"
#: build/lib/core/models.py:1973 core/models.py:1973
#: build/lib/core/models.py:1975 core/models.py:1975
msgid "Document invitations"
msgstr "文件邀請"
#: build/lib/core/models.py:1993 core/models.py:1993
#: build/lib/core/models.py:1995 core/models.py:1995
msgid "This email is already associated to a registered user."
msgstr "此電子郵件地址已與已註冊使用者關聯。"
#: build/lib/impress/settings.py:808 impress/settings.py:808
#: build/lib/impress/settings.py:837 impress/settings.py:837
msgid "Docs AI"
msgstr ""

View File

@@ -2,12 +2,12 @@
# impress package
#
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
requires = ["uv_build>=0.11.9,<0.12"]
build-backend = "uv_build"
[project]
name = "impress"
version = "4.8.6"
version = "5.0.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -21,12 +21,11 @@ classifiers = [
]
description = "Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing."
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
requires-python = "~=3.13.0"
dependencies = [
"beautifulsoup4==4.14.3",
"boto3==1.42.59",
"boto3==1.42.93",
"Brotli==1.2.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
@@ -34,37 +33,39 @@ dependencies = [
"django-countries==8.2.0",
"django-csp==4.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.24",
"django-lasuite[all]==0.0.26",
"django-parler==2.3",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django<6.0.0",
"django-treebeard<5.0.0",
"djangorestframework==3.16.1",
"djangorestframework==3.17.1",
"django-waffle==5.0.0",
"drf_spectacular==0.29.0",
"dockerflow==2026.1.26",
"dockerflow==2026.3.4",
"easy_thumbnails==2.10.1",
"emoji==2.15.0",
"factory_boy==3.3.3",
"gunicorn==25.1.0",
"gunicorn==25.3.0",
"jsonschema==4.26.0",
"langfuse==3.11.2",
"lxml==6.1.0",
"markdown==3.10.2",
"mistralai==1.12.4",
"mozilla-django-oidc==5.0.2",
"nested-multipart-parser==1.6.0",
"openai==2.24.0",
"openai==2.32.0",
"psycopg[binary,pool]==3.3.3",
"pycrdt==0.12.47",
"pydantic==2.12.5",
"pycrdt==0.12.50",
"pydantic==2.13.3",
"pydantic-ai-slim[openai,logfire,web]==1.58.0",
"PyJWT==2.12.0",
"PyJWT==2.12.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.33.0",
"sentry-sdk==2.53.0",
"uvicorn==0.41.0",
"requests==2.33.1",
"sentry-sdk==2.58.0",
"uvicorn==0.45.0",
"whitenoise==6.12.0",
]
@@ -78,29 +79,30 @@ dependencies = [
dev = [
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2026.3.1",
"drf-spectacular-sidecar==2026.4.14",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.10.0",
"pyfakefs==6.1.3",
"ipython==9.12.0",
"pyfakefs==6.2.0",
"pylint-django==2.7.0",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pytest-cov==7.1.0",
"pytest-django==4.12.0",
"pytest==9.0.2",
"pytest==9.0.3",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.26.0",
"ruff==0.15.4",
"types-requests==2.32.4.20260107",
"ruff==0.15.11",
"types-requests==2.33.0.20260408",
]
[tool.setuptools]
packages = { find = { where = ["."], exclude = ["tests"] } }
zip-safe = true
[tool.distutils.bdist_wheel]
universal = true
[tool.uv.build-backend]
module-root = ""
source-exclude = [
"**/tests/**",
"**/test_*.py",
"**/tests.py",
]
[tool.ruff]
exclude = [

View File

@@ -1,7 +0,0 @@
#!/usr/bin/env python
"""Setup file for the impress module. All configuration stands in the setup.cfg file."""
# coding: utf-8
from setuptools import setup
setup()

2440
src/backend/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -60,7 +60,7 @@ COPY --from=impress-builder /home/frontend/apps/impress/out /app
FROM ${FRONTEND_IMAGE} AS frontend-source
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.22 AS frontend-production
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
# Upgrade system packages to install security updates
USER root

View File

@@ -2,7 +2,6 @@ PORT=3000
BASE_URL=http://localhost:3000
BASE_API_URL=http://localhost:8071/api/v1.0
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true
MEDIA_BASE_URL=http://localhost:8083
CUSTOM_SIGN_IN=false
IS_INSTANCE=false

View File

@@ -2,7 +2,6 @@ PORT=3000
BASE_URL=http://localhost:3000
BASE_API_URL=http://localhost:8071/api/v1.0
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true
MEDIA_BASE_URL=http://localhost:8083
IS_INSTANCE=false
CUSTOM_SIGN_IN=false

File diff suppressed because one or more lines are too long

View File

@@ -192,10 +192,10 @@ endobj
(react-pdf)
endobj
55 0 obj
(D:20260403132357Z)
(D:20260505110445Z)
endobj
56 0 obj
(chromium-8651-0-doc-export-override-content)
(chromium-4903-0-doc-export-override-content)
endobj
52 0 obj
<<
@@ -216,7 +216,7 @@ endobj
58 0 obj
<<
/Type /FontDescriptor
/FontName /VIBRRZ+Inter18pt-Regular
/FontName /HRJUFI+Inter18pt-Regular
/Flags 4
/FontBBox [-742.1875 -323.242187 2579.589844 1109.375]
/ItalicAngle 0
@@ -232,7 +232,7 @@ endobj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /VIBRRZ+Inter18pt-Regular
/BaseFont /HRJUFI+Inter18pt-Regular
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -247,7 +247,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /VIBRRZ+Inter18pt-Regular
/BaseFont /HRJUFI+Inter18pt-Regular
/Encoding /Identity-H
/DescendantFonts [59 0 R]
/ToUnicode 60 0 R
@@ -256,7 +256,7 @@ endobj
62 0 obj
<<
/Type /FontDescriptor
/FontName /TDKMKH+Inter18pt-Bold
/FontName /XKLDZR+Inter18pt-Bold
/Flags 4
/FontBBox [-790.527344 -334.472656 2580.566406 1114.746094]
/ItalicAngle 0
@@ -272,7 +272,7 @@ endobj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /TDKMKH+Inter18pt-Bold
/BaseFont /XKLDZR+Inter18pt-Bold
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -287,7 +287,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /TDKMKH+Inter18pt-Bold
/BaseFont /XKLDZR+Inter18pt-Bold
/Encoding /Identity-H
/DescendantFonts [63 0 R]
/ToUnicode 64 0 R
@@ -296,7 +296,7 @@ endobj
66 0 obj
<<
/Type /FontDescriptor
/FontName /JYBWBW+Inter18pt-Italic
/FontName /QHBJWW+Inter18pt-Italic
/Flags 68
/FontBBox [-747.558594 -323.242187 2595.703125 1109.375]
/ItalicAngle -9.398804
@@ -312,7 +312,7 @@ endobj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /JYBWBW+Inter18pt-Italic
/BaseFont /QHBJWW+Inter18pt-Italic
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -327,7 +327,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /JYBWBW+Inter18pt-Italic
/BaseFont /QHBJWW+Inter18pt-Italic
/Encoding /Identity-H
/DescendantFonts [67 0 R]
/ToUnicode 68 0 R
@@ -336,7 +336,7 @@ endobj
70 0 obj
<<
/Type /FontDescriptor
/FontName /DLRHPN+GeistMono-Regular
/FontName /NBHLIK+GeistMono-Regular
/Flags 5
/FontBBox [-1738 -247 654 1012]
/ItalicAngle 0
@@ -352,7 +352,7 @@ endobj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /DLRHPN+GeistMono-Regular
/BaseFont /NBHLIK+GeistMono-Regular
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -367,7 +367,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /DLRHPN+GeistMono-Regular
/BaseFont /NBHLIK+GeistMono-Regular
/Encoding /Identity-H
/DescendantFonts [71 0 R]
/ToUnicode 72 0 R
@@ -376,7 +376,7 @@ endobj
74 0 obj
<<
/Type /FontDescriptor
/FontName /LHWXUO+Inter18pt-BoldItalic
/FontName /VMRKYJ+Inter18pt-BoldItalic
/Flags 68
/FontBBox [-795.898437 -334.472656 2596.191406 1114.746094]
/ItalicAngle -9.398804
@@ -392,7 +392,7 @@ endobj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /LHWXUO+Inter18pt-BoldItalic
/BaseFont /VMRKYJ+Inter18pt-BoldItalic
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -407,7 +407,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /LHWXUO+Inter18pt-BoldItalic
/BaseFont /VMRKYJ+Inter18pt-BoldItalic
/Encoding /Identity-H
/DescendantFonts [75 0 R]
/ToUnicode 76 0 R
@@ -713,30 +713,21 @@ endobj
/Filter /FlateDecode
>>
stream
xœí]ݎ㸱¾ï§ð ´Â?°èdO=6;@.\Ìhfr0}‚Í¢DŠÿ”e¯»·¶×cWY*ŠUÉ*Êö‡/Hý=cõ<63>dx<64>Bx¼Ì¯O?Û?lÀ:êÿÕ?ª¿ä?{ëèBbßu:Œ¾h÷ý—ÿÿßùË_þôûËüË2Gü2ÿßÓïþô ¾üã<75>Û™‰üýϪ÷Ö<65>äÉåÃ×§¿}‡RzÄÔƒª÷…z¨—ø³úgTÏxyFüå"ù0a†8½è“ÈË…Ða"£<>Ø(Ä‹º
9Œ#ãR<18> ¿_>ü÷Ó}P—º^Œ<> ¢$㲟<C2B2>ÛÜŸï#,Ä€&J©\Ž~“>$§ûP¢aDxiéÈ,9ü ûðË-|ÈbLù0ð¶Qüp^“½£™Èħ@ÖNªéI¢ËëÓÈÑ@¤rç¤åo<C3A5>\fÿX…uåªo±ª®˜õµ¬ço/ã³½æoöÙÿw~úŸ§¿^œ“ƒ “dSAF6©á£ÆÃ0<C383>db Î¥T“Ò õ3uz¶ÅþHÆõ¢™¾}yúêů—‡ŠÓJõ¢‡·!gíˆ^åg¥x^—(ó&2ºÍòøòý?mì<6D>AÏ4j G<C2A0>h%°·^C<E28093>ßo"/½qs·ÑsY¦>ó`vºÓÇÝëõÔ˜[Z<>¾óÕDÉŽîw3ˆÝcô<77>åØ-ÏzŠÒ áë4ªäo<C3A4>\fÿXµýo±ª®˜õµ¬ço/ã³½æoöÙÿ¿ºØ¸UŬ&ziÙ­5Ô,@ËR„„]šL„—F"¶«6®ÜÕ$Yp²KÍÒï¹HÌ ³è7 ÓåÏxmÎ+9p2pfð齌ûåYZÃW6°žPpÐÞ<C390>¹ä×ä¥^ãdøI¡C%äþâÒ¦úé~"—?ýôô·¿«ëülà°oëõ´ØÙq*”QÕóa<C3B3>yb-¬Õ<C2AC>ô!/áÔŸ #<03>bc‰ˆì<CB86>½X*„ônSä<53> »4÷Ú3#¯ÐCÑ«ðy>±<¶sŽ£ûÁ³÷•‡¬ïŒ0F \ƒtÓµö€Ûšy-®Fhy+µ–µ€ e"”ïs{Ør+YÛúWŽÿ¶ôEƒ±7'º] ý:Û¶¶O8Ù F<>µ)5fl¹lºSnMýÜZo\}µYØ>«JS»±Å²ù·Ž"WFùí«9*rÅ({¹L2föÒÌœÞe³ïK]¤ê·a)V¾ý.qÒGõø¤³÷ú³ó<C2B3>©rWÿèý<4˜ÍM»Ÿ§[z¹ÈA5g÷ø´ó0K:MW"z—ÇßÖK­»õÙ-?ìÕ.<2E>nënˆ“Òí½¡÷•n¯}TuÅ.SqvœŠgTuÅO·Wg”@øLOfL‰ˆÜ.ݾ*üwìÒÜ«ïÍíeüÂÌ)+ôPô*|žOD,<2C>íœãè~ðì}•K·}#Þ `Œ@¸éPÿZ#{ãtÛÚ±t»6”‰P¾Ïía»:Ý.Ä[ú¢Á˜<19>o(Ýþ5v°õ½¨q £Édœæž‰½eŒ?ÚtV½&Øfžt½<74>be¶Üj&ëq*ád
Ì&¡Ú†>UknIì5L{•1¬ra:0,ô¾ùz!a#™ÌU<C38C>a2MÞ!ß÷|‡µ<E280A1>¸­RDúNÆiK©ïÔ´Ÿîw‡Û1Íÿ¸3eJ„éÞŤ)’¤-”& íq)¨°~ÌËÃÀþóRlmŸœ°Çlï­èW{»Q83ÎäRµé¡°<C2A1>X0¼#8.5ä†IM1“¸ #æMJ2,Ñ…‰_c¢RŒ`)—ZÙÎTÌ»ÃKí„¡B@Ò{jiðŠÜß̈Üí¬|Zæk<1F>zÞ&D<>MtqïÇä³i<C2B3>~‡­<C2AD>ÿ¸Z¦ØÀõ‡Ž¾Cч<C391>¶UJØgW<1B><˜÷>-Ãt¹]tÈ2˜ã¿X[VÖÓñÞÛlØ…h[õyiœá‰<58>œK´<4B>.”¯Kÿ¿•×05£I%­+8­æ[˜¥:åø)ù™$×<C397>Ca<06>jù¥„ªdò£½\fÿXdNEY1/\¹ #F…TŽUÊâ^ŽòììwkÓ§¯‡œÓ|¿óß²¸Ø·õšê[¤ª+f?ìÎŽS¡Œª®˜Ìka­†¤u —ø<ªúǦÑHl¾ÂÙ3ޝ
ý<EFBFBD>àzã{<7B>ç¡ÇvÎqt/tžsw:3&¼ãp С¾µFõ·{Ÿ³´ÜúÕ²æ•cÿžïqÞkÓe¿q j2p½B·|V.·ìð×t©@L‰ÄÉ/.ì³¼ýÊ÷úD<C3BA>º2NÒf/—¤Ù?™SQVÌ ‰¤ÑóBiñ·PG»./­Ã >P1a9^Så5O¸¨ÐÐ~Pdì…çBØó;Ú)wðš0ßr^Ý·õšê{¤ª+f|ÎŽS¡Œª®˜ÃÜka¯†¤ ¯ Ÿ¶@ö™ xŠM&Â’vC?ßžéå*,Ü ¿.Ç);8LÓŠLÖYL Q˜ó‰CÇvÎ Z/Ë^÷³Ò´ÉÄù  <>àPŸÏBÎm«® ¹x=ëY—
rg.pÎÈH7çD¥~µ4¡+Çhƒw”fdç´Î ¬÷©V¶&@æFQz+Yl“Dì÷<>ü+j½%€Ù€&½¯^¯L¶õÅ~Xˆ0183ÿ<E280B9>\fÿX“£1žó·¾M6Õóļ0û]Ù¥ˆÓ:}¹$mŸ¬Pc3®<©½N šd.Ê7´â9{áùùöü‡vʼ&Ì7Íìvm½¦ú©êŠÙŸ³ãT(£ª+æã0wb-ìÕ<C3AC>ôá5áÓÈ>“‰Ød",ÉQaíåãÛ3½\……;á×%¥o#U]±›Þ¢´¿ ª+懂ˆíœ´n8½î#2c2q~ôÈ7š´NBÎmKñ ¹ÄzÖ³.9pîÓïî\à¤á©š<C2A9>Õ•nøUÒ„¾£ ÞQš<C5A1>Ó:'°n\ܧ2 Z}ÜÊäs©2È|ŠóÍW&v}…MôÇwФ*G Ÿ<>\fÿX3<58>L$+æ·¾<C2B7>¦zž˜f¿+~Џô,”KûÌ÷4)'Æö£<tQ¾¡`ˆgì…ççCØó;Ú)wðš0ßt¡Ûµõšê{¤ª+f|ÎŽS¡Œª®˜<C2AE>Ã܉µ°WC҇ׄO »[8aIŽ
k/ßžéå*,Ü ¿.)<29>|©êŠÝô¦ý%U]1Ÿ8hl眠uñèu“‰óë <C3AB><>àФurn[ŠÍ%Ö³žuÉ<75>s—~÷ç'<27> OÕ„¬ö¨tï&ôåMðŽÒŒìœÖ9<C396>uãâN•ɾՇ­LËW&|@rÒ_Èyó•‰]_ˆÝĤTª`[ÖúRîõìŽ2?þIERH¿t«±[ξ˜fÿÂý„<C3BD>¬[¶;¹$­sÕ7Â(š”»ý1½wH¡™ýpÎX Ïϸ;˜ü®†6Ê];Î[.d~K¯qEYœ¼Üù«%eqîï&”Ytvö"oµ€Ï_îòîNbÛÚJG¬mB8×» Ð%<25>#U]±<C2B1>´¼¤ª+æ`-Ãó¯ PÌÊö±6˜8¿åºk¨;{y2n[
ÍÅkLÏzá<7A>o—þö¯Îç ßW5¡©=*½<>+/Ý]«~  £¥?;_uNNݘ¸SU°oõa«åûüÙORQ:No¿*°+ˆœì"•úW7¨ä¶¨ÜË%iö<69>5Y•¤Y1/lk˜¾$äÆË"æ…ÙÔÙž…rIÚf ŽÕ<AôÇýT-ôP¾<50> WK  0çw7´SîÞ5A¾iƶkë5Õ÷HUWÌ>ôœ§BU]1¹ka¯†¤ ­ —Ö»KúŠAI k.Ýž©å*$Ü ½. <0A>\©êŠÝÔ$öEU]1Ÿ8Xl眠õ¢±èt<1F>i‰Óë<C393><C3AB> ¾AàЄunn[^Í%Ö²ž5ÉAÓOºd'<27> ¬ö¨ô¢¯’!teMàŽŒì|Ö9yu£â>IÐêÃV$æÇ–òŸ ¢’,ßɺ¶,Y#“‚³ ëm¹½\fÿXdN•Y1/xKMñœ”{í-«û´pÁž˜Ö™éßÇähd\<5C>©I!Zò„g²<67>ì‡uÆZpvÁíÁ,áuôµ½gׄõ«Ú¾­×T¿#U]1û`svœ
eTuÅ|ÖžX y5$]@Mø´¬Ïx˜ˆJj4XsùèöL&W!áNèuùgèÙPS“ýÉ,Jí³šš<Ÿˆÿ)¶sN¬zAXr¶ôÁøìœGÀ^¨Ãg å¶5ö¾µhÝj^€÷Iu×ý ð4 j A'Ì*kOÞЄá0sÈÍU=3S_ôïTWì}ܲb|Ñ?0MÌÏ™æê 6Œìþ?1{ƒúb]AìÇ ˜`
ÌÁ£ÿN.I³¬É¼ÍŠyÁ-`ÌæoNʽž½^ø9[°'æ…mžä˜$d¿wy&×H<C397>²e¬gçݘó:úÚÞ³kÂzÓ m×Ökªßª®˜}°9;N…2ªºb>k'ÖB^ IP>­ƒÕß…+G%5¬¹|t{&“«<E2809C>p'ôº43ôl¨©Éþd&ðyMMžOÄ?‹íœ«nœíÃ0c0>»ŽóØ[àMS' å¶ô¾µxÝj^€6<îrê¾5þ„Aà4 j A/ÌÊkWÞÐá0sÈÍU=3S_ôïS_ì}Üú‚o[0†ÙÛ¿m±.ÈþXîÊ9…liº—KÒìës¦Ä¼à.$mâæ‰yaö»â'|H®Ã×—KÒ:QŒ ˜(oºB<Žtš.*4´Ù{áù…3…ßáÐN¹ƒ×„ùˆGp\û©êŠÙŸ³ãT(£ª+æÃ0÷ÄZØ«!éÂk§-<2D>}Vƒ Å&aIŽŠ=Sa"¾=ÓËUX¸~]Âù6RÕ»é-ÌçKªºb>q(ðØÎ9Aë…cÙë>"Ó&ç7€>ù
C}> 9·-­ƒæâõ¬g]òÀ¹Ë¹ûs<C3BB>sF†¯jBV{TzáWKºrŒ6xGiFvNëœÀºqq§ÂdßêãV&â]~ *¦q&ítàq[Ÿ÷xÜ$ð¸<01>ð¸<01>ð¸m`Ûš·Ür ÂÌÈË:xÜÌ7<C38C>±¥þê(ˆRå4õ4¸t4õ@á†#Þªc0
·èڀ (Ü2A
·ãÈ
·Ã#(Ü€Âí1Êâ—_ÔCÍ¿dJ¦ÿDÖŠ<ii9€'­fàIž4àIž4àIž4àI;àIž´ƒé?ù¨ŸJéÿH(£²!ý2²H22 #³ <02><19>µ #22 #22 #»iú?Û Ÿþ3.ÅØ<C385>þã0~ã
ã0~ã0~<7E> `ü:Œ|`üƯK½Í®{)õ¦BõƒÕSo ÕŠ% Õ*6! ÕZ- ÕZ- ÕZ- ÕZ­[¦ýŸí®{6í'¸¨«ÃPW¥Â
ÔU@]ÔU@]µÙê* ®ê* ®êªc_•ÅöQøº,C„6ìÚ5”c0j¨TX<54>
¨¡€
¨¡6;@ ÔP@ ¨¡²ÑÿÍSCEù;)ß•!±þ;p0áH&à`¦°¸&à`ºj(p0p0Ó­Kó}×Âw^ÅÀõ'©HªˆÉŽ0Ùú TGþ<>êHÕPÕPÕѶ­ :Ê-wÑ ÌŒ¼¬ó€êÈd{òåÂUmÀ0S¹ùw ï¢*õ“–óÈç?¢Ét<C389>£<EFBFBD>sÑTßþ£Xþ#à?²_Êþ#à?þ£ö¡üGÀüGÀüG·å?z¹0U#,ߺÔ_Ð$/Êà0<C3A0>£À$Y`1P$Ç–hf¤Xf$`F²É0#30#µ`Ff$`FjŠ
0#3Òaf¤— W9¾$Óµ0 Ó€øR<16>Oé²€Ó$ e0&E0&c](€1 “€1©}(c0&c0&cÒm“TY@&øÈmY°Ô
é[£ù÷†Zè“€> è“P`è“€> è“€>éd}Òaä}Ð'=XžÞ~*ÚŒà†ŒX“b X“
AÖ¤€,X“€5 X“€5 X“€5 X“€5馬I/— BÚÏäëÏâìä9÷a<>´¡:¥íWÿ<57>N)V S:% S:¥ÍÐ)<01>Ð)<01>Ð)]E§Ôú…[…]eG_$p+·Ò¸•öøÛ’àVʸ•ºÐër:àV:+àVn%àVŒ¾en¥¦/ɪà#I'ÙB“
4K4K@³´¨€f hŽâ×%y@³4K@³4Kû¨ÍÐ,]E³ý~Nö²ª%A4I»šã^¢ª·oýõ{L hLvV…h"*йÚÇï¥:¨mý#×·úÅlÔ²l[ŽY˜‡éãd¯Vµ?Œk? —ïÿ¹½ÝÁ[wz¹<s”úiíÉC¹õ¤á/ɇI³g˜lè2¾<77>\84ˆz<03>ƒ-üõ¡˜ªÇd´˜r<CB9C>'gÃ>>/”øs²×ø=^Ê£väÉY §&g²*oKŠBØÖ?lƒ@íƒÙøM:&¸Œ3ɘ™³QmêXS4<53>žº¡5èÚÔGû0fn䢡p×ó$ä}þ{ƒó;£c)òWëŸOŸ“E
êF
Ks댃:<3A>Š&(pþ¾¡ §f(Lz “Ê6ËhPÝ^¢ß {No<0F>t<EFBFBD>HPÝöü6bå!}uäýbcdh™>¦ð!ôÏÝr@ÁºbOë<1B> Y—[
ÿ1ªyZ ÷|ªBä=× £š<ήÊ©¹­>ÍKn¾Yþ<59>>Vf~¼#ì½td|/áï¥#â½tD>VGÞaN¾*ý×¥œ6ó§e]4%6ZÖX³/š®¢õœþ ºucòÀ•«&m7<6D>UY£ÊúçÖSûOÕÜZ\rüe9êYåÔÂ$f;Cû1È6ÄU=&[¶ŽÎ¡æ½Uþl_ +g62('*[¼ÍeeÕþÙxóõI,Ë4OîŸY+ĸ¼ü¯ pÍ[þ Vê.ûè.^æh¬í,=Ò'7ÒÃmÙÿ§os2
xœí][<5B>㸱~ï_á?Ð
XôC²'Áž‡ Af439Ÿ`³ÀÉßIQÅ;eÙÛÝ[Û뱫,Ū<C385>deûäÿž±þG2<H…/Óõég÷‡íøBGó¿~ãGý—|ãgïoy]È@Ü»›ã<>ÏÚß}ÿåÿÿwúò—?ýþ2ýò„ì¿Lÿ÷ô»?ý‚/ÿøEŸõãzfâ"ÿÁ³ê½õA[þ#¹`rùðõéoß!„´ ú}¡ú%þ¬ÿõ3žŸ¹H>(̧s:(2
­B¼è«<EFBFBD>Ã82.…ÕÈ— úûåÃ?ý×}©ËÅ9 Á± Z².ûÙºmûó}„…<18>¢”Êùè7éCrº%F„ÕH[|HG6`ɹào؇_îáC.°cʇ<C38A>ȰŽâWçµ1Ù;:EW9€,<2C>ÔÓ“D—ëÓÈÑ@¤v§2ò·@.I“¬ÆºvÕ·XUWLæZó×—ñÉ]ó7÷ìÿ;=ýÏÓ_/›“ƒ J2Š© #Szøèñ0¨(†±àL QJ=) Ò<ÓMoÃ6Ûɸ\43×ò¯/O_½8âå2òPÙ´Rÿ…èámÈYgG7¢ùY+ž—%ʾ‰¬nµü¾|ÿOûcг<C390>¤G ì-—µ¡Îï7ŒÞȸyØÅ˜¹ŽÌSŸ}07Ý™ã¿îõfjÌ-­“RH…|õǦ  Qr£ûÝ̲a÷<>ãc9vóß³™¢ÌbCø2<C3B8>jù[ —¤É?`cÿ[¬ª+&s-ËùëËøÅä®ù›{öÿ¯.6ÛªbW³´ìÖj y)BÂ-M6Âs#ëU[W®Ë<C2AE>nÌ8Ù¥véß¹HÌ è7ÓùÏz­Î+9p2pfñ齌ûåYZÂW6°œPpÐÞ<C390>½äkòRoq²‡Ž?ü¤Ñ¡rÿ?qiSýô‡??‘Ë¿Ÿ~zúÛßõu~¶pØ·u=mŒxÞìl*”QÕÓa<C393>yb-¬Õ<C2AC>ô!/áÔŸ #U±±DDvÈž-Bú°)òA<C3B2><41>»öÌÈ ôPô*|žND,<2C>íœãè~ðì}åá&kÄ;£Œ— Ýu­½'àÖf®ÅÕÍo¥Ö²°¡L„ò}n[n%k[ÿÊñ_—¾h0öæD÷¡_g»Öö 'Äh²Ö ¥ÆŒ #‚©åÖÔÏ­ÍÆÕW·™…ݳ®ô0u[,(rm”ß¿š£"WŒ²—ˆ”1»—f÷àÌ.{_š"Õì¸ j¤Xûö»ÄIõã“~LÞëÏol•»øÇìç¡Ánnºý<ÓÒËEº9·Çgœ‡YÒi¦1»<þ¶^­Ïnùa¯v<C2AF>tÛtCœ”nï ½¯t{é[¤ª+v™ÊfgSñŒª®xãéö⌟ âÉŒ)û¥Û7…ÿA<C3BF><41>»úÞ\_Æ/윲@E¯ÂçéDÄòØÎ9ŽîÏÞW¹tÛ7â<37>ÑÆ„K<E2809E>õ¯5²wN·= K·ë`C™åûÜÓíBü×¥/Œ™ø†Òí_cÛÜ‹*0R6ã´÷LÜ-cüÑ¥³ú5Á.ó¤Ë}'³ùV3YŽÓ 'Ó¨`. 56Ì©æX{KÚf¯aÚ«<C39A>a<EFBFBD> Ó<>aaöÍ— Éd® Ê6ù€|ßó6>â®Jé;xÕšR?,¨i?=îvcÿqgÊêÑŤ-’¤+””ƒö8TØ<¦ùaaÿy.¶ÖON¸cÖ÷ô«»Ý(-œgr®ÚÌPXO,œ rƒÒSŒ÷#aļII†å º0ñkLTÚ!,å\+»™Šywx©0tHzo@/ ^‘û›»<E28098>•Oó|íãÑÌÛ„h#Eg÷~L^1Sƒ$„ðl­ìüÇõ2Ån>tôŠ>ì´®RÂ<52><·Ú˜äÁ¾÷i¦j|änÑ!óT`<60>ÿâl9ÙLÄ{oµá¢ue4ç¥q†°0;9—h;](_þk¯ajG“NZp:Í·0K±LÊñSò3I[B…M<êå—ªGÍ<E28099>örIšüc=eżpã&Œ5R9Ö)Ëö*î•gg¿[>}9¼àœæû<C3A6>·8øžÅž­kªoª®˜ü°ov6ʨêŠé0À<±ÖjHzP—pi€Ï£®ßql*<11>ÄæK!œ=ãø¦Ð?®w¾×yZqlçG÷Bç9w§3cÂ;¾ˆê[kT»÷9ëAË­_-k^9öïùç£6]öº–!7; tÍgå|ËM—
dÀ”HœüâÂ>ËÛ¯|×'ô¥<C3B4>QI˜½\&ÿXdOEY1/$FOÌ ¥ÅßAíV¸¼´ #,ø@…Âr¼60¦ËkžpQ¡¡ý ÈØ Ï/„ °çw8´Sîà-a¾ç¼ºoëšê{¤ª+&|<>M…2ªºb: sO¬…½.¼&|ÚÙg6`L„%9*܆~>¾=ÓËMXx§Xîà09L+2Yg1<67>Da•VL'Û9'h½p,{ÝÏJÓ&ç7€>ùC}> ­ºæâõ¬g]*È<>¹À9##YÜœ•^øÕÒ„®£ ÞQš<C5A1>Ó:'°n\<¦Z}µ5²7ŠÒ[ɺ`S±Üò¯¨õ–fRf_½^™¬ë‹û°ab°pfÿ;¹$Mþ±6Gc<+æo}S.Õóļ0ù]Ù¥ˆjɾ\ÖOVè±<C3A8>מ4^§) Ù„‹ò í‡xÎ^x~><04>=¿Ã¡<C383>ro ó]3»][×Tß#U]1ùàÛìl*”QÕÓq˜ob-ìÕ<C3AC>ôá5áÓÈ>“‰Ød",ÉQáìåãÛ3½Ü„…áwKJ#ßFªºb7½EiAUWL'Û9'hÝp,zÝGdÆdâü:è#<23>¯ 84i<34>„œûâAs‰õ¬g]ÚÀ¹O¿»s<C2BB>“F†§jBV{TºáWIúrŒ&xGiFvNëœÀºqñ˜Ê$hõõV&ŸK•‰@öSœo¾2qëË(\ª`>¾ƒ”®|vrIšücí4¢HVÌ Ûú6
—êyb^˜ü®ø)âܳP.IÛg¾Õ ¤TŒíGyè¢|CÁÏØ Ïχ °çw8´Sîà-a¾ëB·këšê{¤ª+&|<>M…2ªºb:óM¬…½>¼&|ÚÙÝÂY KrT8{ùøöL/7aáAøÝÒÈ·ª®ØMoaÚ_RÕÓ‰C<E280B0>ÆvÎ Z7™1™8¿úä+MZ'!ç¾¥xÐ\b=ëY—6pîÒïþ\à¤á©š<C2A9>Õ•nøUÒ„¾£ ÞQš<C5A1>Ó:'°n\<¨2Ù·új+Âò• <1F> 9o¾2që q˜”JìÊZ_ʽž¶£ì<C2A3>RÒ/·ÕŒ¸-g_Ì “á~BH<>\9ša)ínRhf?œ3ÖÂó3«¡<C2AB>r׎„óž ™ßÒ5îo (‹Ó¯íüE<C3BC>в8uƒwJÁ,:»{·ZÀç/wyw'±íl¥#Ö6!ŒëC¸%<25>#U]±<C2B1>´¼¤ª+¦`-Ãóo PÌÊö±6˜8¿åºK¨;{y2î[
ÍÅkLÏzá<7A>o—þö¯Îç ßW5¡©=*½<>+/Ý]«~  £¥?;_uNNݘxPU°oõÕVó÷ù³Ÿ¤¢tTo¿*p+ˆTnJó«TrWTîå4ùÇÚ¬JÒ¬˜Ö5Ì\ÚÆË,æ…ÉԹž…rIZg Žõ<AÌÇýT-ôP¾<50> WK  0çw7´SîÞ-A¾kƶkëšê{¤ª+&z<7A>M…2ªºb:òM¬…½.´&\Zì.é+%9$œ¹|t{¦<E28093>ð ônihäÚHUW즶 ±/ªêŠéÄ<C3A9>Àb;ç­<17>E§û€L[Lœ^‡|ñ‡&¬“psßò:h.±õ¬I4ý¤û@¸ðTMÀj<C380>J/ú*BWvÑî(ÁÈÎg<C38E>“W7*S­¾ÚŠÄþØRþTTùû£o»"YÖ9K`Di8 ²Ü–ÛË%iò<69>EöT™ó‚·´¹o“r¯½euŸ.#ØóÂ2S0óû˜<1C>Œ1¥4¢%Ox&ÛÈ~Xg¬gÜÌ^G¯í=»%¬÷\Õöm]SýŽTuÅäƒm³³©PFUWL‡a퉵<E280B0>WCÒÔ„OÀúŒ<07>c‰¨¤Fƒ3—<33>nÏdr„Þ-ÿ =jj²?™E©}VS“§ñ¯b;çĪ„%gû0LŒÏnÀyì%ð‡:|Zî[cï[‹Ö­æhÃã>©îZãož¦C­!è„YeíïÉš0f¹¹ªgfêþƒêŠ]£¯·¬Lûs¦¹ú #{üOÌÞ¡¾XV÷1(&˜³"xtàßÉ%iò<69>µ™— Y1/l sùÛ&å^O^/üœ<C3BC>-ØóÂ:OŒrL²ß;ˆ<“k$HÙ2Ö³ónÌy½¶÷ì°Þ5CÛµuMõ;RÕ¶ÍΦBU]1‡õ&ÖB^ IP>­ƒÕß…+G%5œ¹|t{&“<E2809C>ð ônifèÙPS“ýÉ,Làóšš<<3C>ˆÛ9'VÝ ,8Û‡aÆ`|vç°×Àš¦N@Ë} è}kñºÕ¼­xÜåÔ}kü ƒ`Ó4`¨5½0+¯ý]yC †ÃÌ!7WõÌL}ÑL}±oôõÖ¼xÛ1ÌÞþmeá@îÇrÎ)äJÓ½\&ÿXŸ+0%æ…máBÒ%nž˜&¿+~‡ä2|}¹$-ňЀ‰ö¶¸è+ÄãH•J¸¨ÐÐ~dgì…çBÌ~‡C;åÞæGÐ "Áqé{¤ª+&|<>M…2ªºb: sO¬…½.¼&|ÚÙg=ØPl2䍨3&âÛ3½Ü„…áwK8#ßFªºb7½…ù|IUWL'Û9'h½p,{ÝGdÚdâüÐG _@p¨Ïg!ç¾¥uÐ\¼žõ¬K8w9w.pÎÈðUMÈj<C388>J/üjiBWŽÑï(ÍÈÎi<C38E>X7.T˜ì[}½•‰x—¨ŠiÜ”2N·åy?0<>ÇM<02>ð¸<01>ð¸<01>Û
¶µàqË-wÑ ÌŒ¼¬ó€ÇÍ~Ó;Jà¯ñLªœ¦žFM=P¸áH
·Ãa
·€·êÌ<>Â-º6 p
·LЀÂí8r€ÂíðÈ
7 p{åñË<C3B1>/ú¡ç_¢é?1Ÿµ"„OÚAZàI+„xÒ€' xÒ€' xÒ€' xÒGxÒ€'í`úO>êǧRú?ʨlHÿ<48>Œ,’€Œ ÈÈÜ‚dd@FddíCÈÈ€Œ ÈÈ€Œ ÈÈîšþO®ȧÿŒK16¤ÿÀøŒ_Àø…ƒÀøŒ_ÀøŒ_§ ¿#¿€ñë•¥Þv×½”zS¡ûÁê©7ÐjÅÐj ´ZÐj­Ðj­Ðj­Ðj­Ö=ÓþÏn×=›öÜ´áÔUÃPW¥Â
ÔU@]ÔU@]µÚê* ®ê* ®êªc_•ÅîQøº,C„6ìÚ5ÔÆ`ÔP©°5PC5PC­v€
¨¡€*úè PCe£ÿ§†ŠòwRþ¾+$Bbù!và`‘ LÀÁLaqLÀÁtÓP&à`&à`¦{—öû®…*<C2BC>ORT a²ö¨ŽüÕ‘ª# :ª# :ª£lk3@u”[î¢A˜yYçÕÍöäË…ëÚ€a¦sóïfÞ#Duê'çÏD“é Gç#Ußþ£Xþ#à?r_Êþ#à?þ£ö¡üGÀüGÀüG÷å?z¹0]#Ìߺ4_Ð$/Úà FЉU`, °(´3R,30#¹ä˜‘€ ˜‘Ú‡0#30#5E˜‘€é03ÒË…ë_‰éRP5 >—äSº,àƒR1ÑPcR$c0&¹…“€1 “Ú‡0&c0&c0&Ý—1I—t`<>Ü•s­<73>¾E0Úqo¨€> è“€> <06>> è“€> è“NAÐ'F>Ð'}Ò+ËÃÓÛïBGÜ<>qkR,kR!ÈÀš<14>Åk°&k°&k°&k°&Ý•5éå2¢AH÷™|óYœ<59><å>Œcй6”@§´þê?Ð)¥Â
tJ@§tJ@§´Ú:% S:% Sè”Z¿p«±«í˜n%àVº·ÒkÜJY°·Rz·œ¸•Ç
¸•€[ ¸•€[©£o™[©éK²:øHR%[hR<68>f)€f hfÐ,ÍÒQünIÐ,ÍÐ,ÍÒ>*@³4K7Ñ,E¿Ÿ“ý¢¬®FIMҮ渗¨î-Æký“Wá4&;«C¤ˆŽb®öñûG© j[ÿÈ«ë[ýbÖj9¶­<C2B6>Y؇í£rW«ÛÆ¥Ÿ?<3F>Ë÷ÿÜ <0A>Þîàµ;½\ž9J}€Œ´öäU¹õ¤á/ÉeØ3ì¶t_»uHÎD¿<44>ÇÁþæPLõCY-¦\`µÙp<C399>Ï3%þœìµ”î~¿áÜ>…Œ<9EâÔJAå}Z[û‡"¨{0&e€eÆ8“ŒÙ…Уaó“Â4´ ИúèöÀÌ]e4î){žd˜´OÆop±atL"Eþjýó¹|²HAÝHai¢ŸqЧSÑÎß7¤j†2;úâXP:õ-£Aw{Ž~$Üi8½W=ÒA"AYv¯ÚóÛˆµ‡ÌÕ÷<C3B7>¡Adú˜Â‡0¿‰ôëŠ l<e,o4d9|œïo$üǨ!<21>-ÜÐ𨫢÷\´Œzò8»h)× ®¶oÎ…ÂjùúºÊ„ãaï¥#ã{é/ï¥#òuuä¤ä«Ö<C396>k{[¾š×E[ï£y<C2A3>µ´éÞlšÄkY'9&¯¸r5 ò¶³:kÔYâÜqŒš¯"êšÛˆs<0E>¿ÌG=ëœZØDÒî­?ÙÆœ¸ê‡reë¸9Ô¾·ÈŸÝkáäÌ®ª`¥£¢²ÅÛ\VVퟭ7¯ObþŒ¨}Úþ™ŒBŒóÁÿ†<C3BF>×¾åŸàô1»ï¼©¿ÅË­ƒµžeFºÚFz¸=`
endstream
endobj
77 0 obj
@@ -1405,7 +1396,7 @@ trailer
/Size 87
/Root 3 0 R
/Info 52 0 R
/ID [<7800bd1e70bdb9114e48fc6d480ec696> <7800bd1e70bdb9114e48fc6d480ec696>]
/ID [<f859d5aa137a1b00be187261c0c2b77d> <f859d5aa137a1b00be187261c0c2b77d>]
>>
startxref
101711

View File

@@ -66,6 +66,8 @@ if (process.env.IS_INSTANCE !== 'true') {
name: 'Albert AI',
color: '#8bc6ff',
},
AI_FEATURE_ENABLED: true,
AI_FEATURE_BLOCKNOTE_ENABLED: true,
});
await mockAIResponse(page);
@@ -131,6 +133,8 @@ if (process.env.IS_INSTANCE !== 'true') {
name: 'Albert AI',
color: '#8bc6ff',
},
AI_FEATURE_ENABLED: true,
AI_FEATURE_BLOCKNOTE_ENABLED: true,
});
await mockAIResponse(page);
@@ -166,6 +170,11 @@ if (process.env.IS_INSTANCE !== 'true') {
page,
browserName,
}) => {
await overrideConfig(page, {
AI_FEATURE_ENABLED: true,
AI_FEATURE_LEGACY_ENABLED: true,
});
await page.route(/.*\/ai-translate\//, async (route) => {
const request = route.request();
if (request.method().includes('POST')) {
@@ -229,6 +238,11 @@ if (process.env.IS_INSTANCE !== 'true') {
page,
browserName,
}) => {
await overrideConfig(page, {
AI_FEATURE_ENABLED: true,
AI_FEATURE_LEGACY_ENABLED: true,
});
await mockedDocument(page, {
accesses: [
{
@@ -303,6 +317,11 @@ if (process.env.IS_INSTANCE !== 'true') {
});
test(`it checks ai_proxy ability`, async ({ page, browserName }) => {
await overrideConfig(page, {
AI_FEATURE_ENABLED: true,
AI_FEATURE_LEGACY_ENABLED: true,
});
await mockedDocument(page, {
accesses: [
{

View File

@@ -0,0 +1,352 @@
import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc, overrideConfig, verifyDocName } from './utils-common';
import { writeInEditor } from './utils-editor';
import { connectOtherUserToDoc, updateShareLink } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Collaboration', () => {
/**
* We check:
* - connection to the collaborative server
* - signal of the backend to the collaborative server (connection should close)
* - reconnection to the collaborative server
*/
test('checks the connection with collaborative server', async ({ page }) => {
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
});
await page
.getByRole('button', {
name: 'New doc',
})
.click();
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
await writeInEditor({ page, text: 'Hello World' });
let framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
// When the visibility is changed, the ws should close the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Check the ws is connected again
webSocket = await page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
});
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
});
test('it cannot edit if viewer but see and can get resources', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(page, 'doc-viewer', browserName, 1);
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello World' });
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Reading');
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl: page.url(),
withoutSignIn: true,
docTitle,
});
await expect(
otherPage.getByLabel('It is the card information').getByText('Reader'),
).toBeVisible();
// Cannot edit
const editor = otherPage.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'false');
// Owner add a image
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
// Owner see the image
await expect(
page.locator('.--docs--editor-container img.bn-visual-media').first(),
).toBeVisible();
// Viewser see the image
const viewerImg = otherPage
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(viewerImg).toBeVisible({
timeout: 10000,
});
// Viewer can download the image
await viewerImg.click();
const downloadPromise = otherPage.waitForEvent('download');
await otherPage.getByRole('button', { name: 'Download image' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('logo-suite-numerique.png');
await cleanup();
});
test('it checks block editing when not connected to collab server', async ({
page,
browserName,
}) => {
test.slow();
/**
* The good port is 4444, but we want to simulate a not connected
* collaborative server.
* So we use a port that is not used by the collaborative server.
* The server will not be able to connect to the collaborative server.
*/
await overrideConfig(page, {
COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/',
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
});
await page.goto('/');
const [parentTitle] = await createDoc(
page,
'editing-blocking',
browserName,
1,
);
const card = page.getByLabel('It is the card information');
await expect(
card.getByText('Others are editing. Your network prevent changes.'),
).toBeHidden();
const editor = page.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'true');
let responseCanEditPromise = page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Editing');
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
const urlParentDoc = page.url();
const { name: childTitle } = await createRootSubPage(
page,
browserName,
'editing-blocking - child',
);
let responseCanEdit = await responseCanEditPromise;
expect(responseCanEdit.ok()).toBeTruthy();
let jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
expect(jsonCanEdit.can_edit).toBeTruthy();
const urlChildDoc = page.url();
/**
* We open another browser that will connect to the collaborative server
* and will block the current browser to edit the doc.
*/
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl: urlChildDoc,
docTitle: childTitle,
withoutSignIn: true,
});
const webSocketPromise = otherPage.waitForEvent(
'websocket',
(webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
},
);
await otherPage.goto(urlChildDoc);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
await verifyDocName(otherPage, childTitle);
await page.reload();
responseCanEdit = await page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
expect(responseCanEdit.ok()).toBeTruthy();
jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
expect(jsonCanEdit.can_edit).toBeFalsy();
await expect(
card.getByText('Others are editing. Your network prevent changes.'),
).toBeVisible({
timeout: 10000,
});
await expect(editor).toHaveAttribute('contenteditable', 'false');
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toBeHidden();
await expect(page.getByRole('heading', { name: childTitle })).toBeVisible();
await page.goto(urlParentDoc);
await verifyDocName(page, parentTitle);
await page.getByRole('button', { name: 'Share' }).click();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitemradio', { name: 'Reading' }).click();
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
await page.goto(urlChildDoc);
await expect(editor).toHaveAttribute('contenteditable', 'true');
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText(childTitle);
await expect(page.getByRole('heading', { name: childTitle })).toBeHidden();
await expect(
card.getByText('Others are editing. Your network prevent changes.'),
).toBeHidden();
await cleanup();
});
test('checks disconnection and reconnection when changing tab visibility', async ({
page,
}) => {
await overrideConfig(page, {
COLLABORATION_WS_INACTIVITY_TIMEOUT: 2, // 2 seconds for the test to be faster
});
await page.goto('/');
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
});
await page
.getByRole('button', {
name: 'New doc',
})
.click();
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
await writeInEditor({ page, text: 'Hello World' });
let framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
// When the visibility is changed, the ws should close the connection
const wsClosePromise = webSocket.waitForEvent('close');
// Simulate the tab being hidden
await page.evaluate(() => {
Object.defineProperty(document, 'hidden', {
value: true,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
});
// Assert the ws connection is closed after inactivity timeout
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Check the ws is connected again
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
});
// Simulate the tab becoming visible again
await page.evaluate(() => {
Object.defineProperty(document, 'hidden', {
value: false,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
});
webSocket = await webSocketPromise;
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
// Assert the ws connection is working again
expect(framesent.payload).not.toBeNull();
});
});

View File

@@ -3,14 +3,9 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import {
createDoc,
goToGridDoc,
overrideConfig,
verifyDocName,
} from './utils-common';
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor';
import { connectOtherUserToDoc, updateShareLink } from './utils-share';
import { updateShareLink } from './utils-share';
import {
createRootSubPage,
getTreeRow,
@@ -65,19 +60,6 @@ test.describe('Doc Editor', () => {
toolbar.locator('button[data-test="createLink"]'),
).toBeVisible();
/**
* Because of how Posthog is loaded and how auth session are
* saved, this assertion is not reliable on test instances
* We will dedicate a testcase to check the AI features
* on test instances with a specific setup
*/
if (process.env.IS_INSTANCE !== 'true') {
// eslint-disable-next-line playwright/no-conditional-expect
await expect(
toolbar.getByRole('button', { name: 'Ask AI' }),
).toBeVisible();
}
await expect(
toolbar.locator('button[data-test="comment-toolbar-button"]'),
).toBeVisible();
@@ -109,7 +91,6 @@ test.describe('Doc Editor', () => {
await image.click();
await expect(toolbar.getByRole('button', { name: 'Ask AI' })).toBeHidden();
await expect(
toolbar.locator('button[data-test="comment-toolbar-button"]'),
).toBeHidden();
@@ -125,63 +106,6 @@ test.describe('Doc Editor', () => {
).toBeVisible();
});
/**
* We check:
* - connection to the collaborative server
* - signal of the backend to the collaborative server (connection should close)
* - reconnection to the collaborative server
*/
test('checks the connection with collaborative server', async ({ page }) => {
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
});
await page
.getByRole('button', {
name: 'New doc',
})
.click();
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
await writeInEditor({ page, text: 'Hello World' });
let framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
// When the visibility is changed, the ws should close the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Check the ws is connected again
webSocket = await page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
});
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
});
test('markdown button converts from markdown to the editor syntax json', async ({
page,
browserName,
@@ -299,70 +223,6 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
});
test('it cannot edit if viewer but see and can get resources', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(page, 'doc-viewer', browserName, 1);
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello World' });
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Reading');
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl: page.url(),
withoutSignIn: true,
docTitle,
});
await expect(
otherPage.getByLabel('It is the card information').getByText('Reader'),
).toBeVisible();
// Cannot edit
const editor = otherPage.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'false');
// Owner add a image
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
// Owner see the image
await expect(
page.locator('.--docs--editor-container img.bn-visual-media').first(),
).toBeVisible();
// Viewser see the image
const viewerImg = otherPage
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(viewerImg).toBeVisible({
timeout: 10000,
});
// Viewer can download the image
await viewerImg.click();
const downloadPromise = otherPage.waitForEvent('download');
await otherPage.getByRole('button', { name: 'Download image' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('logo-suite-numerique.png');
await cleanup();
});
test('it adds an image to the doc editor', async ({ page, browserName }) => {
await createDoc(page, 'doc-image', browserName, 1);
@@ -507,151 +367,6 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Analyzing file...')).toBeHidden();
});
if (process.env.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY === 'true') {
test('it checks block editing when not connected to collab server', async ({
page,
browserName,
}) => {
test.slow();
/**
* The good port is 4444, but we want to simulate a not connected
* collaborative server.
* So we use a port that is not used by the collaborative server.
* The server will not be able to connect to the collaborative server.
*/
await overrideConfig(page, {
COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/',
});
await page.goto('/');
const [parentTitle] = await createDoc(
page,
'editing-blocking',
browserName,
1,
);
const card = page.getByLabel('It is the card information');
await expect(
card.getByText('Others are editing. Your network prevent changes.'),
).toBeHidden();
const editor = page.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'true');
let responseCanEditPromise = page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Editing');
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
const urlParentDoc = page.url();
const { name: childTitle } = await createRootSubPage(
page,
browserName,
'editing-blocking - child',
);
let responseCanEdit = await responseCanEditPromise;
expect(responseCanEdit.ok()).toBeTruthy();
let jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
expect(jsonCanEdit.can_edit).toBeTruthy();
const urlChildDoc = page.url();
/**
* We open another browser that will connect to the collaborative server
* and will block the current browser to edit the doc.
*/
const { otherPage } = await connectOtherUserToDoc({
browserName,
docUrl: urlChildDoc,
docTitle: childTitle,
withoutSignIn: true,
});
const webSocketPromise = otherPage.waitForEvent(
'websocket',
(webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
},
);
await otherPage.goto(urlChildDoc);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
await verifyDocName(otherPage, childTitle);
await page.reload();
responseCanEdit = await page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
expect(responseCanEdit.ok()).toBeTruthy();
jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
expect(jsonCanEdit.can_edit).toBeFalsy();
await expect(
card.getByText('Others are editing. Your network prevent changes.'),
).toBeVisible({
timeout: 10000,
});
await expect(editor).toHaveAttribute('contenteditable', 'false');
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toBeHidden();
await expect(
page.getByRole('heading', { name: childTitle }),
).toBeVisible();
await page.goto(urlParentDoc);
await verifyDocName(page, parentTitle);
await page.getByRole('button', { name: 'Share' }).click();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitemradio', { name: 'Reading' }).click();
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
await page.goto(urlChildDoc);
await expect(editor).toHaveAttribute('contenteditable', 'true');
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText(childTitle);
await expect(
page.getByRole('heading', { name: childTitle }),
).toBeHidden();
await expect(
card.getByText('Others are editing. Your network prevent changes.'),
).toBeHidden();
});
}
test('it checks if callout custom block', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
@@ -702,25 +417,23 @@ test.describe('Doc Editor', () => {
test('it checks interlink feature', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1);
await verifyDocName(page, randomDoc);
const { name: docChild1 } = await createRootSubPage(
page,
browserName,
'doc-interlink-child-1',
);
await verifyDocName(page, docChild1);
const { name: docChild2 } = await createRootSubPage(
page,
browserName,
'doc-interlink-child-2',
);
await verifyDocName(page, docChild2);
const treeRow = await getTreeRow(page, docChild2);
// To let the time for the emoji-picker to load
await page.waitForTimeout(500);
await treeRow.locator('.--docs--doc-icon').click();
await page.getByRole('button', { name: '😀' }).first().click();

View File

@@ -104,6 +104,9 @@ test.describe('Doc Header', () => {
browserName,
1,
);
await writeInEditor({ page, text: 'Hello Content' });
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Editing');
@@ -116,7 +119,9 @@ test.describe('Doc Header', () => {
docTitle,
});
// Wait for other page to sync
await expect(otherPage.getByText('Hello Content')).toBeVisible();
// Wait for other page to broadcast sync
await page.waitForTimeout(1000);
await page.keyboard.press('Escape');
@@ -124,9 +129,8 @@ test.describe('Doc Header', () => {
await expect(elTitle).toBeVisible();
await elTitle.fill('Hello World');
await elTitle.blur();
await verifyDocName(page, 'Hello World');
// Wait for other page to sync
// Wait for other page to broadcast sync
await page.waitForTimeout(1000);
// Check other user page
@@ -144,6 +148,36 @@ test.describe('Doc Header', () => {
await cleanup();
});
test('it pastes plain text in the title without keeping formatting', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-title-paste', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'Document title' });
await docTitle.click();
await page.keyboard.press('Control+a');
await page.evaluate(() => {
const el = document.querySelector('[aria-label="Document title"]');
if (!el) {
return;
}
const dt = new DataTransfer();
dt.setData('text/plain', 'Pasted plain text');
dt.setData('text/html', '<b><em>Pasted plain text</em></b>');
el.dispatchEvent(
new ClipboardEvent('paste', { clipboardData: dt, bubbles: true }),
);
});
await docTitle.blur();
await expect(docTitle).toHaveText('Pasted plain text');
// Ensure formatting tags from text/html were not inserted.
await expect(docTitle.locator('b, em, strong, i')).toHaveCount(0);
});
test('it updates the title doc adding a leading emoji', async ({
page,
browserName,
@@ -522,7 +556,6 @@ test.describe('Doc Header', () => {
name: 'Share',
exact: true,
});
await expect(shareButton).toBeVisible();
await shareButton.click();
await page.getByRole('button', { name: 'Copy link' }).click();
@@ -533,10 +566,8 @@ test.describe('Doc Header', () => {
);
const clipboardContent = await handle.jsonValue();
const origin = await page.evaluate(() => window.location.origin);
expect(clipboardContent.trim()).toMatch(
`${origin}/docs/mocked-document-id/`,
);
const url = page.url();
expect(clipboardContent.trim()).toMatch(url);
});
test('it pins a document', async ({ page, browserName }) => {

View File

@@ -31,6 +31,8 @@ test.describe('Inherited share accesses', () => {
.getByRole('link')
.click();
await page.getByRole('button', { name: 'close' }).first().click();
await verifyDocName(page, parentTitle);
});

View File

@@ -185,23 +185,23 @@ test.describe('Doc Version', () => {
await page.getByLabel('Restore', { exact: true }).click();
await page.waitForTimeout(500);
const mainEditor = page.getByLabel('Document editor');
await expect(editor.getByText('Hello')).toBeVisible();
await expect(editor.getByText('World')).toBeHidden();
await expect(mainEditor.getByText('Hello')).toBeVisible();
await expect(mainEditor.getByText('World')).toBeHidden();
// The old comment is not restored
await expect(editor.getByText('Hello')).toHaveCSS(
await expect(mainEditor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
// We can add a new comment
await editor.getByText('Hello').selectText();
await mainEditor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Add comment' }).click();
await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click();
await expect(editor.getByText('Hello')).toHaveClass('bn-thread-mark');
await expect(mainEditor.getByText('Hello')).toHaveClass('bn-thread-mark');
});
});

View File

@@ -153,7 +153,8 @@ test.describe('Help feature', () => {
theme_customization: {
onboarding: {
enabled: true,
learn_more_url: 'https://example.com/learn-more',
learn_more_url: 'http://localhost:3000/learn-more',
ready_template_url: 'http://localhost:3000/ready-template',
},
},
});
@@ -184,18 +185,19 @@ test.describe('Help feature', () => {
'0',
);
await page.getByTestId('onboarding-step-3').click();
await expect(page.getByTestId('onboarding-step-3')).toHaveAttribute(
'tabindex',
'0',
);
const step3 = page.getByTestId('onboarding-step-3');
await step3.click();
await expect(step3).toHaveAttribute('tabindex', '0');
await expect(
step3.getByRole('link', { name: 'ready-made template' }),
).toHaveAttribute('href', 'http://localhost:3000/ready-template');
const learnMoreLink = page.getByRole('link', {
name: 'Learn more docs features',
});
await expect(learnMoreLink).toHaveAttribute(
'href',
'https://example.com/learn-more',
'http://localhost:3000/learn-more',
);
await learnMoreLink.click();
@@ -241,6 +243,16 @@ test.describe('Help feature', () => {
await expect(
modal.getByRole('button', { name: /Suivant/i }),
).toBeVisible();
await modal
.getByText(/Tirez parti de la bibliothèque de contenu/)
.first()
.click();
await expect(
modal.getByText(/Commencez à partir de/).first(),
).toBeVisible();
await expect(modal.getByRole('link')).toHaveText(
"modèles prêts à l'emploi",
);
});
test('Modal is displayed automatically on first connection', async ({

View File

@@ -131,7 +131,7 @@ test.describe('Language', () => {
await waitForLanguageSwitch(page, TestLanguage.French);
// Check for French 404 response
await check404Response('Pas trouvé.');
await check404Response('Non trouvé.');
});
test('it check translations of the slash menu when changing language', async ({

View File

@@ -13,11 +13,12 @@ export const CONFIG = {
name: 'Docs AI',
color: '#8bc6ff',
},
AI_FEATURE_ENABLED: true,
AI_FEATURE_BLOCKNOTE_ENABLED: true,
AI_FEATURE_ENABLED: false,
AI_FEATURE_BLOCKNOTE_ENABLED: false,
AI_FEATURE_LEGACY_ENABLED: true,
API_USERS_SEARCH_QUERY_MIN_LENGTH: 3,
CRISP_WEBSITE_ID: null,
COLLABORATION_WS_INACTIVITY_TIMEOUT: 15,
COLLABORATION_WS_URL: process.env.COLLABORATION_WS_URL,
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
CONVERSION_UPLOAD_ENABLED: true,
@@ -131,6 +132,13 @@ export const createDoc = async (
await openHeaderMenu(page);
}
const responsePromiseCreateDoc = page.waitForResponse(
(response) =>
response.url().includes('/api/v1.0/documents/') &&
response.status() === 201 &&
response.request().method() === 'POST',
);
await page
.getByRole('button', {
name: 'New doc',
@@ -142,34 +150,46 @@ export const createDoc = async (
waitUntil: 'networkidle',
});
const responseCreateDoc = await responsePromiseCreateDoc;
expect(responseCreateDoc.ok()).toBeTruthy();
const { id: docId } = (await responseCreateDoc.json()) as { id: string };
const responsePromiseUpdateDoc = page.waitForResponse(
(response) =>
response.url().includes(`/api/v1.0/documents/${docId}`) &&
response.status() === 200 &&
response.request().method() === 'PATCH',
);
const input = page.getByLabel('Document title');
await expect(input).toBeVisible();
await expect(input).toHaveText('');
await expect(input).toBeVisible({
timeout: 10000,
});
await expect(input).toHaveText('', {
timeout: 10000,
});
await input.fill(randomDocs[i]);
await input.blur();
void input.blur();
const responseUpdateDoc = await responsePromiseUpdateDoc;
expect(responseUpdateDoc.ok()).toBeTruthy();
}
return randomDocs;
};
export const verifyDocName = async (page: Page, docName: string) => {
await expect(
page.getByLabel('It is the card information about the document.'),
).toBeVisible({
const card = page.getByLabel(
'It is the card information about the document.',
);
await expect(card).toBeVisible({
timeout: 10000,
});
/*replace toHaveText with toContainText to handle cases where emojis or other characters might be added*/
try {
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText(docName, {
timeout: 3000,
});
} catch {
await expect(page.getByRole('heading', { name: docName })).toBeVisible();
}
await expect(card).toHaveText(new RegExp(docName), {
timeout: 10000,
});
};
export const getGridRow = async (page: Page, title: string) => {
@@ -231,11 +251,9 @@ export const updateDocTitle = async (page: Page, title: string) => {
const input = page.getByRole('textbox', { name: 'Document title' });
await expect(input).toHaveText('');
await expect(input).toBeVisible();
await input.click();
await input.fill(title, {
force: true,
});
await input.click();
await input.blur();
await verifyDocName(page, title);
};
@@ -251,15 +269,17 @@ export const waitForResponseCreateDoc = (page: Page) => {
export const mockedDocument = async (page: Page, data: object) => {
// document/[ID]/ or document/[ID]/tree/ routes
let uuid: string | undefined;
await page.route(/.*\/documents\/[^/]+\/(?:$|tree\/.*)/, async (route) => {
const request = route.request();
if (request.method().includes('GET') && !request.url().includes('page=')) {
uuid = request.url().match(/\/documents\/([^/]+)\//)?.[1];
const { abilities, ...doc } = data as unknown as {
abilities?: Record<string, unknown>;
};
await route.fulfill({
json: {
id: 'mocked-document-id',
id: uuid,
title: 'Mocked document',
path: '000000',
abilities: {
@@ -304,6 +324,8 @@ export const mockedDocument = async (page: Page, data: object) => {
await route.continue();
}
});
return uuid;
};
export const mockedListDocs = async (page: Page, data: object[] = []) => {

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "4.8.6",
"version": "5.0.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.58.2",
"@playwright/test": "1.59.1",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-plugin-docs": "*",
@@ -24,7 +24,7 @@
"dependencies": {
"@types/pngjs": "6.0.5",
"convert-stream": "1.0.2",
"dotenv": "17.3.1",
"dotenv": "17.4.2",
"pdf-parse": "2.4.5",
"pixelmatch": "7.1.0",
"pngjs": "7.0.0"

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "es2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,

View File

@@ -6,6 +6,7 @@ const buildId = crypto.randomBytes(256).toString('hex').slice(0, 8);
/** @type {import('next').NextConfig} */
const nextConfig = {
allowedDevOrigins: ['docs.127.0.0.1.nip.io'],
output: 'export',
trailingSlash: true,
images: {

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "4.8.6",
"version": "5.0.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -23,60 +23,60 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@ai-sdk/openai": "3.0.47",
"@blocknote/code-block": "0.47.3",
"@blocknote/core": "0.47.3",
"@blocknote/mantine": "0.47.3",
"@blocknote/react": "0.47.3",
"@blocknote/xl-ai": "0.47.3",
"@blocknote/xl-docx-exporter": "0.47.3",
"@blocknote/xl-multi-column": "0.47.3",
"@blocknote/xl-odt-exporter": "0.47.3",
"@blocknote/xl-pdf-exporter": "0.47.3",
"@ai-sdk/openai": "3.0.53",
"@blocknote/code-block": "0.49.0",
"@blocknote/core": "0.49.0",
"@blocknote/mantine": "0.49.0",
"@blocknote/react": "0.49.0",
"@blocknote/xl-ai": "0.49.0",
"@blocknote/xl-docx-exporter": "0.49.0",
"@blocknote/xl-multi-column": "0.49.0",
"@blocknote/xl-odt-exporter": "0.49.0",
"@blocknote/xl-pdf-exporter": "0.49.0",
"@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.38",
"@fontsource-variable/material-symbols-outlined": "5.2.42",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/cunningham-react": "4.2.0",
"@gouvfr-lasuite/cunningham-react": "4.3.0",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.19.10",
"@gouvfr-lasuite/ui-kit": "0.20.1",
"@hocuspocus/provider": "3.4.4",
"@mantine/core": "8.3.18",
"@mantine/hooks": "8.3.18",
"@react-aria/live-announcer": "3.4.4",
"@mantine/core": "9.0.2",
"@mantine/hooks": "9.0.2",
"@react-aria/live-announcer": "3.5.0",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.45.0",
"@tanstack/react-query": "5.95.0",
"@sentry/nextjs": "10.49.0",
"@tanstack/react-query": "5.99.2",
"@tiptap/extensions": "*",
"ai": "6.0.134",
"ai": "6.0.168",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.27",
"crisp-sdk-web": "1.1.1",
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.6.0",
"i18next": "25.10.4",
"i18next": "26.0.6",
"i18next-browser-languagedetector": "8.2.1",
"idb": "8.0.3",
"lodash": "4.18.1",
"luxon": "3.7.2",
"next": "16.2.3",
"posthog-js": "1.363.1",
"next": "16.2.4",
"posthog-js": "1.369.4",
"react": "*",
"react-aria-components": "1.16.0",
"react-aria-components": "1.17.0",
"react-dom": "*",
"react-dropzone": "15.0.0",
"react-i18next": "16.6.1",
"react-i18next": "17.0.4",
"react-intersection-observer": "10.0.3",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.3.12",
"use-debounce": "10.1.0",
"uuid": "13.0.0",
"styled-components": "6.4.0",
"use-debounce": "10.1.1",
"uuid": "14.0.0",
"y-protocols": "1.0.7",
"yjs": "*",
"zod": "4.3.6",
@@ -84,7 +84,7 @@
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.95.0",
"@tanstack/react-query-devtools": "5.99.2",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
@@ -96,18 +96,18 @@
"@types/react-dom": "*",
"@vitejs/plugin-react": "6.0.1",
"cross-env": "10.1.0",
"dotenv": "17.3.1",
"dotenv": "17.4.2",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "29.0.1",
"jsdom": "29.0.2",
"node-fetch": "2.7.0",
"prettier": "3.8.1",
"prettier": "3.8.3",
"stylelint": "16.26.1",
"stylelint-config-standard": "39.0.1",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vitest": "4.1.0",
"webpack": "5.105.4",
"vitest": "4.1.4",
"webpack": "5.106.2",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"

View File

@@ -1,12 +1,13 @@
import { Ref, forwardRef } from 'react';
import { ComponentPropsWithRef, Ref, forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxType } from './Box';
import { Box, BoxProps } from './Box';
export type BoxButtonType = Omit<BoxType, 'ref'> & {
disabled?: boolean;
ref?: Ref<HTMLButtonElement>;
};
export type BoxButtonType = BoxProps &
Omit<ComponentPropsWithRef<'button'>, keyof BoxProps | 'ref'> & {
disabled?: boolean;
ref?: Ref<HTMLButtonElement>;
};
/**
* Styleless button that extends the Box component.
@@ -59,7 +60,7 @@ const BoxButton = forwardRef<HTMLButtonElement, BoxButtonType>(
if (disabled) {
return;
}
props.onClick?.(event as unknown as React.MouseEvent<HTMLDivElement>);
props.onClick?.(event);
}}
/>
);

View File

@@ -34,9 +34,7 @@ export const TextStyled = styled(Box)<TextProps>`
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
(props, ref) => {
return (
<TextStyled ref={ref as React.Ref<HTMLDivElement>} as="span" {...props} />
);
return <TextStyled ref={ref} as="span" {...props} />;
},
);

View File

@@ -2,7 +2,7 @@ import {
Button,
ButtonProps,
Modal,
ModalProps,
ModalDefaultVariantProps,
ModalSize,
} from '@gouvfr-lasuite/cunningham-react';
import { ReactNode, useEffect } from 'react';
@@ -20,7 +20,7 @@ export type AlertModalProps = {
title: string;
cancelLabel?: string;
confirmLabel?: string;
} & Partial<ModalProps>;
} & Partial<ModalDefaultVariantProps>;
export const AlertModal = ({
cancelLabel,

View File

@@ -49,7 +49,7 @@ export const SideModal = ({
return (
<>
<SideModalStyle width={width} side={side} $css={$css} />
<Modal {...modalProps} size={ModalSize.FULL}>
<Modal {...modalProps} size={ModalSize.FULL} variant="default">
{children}
</Modal>
</>

View File

@@ -28,6 +28,7 @@ interface ThemeCustomization {
onboarding?: {
enabled: true;
learn_more_url?: string;
ready_template_url?: string;
};
translations?: Resource;
waffle?: WaffleType;
@@ -41,6 +42,7 @@ export interface ConfigResponse {
API_USERS_SEARCH_QUERY_MIN_LENGTH?: number;
COLLABORATION_WS_URL?: string;
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean;
COLLABORATION_WS_INACTIVITY_TIMEOUT?: number | null;
CONVERSION_FILE_EXTENSIONS_ALLOWED: string[];
CONVERSION_FILE_MAX_SIZE: number;
CONVERSION_UPLOAD_ENABLED?: boolean;

View File

@@ -70,6 +70,7 @@
.c__modal__title {
padding: 0;
display: block;
}
.c__modal__footer {

View File

@@ -361,7 +361,7 @@
--c--globals--font--weights--medium: 500;
--c--globals--font--weights--bold: 600;
--c--globals--font--weights--extrabold: 800;
--c--globals--font--weights--black: 900;
--c--globals--font--weights--black: 800;
--c--globals--font--families--base:
inter variable, roboto flex variable, sans-serif;
--c--globals--font--families--accent:
@@ -849,6 +849,18 @@
--c--components--forms-checkbox--font-size: var(
--c--globals--font--sizes--sm
);
--c--components--forms-input--border-radius: 4px;
--c--components--forms-input--border-radius--hover: 4px;
--c--components--forms-input--border-radius--focus: 4px;
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius--hover: 4px;
--c--components--forms-select--border-radius--focus: 4px;
--c--components--forms-textarea--border-radius: 4px;
--c--components--forms-textarea--border-radius--hover: 4px;
--c--components--forms-textarea--border-radius--focus: 4px;
--c--components--forms-datepicker--border-radius: 4px;
--c--components--forms-datepicker--border-radius--hover: 4px;
--c--components--forms-datepicker--border-radius--focus: 4px;
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
--c--components--badge--border-radius: 12px;
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
@@ -1731,7 +1743,6 @@
--c--globals--font--sizes--xs-alt: 3rem;
--c--globals--font--weights--thin: 100;
--c--globals--font--weights--extrabold: 800;
--c--globals--font--weights--black: 900;
--c--globals--font--families--accent:
marianne, inter variable, roboto flex variable, sans-serif;
--c--globals--font--families--base:
@@ -2539,6 +2550,18 @@
--c--components--forms-checkbox--font-size: var(
--c--globals--font--sizes--sm
);
--c--components--forms-input--border-radius: 4px;
--c--components--forms-input--border-radius--hover: 4px;
--c--components--forms-input--border-radius--focus: 4px;
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius--hover: 4px;
--c--components--forms-select--border-radius--focus: 4px;
--c--components--forms-textarea--border-radius: 4px;
--c--components--forms-textarea--border-radius--hover: 4px;
--c--components--forms-textarea--border-radius--focus: 4px;
--c--components--forms-datepicker--border-radius: 4px;
--c--components--forms-datepicker--border-radius--hover: 4px;
--c--components--forms-datepicker--border-radius--focus: 4px;
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
--c--components--badge--border-radius: 12px;
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);

View File

@@ -372,7 +372,7 @@ export const tokens = {
medium: 500,
bold: 600,
extrabold: 800,
black: 900,
black: 800,
},
families: {
base: 'Inter Variable, Roboto Flex Variable, sans-serif',
@@ -664,6 +664,26 @@ export const tokens = {
'body--background-color-hover': '#F0F0F3',
},
'forms-checkbox': { 'font-size': '0.875rem' },
'forms-input': {
'border-radius': '4px',
'border-radius--hover': '4px',
'border-radius--focus': '4px',
},
'forms-select': {
'border-radius': '4px',
'border-radius--hover': '4px',
'border-radius--focus': '4px',
},
'forms-textarea': {
'border-radius': '4px',
'border-radius--hover': '4px',
'border-radius--focus': '4px',
},
'forms-datepicker': {
'border-radius': '4px',
'border-radius--hover': '4px',
'border-radius--focus': '4px',
},
badge: {
'font-size': '0.75rem',
'border-radius': '12px',
@@ -1334,7 +1354,7 @@ export const tokens = {
'sm-alt': '3.5rem',
'xs-alt': '3rem',
},
weights: { thin: 100, extrabold: 800, black: 900 },
weights: { thin: 100, extrabold: 800 },
families: {
accent:
'Marianne, Inter Variable, Roboto Flex Variable, sans-serif',
@@ -1948,6 +1968,26 @@ export const tokens = {
'body--background-color-hover': '#F0F0F3',
},
'forms-checkbox': { 'font-size': '0.875rem' },
'forms-input': {
'border-radius': '4px',
'border-radius--hover': '4px',
'border-radius--focus': '4px',
},
'forms-select': {
'border-radius': '4px',
'border-radius--hover': '4px',
'border-radius--focus': '4px',
},
'forms-textarea': {
'border-radius': '4px',
'border-radius--hover': '4px',
'border-radius--focus': '4px',
},
'forms-datepicker': {
'border-radius': '4px',
'border-radius--hover': '4px',
'border-radius--focus': '4px',
},
badge: {
'font-size': '0.75rem',
'border-radius': '12px',

View File

@@ -35,7 +35,7 @@ const initialState: ThemeStore = {
colorsTokens: defaultTokens.globals.colors,
componentTokens: defaultTokens.components,
contextualTokens: defaultTokens.contextuals,
currentTokens: tokens.themes[DEFAULT_THEME] as Partial<Tokens>,
currentTokens: tokens.themes[DEFAULT_THEME],
fontSizesTokens: defaultTokens.globals.font.sizes,
setTheme: () => {},
spacingsTokens: defaultTokens.globals.spacings,

View File

@@ -1,12 +1,13 @@
import React from 'react';
import React, { ComponentPropsWithRef } from 'react';
import { Box, BoxType } from '@/components';
import { Box, BoxProps } from '@/components';
type AvatarSvgProps = {
initials: string;
background: string;
fontFamily?: string;
} & BoxType;
type AvatarSvgProps = BoxProps &
Omit<ComponentPropsWithRef<'svg'>, keyof BoxProps> & {
initials: string;
background: string;
fontFamily?: string;
};
export const AvatarSvg: React.FC<AvatarSvgProps> = ({
initials,

View File

@@ -11,11 +11,15 @@ vi.mock('@/stores', () => ({
useResponsiveStore: () => ({ isDesktop: false }),
}));
vi.mock('@/features/skeletons', () => ({
useSkeletonStore: () => ({
setIsSkeletonVisible: vi.fn(),
}),
}));
vi.mock('@/features/skeletons', async () => {
const actual = await vi.importActual<any>('../../../skeletons');
return {
...actual,
useSkeletonStore: () => ({
setIsSkeletonVisible: vi.fn(),
}),
};
});
vi.mock('../../doc-management', async () => {
const actual = await vi.importActual<any>('../../doc-management');

View File

@@ -0,0 +1,33 @@
import { sanitizeColor } from '../utils';
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
describe('sanitizeColor', () => {
it('accepts valid 6-digit hex colors', () => {
expect(sanitizeColor('#1a2b3c')).toBe('#1a2b3c');
expect(sanitizeColor('#AABBCC')).toBe('#AABBCC');
expect(sanitizeColor('#000000')).toBe('#000000');
expect(sanitizeColor('#ffffff')).toBe('#ffffff');
});
it('rejects 3-digit hex colors and returns a valid random hex color', () => {
expect(sanitizeColor('#abc')).toMatch(HEX_COLOR_RE);
});
it('rejects named colors and returns a valid random hex color', () => {
expect(sanitizeColor('red')).toMatch(HEX_COLOR_RE);
expect(sanitizeColor('blue')).toMatch(HEX_COLOR_RE);
});
it('rejects CSS injection attempts and returns a valid random hex color', () => {
expect(sanitizeColor('red; behavior: expression(alert(1))')).toMatch(
HEX_COLOR_RE,
);
expect(sanitizeColor('#fff; color: red')).toMatch(HEX_COLOR_RE);
expect(sanitizeColor('javascript:alert(1)')).toMatch(HEX_COLOR_RE);
});
it('rejects empty string and returns a valid random hex color', () => {
expect(sanitizeColor('')).toMatch(HEX_COLOR_RE);
});
});

View File

@@ -15,7 +15,6 @@ import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import type { Awareness } from 'y-protocols/awareness';
import * as Y from 'yjs';
@@ -35,14 +34,14 @@ import {
useUploadStatus,
} from '../hook';
import { useEditorStore } from '../stores';
import { cssEditor } from '../styles';
import { DocsEditorStyle } from '../styles';
import { DocsBlockNoteEditor } from '../types';
import { randomColor } from '../utils';
import { randomColor, sanitizeColor } from '../utils';
import BlockNoteAI from './AI';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { cssComments, useComments } from './comments/';
import { DocsCommentsStyle, useComments } from './comments/';
import {
AccessibleImageBlock,
CalloutBlock,
@@ -153,12 +152,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
*/
renderCursor: (user: { color: string; name: string }) => {
const cursorElement = document.createElement('span');
const safeColor = sanitizeColor(user.color);
cursorElement.classList.add('collaboration-cursor-custom__base');
const caretElement = document.createElement('span');
caretElement.classList.add('collaboration-cursor-custom__caret');
caretElement.setAttribute('spellcheck', `false`);
caretElement.setAttribute('style', `background-color: ${user.color}`);
caretElement.setAttribute('style', `background-color: ${safeColor}`);
if (showCursorLabels === 'always') {
cursorElement.setAttribute('data-active', '');
@@ -170,7 +170,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
labelElement.setAttribute('spellcheck', `false`);
labelElement.setAttribute(
'style',
`background-color: ${user.color};border: 1px solid ${user.color};`,
`background-color: ${safeColor};border: 1px solid ${safeColor};`,
);
labelElement.insertBefore(document.createTextNode(user.name), null);
@@ -260,13 +260,12 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
}, [setEditor, editor]);
return (
<Box
ref={refEditorContainer}
$css={css`
${cssEditor};
${cssComments(showComments, currentUserAvatarUrl)}
`}
>
<Box ref={refEditorContainer} $height="100%">
<DocsEditorStyle />
<DocsCommentsStyle
canSeeComment={canSeeComment}
currentUserAvatarUrl={currentUserAvatarUrl}
/>
{errorAttachment && (
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
<TextErrors
@@ -350,12 +349,9 @@ export const BlockNoteReader = ({
useHeadings(editor);
return (
<Box
$css={css`
${cssEditor};
${cssComments(false)}
`}
>
<Box>
<DocsEditorStyle />
<DocsCommentsStyle canSeeComment={false} />
<BlockNoteView
className="--docs--main-editor"
editor={editor}

View File

@@ -1,37 +1,41 @@
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { PropsWithChildren, useEffect, useState } from 'react';
import { css } from 'styled-components';
import { Box, Loading } from '@/components';
import { Box } from '@/components';
import { DocHeader } from '@/docs/doc-header/';
import {
Doc,
LinkReach,
getDocLinkReach,
useCollaboration,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
import { TableContent } from '@/docs/doc-table-content/';
import { useAuth } from '@/features/auth/';
import { useSkeletonStore } from '@/features/skeletons';
import { SkeletonEditorCore, useSkeletonStore } from '@/features/skeletons';
import { useSkeletonFadeOut } from '@/features/skeletons/hooks/useFadeOut';
import { useAnalytics } from '@/libs';
import { useResponsiveStore } from '@/stores';
import { useCollaboration } from '../hook/useCollaboration';
import { BlockNoteEditor, BlockNoteReader } from './BlockNoteEditor';
const DOCS_EDITOR_CLASS = '--docs--doc-editor';
interface DocEditorContainerProps {
docHeader: React.ReactNode;
docEditor: React.ReactNode;
isDeletedDoc: boolean;
readOnly: boolean;
}
export const DocEditorContainer = ({
children,
docHeader,
docEditor,
isDeletedDoc,
readOnly,
}: DocEditorContainerProps) => {
}: PropsWithChildren<DocEditorContainerProps>) => {
const { isDesktop } = useResponsiveStore();
return (
@@ -39,8 +43,8 @@ export const DocEditorContainer = ({
<Box
$maxWidth="868px"
$width="100%"
$height="100%"
className="--docs--doc-editor"
$flex="1"
className={DOCS_EDITOR_CLASS}
>
<Box
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
@@ -66,7 +70,7 @@ export const DocEditorContainer = ({
})}
$height="100%"
>
{docEditor}
{children}
</Box>
</Box>
</Box>
@@ -82,23 +86,19 @@ interface DocEditorProps {
export const DocEditor = ({ doc }: DocEditorProps) => {
useCollaboration(doc.id);
const { isDesktop } = useResponsiveStore();
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;
const { trackEvent } = useAnalytics();
const [hasTracked, setHasTracked] = useState(false);
const { authenticated } = useAuth();
const isPublicDoc = getDocLinkReach(doc) === LinkReach.PUBLIC;
const { setIsSkeletonVisible } = useSkeletonStore();
useEffect(() => {
if (isProviderReady) {
setIsSkeletonVisible(false);
}
}, [isProviderReady, setIsSkeletonVisible]);
setIsSkeletonVisible(false);
}, [setIsSkeletonVisible, doc.id]);
/**
* Track doc view event only once per doc change
@@ -124,30 +124,56 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
});
}, [authenticated, hasTracked, isPublicDoc, trackEvent]);
if (!isProviderReady || provider?.configuration.name !== doc.id) {
return <Loading />;
}
return (
<>
{isDesktop && <TableContent />}
{isDesktop && <TableContent selector={`.${DOCS_EDITOR_CLASS}`} />}
<DocEditorContainer
docHeader={<DocHeader doc={doc} />}
docEditor={
readOnly ? (
<BlockNoteReader
initialContent={provider.document.getXmlFragment(
'document-store',
)}
docId={doc.id}
/>
) : (
<BlockNoteEditor doc={doc} provider={provider} />
)
}
isDeletedDoc={isDeletedDoc}
readOnly={readOnly}
/>
>
<DocCoreEditor doc={doc} readOnly={readOnly} />
</DocEditorContainer>
</>
);
};
interface DocCoreEditorProps {
doc: Doc;
readOnly: boolean;
}
export const DocCoreEditor = ({ doc, readOnly }: DocCoreEditorProps) => {
const { provider, isReady } = useProviderStore();
const isProviderReady = isReady && provider;
const showContent = !!(
isProviderReady && provider?.configuration.name === doc.id
);
const { skeletonVisible, isFadingOut } = useSkeletonFadeOut(showContent);
if (
skeletonVisible ||
!isProviderReady ||
provider?.configuration.name !== doc.id
) {
return (
<SkeletonEditorCore
isFadingOut={isFadingOut}
$css={css`
padding-top: 0px;
`}
/>
);
}
if (readOnly) {
return (
<BlockNoteReader
initialContent={provider.document.getXmlFragment('document-store')}
docId={doc.id}
/>
);
}
return <BlockNoteEditor doc={doc} provider={provider} />;
};

View File

@@ -1,5 +1,6 @@
import { CommentBody, ThreadStore } from '@blocknote/core/comments';
import type { Awareness } from 'y-protocols/awareness';
import * as Y from 'yjs';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/features/docs/doc-management';
@@ -17,6 +18,13 @@ import {
type ServerThreadListResponse = ServerThread[];
/**
* notifySubscribers generate a transaction, to distinguish
* the origin of the update, we use a specific origin "commentMarkUpdate"
* for the updates coming from the comment mark changes.
*/
export const COMMENT_UPDATE_ORIGIN = 'commentMarkUpdate';
export class DocsThreadStore extends ThreadStore {
protected static COMMENTS_PING = 'commentsPing';
protected threads: Map<string, ClientThreadData> = new Map();
@@ -24,6 +32,7 @@ export class DocsThreadStore extends ThreadStore {
(threads: Map<string, ClientThreadData>) => void
>();
private awareness?: Awareness;
private yDoc?: Y.Doc;
private lastPingAt = 0;
private pingTimer?: ReturnType<typeof setTimeout>;
@@ -31,11 +40,13 @@ export class DocsThreadStore extends ThreadStore {
protected docId: Doc['id'],
awareness: Awareness | undefined,
protected docAuth: DocsThreadStoreAuth,
yDoc?: Y.Doc,
) {
super(docAuth);
if (docAuth.canSee) {
this.awareness = awareness;
this.yDoc = yDoc;
this.awareness?.on('update', this.onAwarenessUpdate);
this.refreshThreads();
@@ -67,7 +78,7 @@ export class DocsThreadStore extends ThreadStore {
continue;
}
const state = states.get(clientId) as
const state:
| {
[DocsThreadStore.COMMENTS_PING]?: {
at: number;
@@ -76,7 +87,7 @@ export class DocsThreadStore extends ThreadStore {
threadId: string;
};
}
| undefined;
| undefined = states.get(clientId);
const ping = state?.commentsPing;
@@ -134,18 +145,30 @@ export class DocsThreadStore extends ThreadStore {
}
/**
* Notifies all subscribers about the current thread state
* Notifies all subscribers about the current thread state.
* We trigger the transaction with a specific origin so we will be able
* to flag that the update comes from a comment update.
* The inner ydoc.transact calls from y-prosemirror will see there's already
* an active transaction and reuse it.
*/
private notifySubscribers() {
// Always emit a new Map reference to help consumers detect changes
const threads = new Map(this.threads);
this.subscribers.forEach((cb) => {
try {
cb(threads);
} catch (e) {
console.warn('DocsThreadStore subscriber threw', e);
}
});
const notify = () => {
this.subscribers.forEach((cb) => {
try {
cb(threads);
} catch (e) {
console.warn('DocsThreadStore subscriber threw', e);
}
});
};
if (this.yDoc) {
this.yDoc.transact(notify, COMMENT_UPDATE_ORIGIN);
} else {
notify();
}
}
private upsertClientThreadData(thread: ClientThreadData) {

View File

@@ -1,11 +1,11 @@
import { css } from 'styled-components';
import { createGlobalStyle, css } from 'styled-components';
export const cssComments = (
canSeeComment: boolean,
currentUserAvatarUrl?: string,
) => css`
& .--docs--main-editor,
& .--docs--main-editor .ProseMirror {
export const DocsCommentsStyle = createGlobalStyle<{
canSeeComment: boolean;
currentUserAvatarUrl?: string;
}>`
.--docs--main-editor.bn-root,
.--docs--main-editor.bn-root .ProseMirror {
// Comments marks in the editor
.bn-editor {
// Resets blocknote comments styles
@@ -14,30 +14,31 @@ export const cssComments = (
background-color: transparent;
}
${canSeeComment &&
css`
.bn-thread-mark:not([data-orphan='true']) {
background-color: color-mix(
in srgb,
var(--c--contextuals--background--palette--yellow--tertiary) 40%,
transparent
);
border-bottom: 2px solid
var(--c--contextuals--background--palette--yellow--secondary);
mix-blend-mode: multiply;
transition:
background-color var(--c--globals--transitions--duration),
border-bottom-color var(--c--globals--transitions--duration);
&:has(.bn-thread-mark-selected) {
background-color: var(
--c--contextuals--background--palette--yellow--tertiary
${({ canSeeComment }) =>
canSeeComment &&
css`
.bn-thread-mark:not([data-orphan='true']) {
background-color: color-mix(
in srgb,
var(--c--contextuals--background--palette--yellow--tertiary) 40%,
transparent
);
border-bottom: 2px solid
var(--c--contextuals--background--palette--yellow--secondary);
mix-blend-mode: multiply;
transition:
background-color var(--c--globals--transitions--duration),
border-bottom-color var(--c--globals--transitions--duration);
&:has(.bn-thread-mark-selected) {
background-color: var(
--c--contextuals--background--palette--yellow--tertiary
);
}
}
}
`}
`}
[data-show-selection] {
color: HighlightText;
@@ -82,6 +83,8 @@ export const cssComments = (
.bn-thread-comment {
padding: 8px;
flex-wrap: nowrap;
gap: 0px;
& .bn-editor {
padding-left: var(--c--globals--spacings--lg);
@@ -105,10 +108,14 @@ export const cssComments = (
// Top bar (Name / Date / Actions) when actions displayed
&:has(.bn-comment-actions) {
& > .mantine-Group-root {
max-width: 70%;
& > .mantine-Group-root:first-child {
right: 0.3rem !important;
top: 0.3rem !important;
background: linear-gradient(
to left,
#fff 90%,
rgba(255, 255, 255, 0) 100%
);
}
.bn-menu-dropdown {
@@ -124,7 +131,6 @@ export const cssComments = (
// Date
span.mantine-focus-auto {
display: inline-block;
}
.bn-comment-actions {
@@ -150,7 +156,8 @@ export const cssComments = (
}
// Actions button edit comment
.bn-container + .bn-comment-actions-wrapper {
.bn-root + .bn-comment-actions-wrapper {
margin-top: var(--c--globals--spacings--2xs);
.bn-comment-actions {
flex-direction: row-reverse;
background: none;
@@ -201,9 +208,8 @@ export const cssComments = (
width: 26px;
height: 26px;
flex: 0 0 26px;
background-image: ${currentUserAvatarUrl
? `url("${currentUserAvatarUrl}")`
: 'none'};
background-image: ${({ currentUserAvatarUrl }) =>
currentUserAvatarUrl ? `url("${currentUserAvatarUrl}")` : 'none'};
background-position: center;
background-repeat: no-repeat;
background-size: cover;

View File

@@ -27,8 +27,15 @@ export function useComments(
encodeURIComponent(user?.full_name || ''),
canComment,
),
provider?.document,
);
}, [docId, canComment, provider?.awareness, user?.full_name]);
}, [
docId,
canComment,
provider?.awareness,
provider?.document,
user?.full_name,
]);
useEffect(() => {
if (canComment) {

View File

@@ -41,7 +41,7 @@ export const LinkSelected = ({
const router = useRouter();
const href = `/docs/${docId}/`;
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
// If ctrl or command is pressed, it opens a new tab. If shift is pressed, it opens a new window
@@ -53,7 +53,7 @@ export const LinkSelected = ({
};
// This triggers on middle-mouse click
const handleAuxClick = (e: React.MouseEvent<HTMLDivElement>) => {
const handleAuxClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (e.button !== 1) {
return;
}
@@ -66,6 +66,7 @@ export const LinkSelected = ({
<BoxButton
as="span"
className="--docs--interlinking-link-inline-content"
data-href={href}
onClick={handleClick}
onAuxClick={handleAuxClick}
draggable="false"

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