Compare commits

...

17 Commits

Author SHA1 Message Date
Sebastian Steinbeißer
bd998dac63
Switch from os.path to pathlib.Path 2025-03-05 21:51:00 +01:00
shamoon
aaaa6c1393
Enhancement: reorganize dates dropdown, add more relative options (#9307) 2025-03-05 12:48:42 -08:00
shamoon
bed82215a0
Chore: remove popper preventOverflow fix (#9306) 2025-03-05 12:47:21 -08:00
shamoon
f8aaa5cb32 Chore: remove error on upload steps after jest tests 2025-03-05 12:47:05 -08:00
shamoon
1e489a0666
Enhancement: add switch to allow merging non-PDFs with archive version (#9305) 2025-03-05 20:46:51 +00:00
shamoon
edc7181843
Enhancement: support assigning custom field values in workflows (#9272) 2025-03-05 12:30:19 -08:00
shamoon
89e5c08a1f
Chore: add codecov frontend test results (#9296) 2025-03-04 22:57:29 +00:00
Trenton H
0faa9e8865
Chore: Split out some items into extras (#9297) 2025-03-04 22:16:09 +00:00
Trenton H
f205c4d0e2
Removes undocumented FileInfo (#9298) 2025-03-04 13:49:47 -08:00
dependabot[bot]
344b2bc0eb
Chore(deps-dev): Bump the frontend-angular-dependencies group in /src-ui with 5 updates (#9288)
* Chore(deps-dev): Bump the frontend-angular-dependencies group

Bumps the frontend-angular-dependencies group in /src-ui with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@angular-eslint/builder](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/builder) | `19.1.0` | `19.2.0` |
| [@angular-eslint/eslint-plugin](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin) | `19.1.0` | `19.2.0` |
| [@angular-eslint/eslint-plugin-template](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin-template) | `19.1.0` | `19.2.0` |
| [@angular-eslint/schematics](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/schematics) | `19.1.0` | `19.2.0` |
| [@angular-eslint/template-parser](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/template-parser) | `19.1.0` | `19.2.0` |


Updates `@angular-eslint/builder` from 19.1.0 to 19.2.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/builder/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v19.2.0/packages/builder)

Updates `@angular-eslint/eslint-plugin` from 19.1.0 to 19.2.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v19.2.0/packages/eslint-plugin)

Updates `@angular-eslint/eslint-plugin-template` from 19.1.0 to 19.2.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v19.2.0/packages/eslint-plugin-template)

Updates `@angular-eslint/schematics` from 19.1.0 to 19.2.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/schematics/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v19.2.0/packages/schematics)

Updates `@angular-eslint/template-parser` from 19.1.0 to 19.2.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/template-parser/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v19.2.0/packages/template-parser)

---
updated-dependencies:
- dependency-name: "@angular-eslint/builder"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/eslint-plugin-template"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/schematics"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/template-parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* Fix lint error on toast close output name

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-03-04 19:09:38 +00:00
Trenton H
817aad7c8b
Fix: Switches data to content to upload raw bytes/text content (#9293) 2025-03-04 18:56:28 +00:00
Trenton H
d82555e644
Enables Codecov test reporting for the backend (#9295) 2025-03-04 18:38:06 +00:00
Trenton H
f3e6ed56b9
Removes the unused Log model and LogFilterSet (#9294) 2025-03-04 18:26:25 +00:00
Trenton H
780d1c67e9
Chore: Combine Python settings files (#9292) 2025-03-04 17:19:47 +00:00
dependabot[bot]
2b72397a4d
Chore(deps-dev): Bump @types/node from 22.13.8 to 22.13.9 in /src-ui (#9290)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.13.8 to 22.13.9.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-04 17:03:07 +00:00
dependabot[bot]
6c13ffaa01
Chore(deps-dev): Bump the frontend-eslint-dependencies group (#9289)
Bumps the frontend-eslint-dependencies group in /src-ui with 3 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) and [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils).


Updates `@typescript-eslint/eslint-plugin` from 8.25.0 to 8.26.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.26.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.25.0 to 8.26.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.26.0/packages/parser)

Updates `@typescript-eslint/utils` from 8.25.0 to 8.26.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/utils/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.26.0/packages/utils)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-eslint-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-eslint-dependencies
- dependency-name: "@typescript-eslint/utils"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-eslint-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-04 08:53:29 -08:00
Trenton H
eb8e124971
Chore: Switch from pipenv to uv (#9251) 2025-03-04 16:15:51 +00:00
85 changed files with 5910 additions and 6118 deletions

View File

@ -76,18 +76,15 @@ RUN set -eux \
&& apt-get update \ && apt-get update \
&& apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES} && apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES}
ARG PYTHON_PACKAGES="\ ARG PYTHON_PACKAGES="ca-certificates"
python3 \
python3-pip \
python3-wheel \
pipenv \
ca-certificates"
RUN set -eux \ RUN set -eux \
echo "Installing python packages" \ echo "Installing python packages" \
&& apt-get update \ && apt-get update \
&& apt-get install --yes --quiet ${PYTHON_PACKAGES} && apt-get install --yes --quiet ${PYTHON_PACKAGES}
COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /bin/uv
RUN set -eux \ RUN set -eux \
&& echo "Installing pre-built updates" \ && echo "Installing pre-built updates" \
&& echo "Installing qpdf ${QPDF_VERSION}" \ && echo "Installing qpdf ${QPDF_VERSION}" \
@ -131,6 +128,8 @@ RUN set -eux \
&& echo "Configuring ImageMagick" \ && echo "Configuring ImageMagick" \
&& mv paperless-policy.xml /etc/ImageMagick-6/policy.xml && mv paperless-policy.xml /etc/ImageMagick-6/policy.xml
COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /bin/uv
# Packages needed only for building a few quick Python # Packages needed only for building a few quick Python
# dependencies # dependencies
ARG BUILD_PACKAGES="\ ARG BUILD_PACKAGES="\
@ -140,11 +139,10 @@ ARG BUILD_PACKAGES="\
libpq-dev \ libpq-dev \
# https://github.com/PyMySQL/mysqlclient#linux # https://github.com/PyMySQL/mysqlclient#linux
default-libmysqlclient-dev \ default-libmysqlclient-dev \
pkg-config \ pkg-config"
pre-commit"
# hadolint ignore=DL3042 # hadolint ignore=DL3042
RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \ RUN --mount=type=cache,target=/root/.cache/uv,id=pip-cache \
set -eux \ set -eux \
&& echo "Installing build system packages" \ && echo "Installing build system packages" \
&& apt-get update \ && apt-get update \
@ -169,9 +167,6 @@ RUN set -eux \
&& mkdir --parents --verbose /usr/src/paperless/paperless-ngx/.venv \ && mkdir --parents --verbose /usr/src/paperless/paperless-ngx/.venv \
&& echo "Adjusting all permissions" \ && echo "Adjusting all permissions" \
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless && chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless
# && echo "Collecting static files" \
# && gosu paperless python3 manage.py collectstatic --clear --no-input --link \
# && gosu paperless python3 manage.py compilemessages
VOLUME ["/usr/src/paperless/paperless-ngx/data", \ VOLUME ["/usr/src/paperless/paperless-ngx/data", \
"/usr/src/paperless/paperless-ngx/media", \ "/usr/src/paperless/paperless-ngx/media", \

117
.devcontainer/README.md Normal file
View File

@ -0,0 +1,117 @@
# Paperless-ngx Development Environment
## Overview
Welcome to the Paperless-ngx development environment! This setup uses VSCode DevContainers to provide a consistent and seamless development experience.
### What are DevContainers?
DevContainers are a feature in VSCode that allows you to develop within a Docker container. This ensures that your development environment is consistent across different machines and setups. By defining a containerized environment, you can eliminate the "works on my machine" problem.
### Advantages of DevContainers
- **Consistency**: Same environment for all developers.
- **Isolation**: Separate development environment from your local machine.
- **Reproducibility**: Easily recreate the environment on any machine.
- **Pre-configured Tools**: Include all necessary tools and dependencies in the container.
## DevContainer Setup
The DevContainer configuration provides up all the necessary services for Paperless-ngx, including:
- Redis
- Gotenberg
- Tika
Data is stored using Docker volumes to ensure persistence across container restarts.
## Configuration Files
The setup includes debugging configurations (`launch.json`) and tasks (`tasks.json`) to help you manage and debug various parts of the project:
- **Backend Debugging:**
- `manage.py runserver`
- `manage.py document-consumer`
- `celery`
- **Maintenance Tasks:**
- Create superuser
- Run migrations
- Recreate virtual environment (`.venv` with `uv`)
- Compile frontend assets
## Getting Started
### Step 1: Running the DevContainer
To start the DevContainer:
1. Open VSCode.
2. Open the project folder.
3. Open the command palette:
- **Windows/Linux**: `Ctrl+Shift+P`
- **Mac**: `Cmd+Shift+P`
4. Type and select `Dev Containers: Rebuild and Reopen in Container`.
VSCode will build and start the DevContainer environment.
### Step 2: Initial Setup
Once the DevContainer is up and running, perform the following steps:
1. **Compile Frontend Assets**:
- Open the command palette:
- **Windows/Linux**: `Ctrl+Shift+P`
- **Mac**: `Cmd+Shift+P`
- Select `Tasks: Run Task`.
- Choose `Frontend Compile`.
2. **Run Database Migrations**:
- Open the command palette:
- **Windows/Linux**: `Ctrl+Shift+P`
- **Mac**: `Cmd+Shift+P`
- Select `Tasks: Run Task`.
- Choose `Migrate Database`.
3. **Create Superuser**:
- Open the command palette:
- **Windows/Linux**: `Ctrl+Shift+P`
- **Mac**: `Cmd+Shift+P`
- Select `Tasks: Run Task`.
- Choose `Create Superuser`.
### Debugging and Running Services
You can start and debug backend services either as debugging sessions via `launch.json` or as tasks.
#### Using `launch.json`
1. Press `F5` or go to the **Run and Debug** view in VSCode.
2. Select the desired configuration:
- `Runserver`
- `Document Consumer`
- `Celery`
#### Using Tasks
1. Open the command palette:
- **Windows/Linux**: `Ctrl+Shift+P`
- **Mac**: `Cmd+Shift+P`
2. Select `Tasks: Run Task`.
3. Choose the desired task:
- `Runserver`
- `Document Consumer`
- `Celery`
### Additional Maintenance Tasks
Additional tasks are available for common maintenance operations:
- **Recreate .venv**: For setting up the virtual environment using `uv`.
- **Migrate Database**: To apply database migrations.
- **Create Superuser**: To create an admin user for the application.
## Let's Get Started!
Follow the steps above to get your development environment up and running. Happy coding!

View File

@ -3,7 +3,7 @@
"dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml", "dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml",
"service": "paperless-development", "service": "paperless-development",
"workspaceFolder": "/usr/src/paperless/paperless-ngx", "workspaceFolder": "/usr/src/paperless/paperless-ngx",
"postCreateCommand": "pipenv install --dev && pipenv run pre-commit install", "postCreateCommand": "/bin/bash -c uv sync --dev && uv run pre-commit install",
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": [

View File

@ -43,7 +43,7 @@ services:
volumes: volumes:
- ..:/usr/src/paperless/paperless-ngx:delegated - ..:/usr/src/paperless/paperless-ngx:delegated
- ../.devcontainer/vscode:/usr/src/paperless/paperless-ngx/.vscode:delegated # VSCode config files - ../.devcontainer/vscode:/usr/src/paperless/paperless-ngx/.vscode:delegated # VSCode config files
- pipenv:/usr/src/paperless/paperless-ngx/.venv - virtualenv:/usr/src/paperless/paperless-ngx/.venv # Virtual environment persisted in volume
- /usr/src/paperless/paperless-ngx/src/documents/static/frontend # Static frontend files exist only in container - /usr/src/paperless/paperless-ngx/src/documents/static/frontend # Static frontend files exist only in container
- /usr/src/paperless/paperless-ngx/src/.pytest_cache - /usr/src/paperless/paperless-ngx/src/.pytest_cache
- /usr/src/paperless/paperless-ngx/.ruff_cache - /usr/src/paperless/paperless-ngx/.ruff_cache
@ -80,4 +80,7 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
pipenv: data:
media:
redisdata:
virtualenv:

View File

@ -5,7 +5,7 @@
"label": "Start: Celery Worker", "label": "Start: Celery Worker",
"description": "Start the Celery Worker which processes background and consume tasks", "description": "Start the Celery Worker which processes background and consume tasks",
"type": "shell", "type": "shell",
"command": "pipenv run celery --app paperless worker -l DEBUG", "command": "uv run celery --app paperless worker -l DEBUG",
"isBackground": true, "isBackground": true,
"options": { "options": {
"cwd": "${workspaceFolder}/src" "cwd": "${workspaceFolder}/src"
@ -61,7 +61,7 @@
"label": "Start: Consumer Service (manage.py document_consumer)", "label": "Start: Consumer Service (manage.py document_consumer)",
"description": "Start the Consumer Service which processes files from a directory", "description": "Start the Consumer Service which processes files from a directory",
"type": "shell", "type": "shell",
"command": "pipenv run python manage.py document_consumer", "command": "uv run python manage.py document_consumer",
"group": "build", "group": "build",
"presentation": { "presentation": {
"echo": true, "echo": true,
@ -80,7 +80,7 @@
"label": "Start: Backend Server (manage.py runserver)", "label": "Start: Backend Server (manage.py runserver)",
"description": "Start the Backend Server which serves the Django API and the compiled Angular frontend", "description": "Start the Backend Server which serves the Django API and the compiled Angular frontend",
"type": "shell", "type": "shell",
"command": "pipenv run python manage.py runserver", "command": "uv run python manage.py runserver",
"group": "build", "group": "build",
"presentation": { "presentation": {
"echo": true, "echo": true,
@ -99,7 +99,7 @@
"label": "Maintenance: manage.py migrate", "label": "Maintenance: manage.py migrate",
"description": "Apply database migrations", "description": "Apply database migrations",
"type": "shell", "type": "shell",
"command": "pipenv run python manage.py migrate", "command": "uv run python manage.py migrate",
"group": "none", "group": "none",
"presentation": { "presentation": {
"echo": true, "echo": true,
@ -118,7 +118,7 @@
"label": "Maintenance: Build Documentation", "label": "Maintenance: Build Documentation",
"description": "Build the documentation with MkDocs", "description": "Build the documentation with MkDocs",
"type": "shell", "type": "shell",
"command": "pipenv run mkdocs build --config-file mkdocs.yml && pipenv run mkdocs serve", "command": "uv run mkdocs build --config-file mkdocs.yml && uv run mkdocs serve",
"group": "none", "group": "none",
"presentation": { "presentation": {
"echo": true, "echo": true,
@ -137,7 +137,7 @@
"label": "Maintenance: manage.py createsuperuser", "label": "Maintenance: manage.py createsuperuser",
"description": "Create a superuser", "description": "Create a superuser",
"type": "shell", "type": "shell",
"command": "pipenv run python manage.py createsuperuser", "command": "uv run python manage.py createsuperuser",
"group": "none", "group": "none",
"presentation": { "presentation": {
"echo": true, "echo": true,
@ -156,7 +156,7 @@
"label": "Maintenance: recreate .venv", "label": "Maintenance: recreate .venv",
"description": "Recreate the python virtual environment and install python dependencies", "description": "Recreate the python virtual environment and install python dependencies",
"type": "shell", "type": "shell",
"command": "rm -R -v .venv/* || pipenv install --dev", "command": "rm -R -v .venv/* || uv install --dev",
"group": "none", "group": "none",
"presentation": { "presentation": {
"echo": true, "echo": true,

View File

@ -27,9 +27,6 @@ indent_style = space
[*.md] [*.md]
indent_style = space indent_style = space
[Pipfile.lock]
indent_style = space
# Tests don't get a line width restriction. It's still a good idea to follow # Tests don't get a line width restriction. It's still a good idea to follow
# the 79 character rule, but in the interests of clarity, tests often need to # the 79 character rule, but in the interests of clarity, tests often need to
# violate it. # violate it.

View File

@ -1,6 +1,8 @@
# https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates#package-ecosystem # https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates#package-ecosystem
version: 2 version: 2
# Required for uv support for now
enable-beta-ecosystems: true
updates: updates:
# Enable version updates for npm # Enable version updates for npm
@ -34,9 +36,8 @@ updates:
- "eslint" - "eslint"
# Enable version updates for Python # Enable version updates for Python
- package-ecosystem: "pip" - package-ecosystem: "uv"
target-branch: "dev" target-branch: "dev"
# Look for a `Pipfile` in the `root` directory
directory: "/" directory: "/"
# Check for updates once a week # Check for updates once a week
schedule: schedule:
@ -53,6 +54,7 @@ updates:
- "*pytest*" - "*pytest*"
- "ruff" - "ruff"
- "mkdocs-material" - "mkdocs-material"
- "pre-commit*"
django: django:
patterns: patterns:
- "*django*" - "*django*"
@ -63,6 +65,10 @@ updates:
update-types: update-types:
- "minor" - "minor"
- "patch" - "patch"
pre-built:
patterns:
- psycopg*
- zxing-cpp
# Enable updates for GitHub Actions # Enable updates for GitHub Actions
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"

View File

@ -14,9 +14,7 @@ on:
- 'translations**' - 'translations**'
env: env:
# This is the version of pipenv all the steps will use DEFAULT_UV_VERSION: "0.6.x"
# If changing this, change Dockerfile
DEFAULT_PIP_ENV_VERSION: "2024.4.1"
# This is the default version of Python to use in most steps which aren't specific # This is the default version of Python to use in most steps which aren't specific
DEFAULT_PYTHON_VERSION: "3.11" DEFAULT_PYTHON_VERSION: "3.11"
@ -59,24 +57,25 @@ jobs:
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
cache: "pipenv"
cache-dependency-path: 'Pipfile.lock'
- -
name: Install pipenv name: Install uv
run: | uses: astral-sh/setup-uv@v5
pip install --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- -
name: Install dependencies name: Install Python dependencies
run: | run: |
pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
-
name: List installed Python dependencies
run: |
pipenv --python ${{ steps.setup-python.outputs.python-version }} run pip list
- -
name: Make documentation name: Make documentation
run: | run: |
pipenv --python ${{ steps.setup-python.outputs.python-version }} run mkdocs build --config-file ./mkdocs.yml uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
mkdocs build --config-file ./mkdocs.yml
- -
name: Deploy documentation name: Deploy documentation
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
@ -84,7 +83,11 @@ jobs:
echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME" echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME"
git config --global user.name "${{ github.actor }}" git config --global user.name "${{ github.actor }}"
git config --global user.email "${{ github.actor }}@users.noreply.github.com" git config --global user.email "${{ github.actor }}@users.noreply.github.com"
pipenv --python ${{ steps.setup-python.outputs.python-version }} run mkdocs gh-deploy --force --no-history uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
mkdocs gh-deploy --force --no-history
- -
name: Upload artifact name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@ -117,12 +120,13 @@ jobs:
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "${{ matrix.python-version }}" python-version: "${{ matrix.python-version }}"
cache: "pipenv"
cache-dependency-path: 'Pipfile.lock'
- -
name: Install pipenv name: Install uv
run: | uses: astral-sh/setup-uv@v5
pip install --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ steps.setup-python.outputs.python-version }}
- -
name: Install system dependencies name: Install system dependencies
run: | run: |
@ -135,12 +139,14 @@ jobs:
- -
name: Install Python dependencies name: Install Python dependencies
run: | run: |
pipenv --python ${{ steps.setup-python.outputs.python-version }} run python --version uv sync \
pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev --python ${{ steps.setup-python.outputs.python-version }} \
--group testing \
--frozen
- -
name: List installed Python dependencies name: List installed Python dependencies
run: | run: |
pipenv --python ${{ steps.setup-python.outputs.python-version }} run pip list uv pip list
- -
name: Tests name: Tests
env: env:
@ -150,17 +156,22 @@ jobs:
PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }} PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }}
PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }} PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }}
run: | run: |
cd src/ uv run \
pipenv --python ${{ steps.setup-python.outputs.python-version }} run pytest -ra --python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
pytest
- -
name: Upload coverage name: Upload coverage
if: ${{ matrix.python-version == env.DEFAULT_PYTHON_VERSION }} if: ${{ matrix.python-version == env.DEFAULT_PYTHON_VERSION }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: backend-coverage-report name: backend-coverage-report
path: src/coverage.xml path: |
coverage.xml
junit.xml
retention-days: 7 retention-days: 7
if-no-files-found: warn if-no-files-found: error
- -
name: Stop containers name: Stop containers
if: always() if: always()
@ -234,6 +245,8 @@ jobs:
run: cd src-ui && npm run lint run: cd src-ui && npm run lint
- -
name: Run Jest unit tests name: Run Jest unit tests
env:
JEST_JUNIT_OUTPUT_FILE: junit-report-${{ matrix.shard-index }}.xml
run: cd src-ui && npm run test -- --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }} run: cd src-ui && npm run test -- --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- -
name: Upload Jest coverage name: Upload Jest coverage
@ -246,7 +259,7 @@ jobs:
src-ui/coverage/lcov.info src-ui/coverage/lcov.info
src-ui/coverage/clover.xml src-ui/coverage/clover.xml
retention-days: 7 retention-days: 7
if-no-files-found: warn if-no-files-found: error
- -
name: Run Playwright e2e tests name: Run Playwright e2e tests
run: cd src-ui && npx playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }} run: cd src-ui && npx playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
@ -258,6 +271,14 @@ jobs:
name: playwright-report-${{ matrix.shard-index }} name: playwright-report-${{ matrix.shard-index }}
path: src-ui/playwright-report path: src-ui/playwright-report
retention-days: 7 retention-days: 7
-
name: Upload frontend test results
if: always()
uses: actions/upload-artifact@v4
with:
name: junit-report-${{ matrix.shard-index }}
path: src-ui/junit-report-${{ matrix.shard-index }}.xml
retention-days: 7
tests-coverage-upload: tests-coverage-upload:
name: "Upload to Codecov" name: "Upload to Codecov"
@ -281,6 +302,13 @@ jobs:
path: src-ui/coverage/ path: src-ui/coverage/
pattern: playwright-report-* pattern: playwright-report-*
merge-multiple: true merge-multiple: true
-
name: Download frontend test results
uses: actions/download-artifact@v4
with:
path: src-ui/junit/
pattern: junit-report-*
merge-multiple: true
- -
name: Upload frontend coverage to Codecov name: Upload frontend coverage to Codecov
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v5
@ -291,6 +319,14 @@ jobs:
directory: src-ui/coverage/ directory: src-ui/coverage/
# dont include backend coverage files here # dont include backend coverage files here
files: '!coverage.xml' files: '!coverage.xml'
-
name: Upload frontend test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: frontend
directory: src-ui/junit/
- -
name: Download backend coverage name: Download backend coverage
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
@ -306,6 +342,14 @@ jobs:
# future expansion # future expansion
flags: backend flags: backend
directory: src/ directory: src/
-
name: Upload backend test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: backend
directory: src/
- -
name: Use Node.js 20 name: Use Node.js 20
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@ -472,16 +516,17 @@ jobs:
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
cache: "pipenv"
cache-dependency-path: 'Pipfile.lock'
- -
name: Install pipenv + tools name: Install uv
run: | uses: astral-sh/setup-uv@v5
pip install --upgrade --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} setuptools wheel with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ steps.setup-python.outputs.python-version }}
- -
name: Install Python dependencies name: Install Python dependencies
run: | run: |
pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
- -
name: Install system dependencies name: Install system dependencies
run: | run: |
@ -502,17 +547,21 @@ jobs:
- -
name: Generate requirements file name: Generate requirements file
run: | run: |
pipenv --python ${{ steps.setup-python.outputs.python-version }} requirements > requirements.txt uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
- -
name: Compile messages name: Compile messages
run: | run: |
cd src/ cd src/
pipenv --python ${{ steps.setup-python.outputs.python-version }} run python3 manage.py compilemessages uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
manage.py compilemessages
- -
name: Collect static files name: Collect static files
run: | run: |
cd src/ cd src/
pipenv --python ${{ steps.setup-python.outputs.python-version }} run python3 manage.py collectstatic --no-input uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
manage.py collectstatic --no-input
- -
name: Move files name: Move files
run: | run: |
@ -528,8 +577,8 @@ jobs:
for file_name in .dockerignore \ for file_name in .dockerignore \
.env \ .env \
Dockerfile \ Dockerfile \
Pipfile \ pyproject.toml \
Pipfile.lock \ uv.lock \
requirements.txt \ requirements.txt \
LICENSE \ LICENSE \
README.md \ README.md \
@ -631,15 +680,17 @@ jobs:
ref: main ref: main
- -
name: Set up Python name: Set up Python
id: setup-python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
cache: "pipenv"
cache-dependency-path: 'Pipfile.lock'
- -
name: Install pipenv + tools name: Install uv
run: | uses: astral-sh/setup-uv@v5
pip install --upgrade --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} setuptools wheel with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- -
name: Append Changelog to docs name: Append Changelog to docs
id: append-Changelog id: append-Changelog
@ -655,7 +706,10 @@ jobs:
CURRENT_CHANGELOG=`tail --lines +2 changelog.md` CURRENT_CHANGELOG=`tail --lines +2 changelog.md`
echo -e "$CURRENT_CHANGELOG" >> changelog-new.md echo -e "$CURRENT_CHANGELOG" >> changelog-new.md
mv changelog-new.md changelog.md mv changelog-new.md changelog.md
pipenv run pre-commit run --files changelog.md || true uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
pre-commit run --files changelog.md || true
git config --global user.name "github-actions" git config --global user.name "github-actions"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA" git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"

1
.gitignore vendored
View File

@ -44,6 +44,7 @@ nosetests.xml
coverage.xml coverage.xml
*,cover *,cover
.pytest_cache .pytest_cache
junit.xml
# Translations # Translations
*.mo *.mo

View File

@ -45,7 +45,6 @@ repos:
- javascript - javascript
- ts - ts
- markdown - markdown
exclude: "(^Pipfile\\.lock$)"
additional_dependencies: additional_dependencies:
- prettier@3.3.3 - prettier@3.3.3
- 'prettier-plugin-organize-imports@4.1.0' - 'prettier-plugin-organize-imports@4.1.0'
@ -55,6 +54,10 @@ repos:
hooks: hooks:
- id: ruff - id: ruff
- id: ruff-format - id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.5.1"
hooks:
- id: pyproject-fmt
# Dockerfile hooks # Dockerfile hooks
- repo: https://github.com/AleksaC/hadolint-py - repo: https://github.com/AleksaC/hadolint-py
rev: v2.12.0.3 rev: v2.12.0.3

View File

@ -1 +0,0 @@
3.10.15

View File

@ -1,87 +0,0 @@
fix = true
line-length = 88
respect-gitignore = true
src = ["src"]
target-version = "py310"
output-format = "grouped"
show-fixes = true
# https://docs.astral.sh/ruff/settings/
# https://docs.astral.sh/ruff/rules/
[lint]
extend-select = [
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
"I", # https://docs.astral.sh/ruff/rules/#isort-i
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
"ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
"PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
"Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q
"RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse
"T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
"TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
"TC", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc
"PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
"PLE", # https://docs.astral.sh/ruff/rules/#pylint-pl
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
]
ignore = ["DJ001", "SIM105", "RUF012"]
[lint.per-file-ignores]
".github/scripts/*.py" = ["E501", "INP001", "SIM117"]
"docker/wait-for-redis.py" = ["INP001", "T201"]
"src/documents/file_handling.py" = ["PTH"] # TODO Enable & remove
"src/documents/management/commands/document_consumer.py" = ["PTH"] # TODO Enable & remove
"src/documents/management/commands/document_exporter.py" = ["PTH"] # TODO Enable & remove
"src/documents/migrations/0012_auto_20160305_0040.py" = ["PTH"] # TODO Enable & remove
"src/documents/migrations/0014_document_checksum.py" = ["PTH"] # TODO Enable & remove
"src/documents/migrations/1003_mime_types.py" = ["PTH"] # TODO Enable & remove
"src/documents/migrations/1012_fix_archive_files.py" = ["PTH"] # TODO Enable & remove
"src/documents/models.py" = ["SIM115", "PTH"] # TODO PTH Enable & remove
"src/documents/parsers.py" = ["PTH"] # TODO Enable & remove
"src/documents/signals/handlers.py" = ["PTH"] # TODO Enable & remove
"src/documents/tasks.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_api_app_config.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_classifier.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_consumer.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_file_handling.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_management.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_management_consumer.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_management_exporter.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_management_thumbnails.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_migration_archive_files.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_migration_document_pages_count.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_migration_mime_type.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_sanity_check.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_tasks.py" = ["PTH"] # TODO Enable & remove
"src/documents/tests/test_views.py" = ["PTH"] # TODO Enable & remove
"src/documents/views.py" = ["PTH"] # TODO Enable & remove
"src/paperless/checks.py" = ["PTH"] # TODO Enable & remove
"src/paperless/settings.py" = ["PTH"] # TODO Enable & remove
"src/paperless/tests/test_checks.py" = ["PTH"] # TODO Enable & remove
"src/paperless/urls.py" = ["PTH"] # TODO Enable & remove
"src/paperless/views.py" = ["PTH"] # TODO Enable & remove
"src/paperless_mail/mail.py" = ["PTH"] # TODO Enable & remove
"src/paperless_mail/preprocessor.py" = ["PTH"] # TODO Enable & remove
"src/paperless_tesseract/parsers.py" = ["PTH"] # TODO Enable & remove
"src/paperless_tesseract/tests/test_parser.py" = ["RUF001", "PTH"] # TODO PTH Enable & remove
"src/paperless_tika/tests/test_live_tika.py" = ["PTH"] # TODO Enable & remove
"src/paperless_tika/tests/test_tika_parser.py" = ["PTH"] # TODO Enable & remove
# Testing
"*/tests/*.py" = ["E501", "SIM117"]
# Migrations
"*/migrations/*.py" = ["E501", "SIM", "T201"]
# Docker specific
"docker/rootfs/usr/local/bin/wait-for-redis.py" = ["INP001", "T201"]
[lint.isort]
force-single-line = true

View File

@ -5,5 +5,6 @@
/src-ui/ @paperless-ngx/frontend /src-ui/ @paperless-ngx/frontend
/src/ @paperless-ngx/backend /src/ @paperless-ngx/backend
Pipfile* @paperless-ngx/backend pyproject.toml @paperless-ngx/backend
uv.lock @paperless-ngx/backend
*.py @paperless-ngx/backend *.py @paperless-ngx/backend

View File

@ -26,28 +26,11 @@ esac
RUN set -eux \ RUN set -eux \
&& ./node_modules/.bin/ng build --configuration production && ./node_modules/.bin/ng build --configuration production
# Stage: pipenv-base
# Purpose: Generates a requirements.txt file for building
# Comments:
# - pipenv dependencies are not left in the final image
# - pipenv can't touch the final image somehow
FROM --platform=$BUILDPLATFORM docker.io/python:3.12-alpine AS pipenv-base
WORKDIR /usr/src/pipenv
COPY Pipfile* ./
RUN set -eux \
&& echo "Installing pipenv" \
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2024.4.1 \
&& echo "Generating requirement.txt" \
&& pipenv requirements > requirements.txt
# Stage: s6-overlay-base # Stage: s6-overlay-base
# Purpose: Installs s6-overlay and rootfs # Purpose: Installs s6-overlay and rootfs
# Comments: # Comments:
# - Don't leave anything extra in here either # - Don't leave anything extra in here either
FROM docker.io/python:3.12-slim-bookworm AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.6.3-python3.12-bookworm-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6
@ -123,9 +106,12 @@ ARG GS_VERSION=10.03.1
# Set Python environment variables # Set Python environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
# Ignore warning from Whitenoise # Ignore warning from Whitenoise about async iterators
PYTHONWARNINGS="ignore:::django.http.response:517" \ PYTHONWARNINGS="ignore:::django.http.response:517" \
PNGX_CONTAINERIZED=1 PNGX_CONTAINERIZED=1 \
# https://docs.astral.sh/uv/reference/settings/#link-mode
UV_LINK_MODE=copy \
UV_CACHE_DIR=/cache/uv/
# #
# Begin installation and configuration # Begin installation and configuration
@ -213,36 +199,25 @@ WORKDIR /usr/src/paperless/src/
# Python dependencies # Python dependencies
# Change pretty frequently # Change pretty frequently
COPY --chown=1000:1000 --from=pipenv-base /usr/src/pipenv/requirements.txt ./ COPY --chown=1000:1000 ["pyproject.toml", "uv.lock", "/usr/src/paperless/src/"]
# Packages needed only for building a few quick Python # Packages needed only for building a few quick Python
# dependencies # dependencies
ARG BUILD_PACKAGES="\ ARG BUILD_PACKAGES="\
build-essential \ build-essential \
git \
# https://www.psycopg.org/docs/install.html#prerequisites
libpq-dev \
# https://github.com/PyMySQL/mysqlclient#linux # https://github.com/PyMySQL/mysqlclient#linux
default-libmysqlclient-dev \ default-libmysqlclient-dev \
pkg-config" pkg-config"
ARG ZXING_VERSION=2.3.0
ARG PSYCOPG_VERSION=3.2.4
# hadolint ignore=DL3042 # hadolint ignore=DL3042
RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \ RUN --mount=type=cache,target=${UV_CACHE_DIR},id=python-cache \
set -eux \ set -eux \
&& echo "Installing build system packages" \ && echo "Installing build system packages" \
&& apt-get update \ && apt-get update \
&& apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \ && apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \
&& python3 -m pip install --upgrade wheel \
&& echo "Installing Python requirements" \ && echo "Installing Python requirements" \
&& curl --fail --silent --no-progress-meter --show-error --location --remote-name-all --parallel --parallel-max 4 \ && uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt \
https://github.com/paperless-ngx/builder/releases/download/psycopg-${PSYCOPG_VERSION}/psycopg_c-${PSYCOPG_VERSION}-cp312-cp312-linux_x86_64.whl \ && uv pip install --system --no-python-downloads --python-preference system --requirements requirements.txt \
https://github.com/paperless-ngx/builder/releases/download/psycopg-${PSYCOPG_VERSION}/psycopg_c-${PSYCOPG_VERSION}-cp312-cp312-linux_aarch64.whl \
https://github.com/paperless-ngx/builder/releases/download/zxing-${ZXING_VERSION}/zxing_cpp-${ZXING_VERSION}-cp312-cp312-linux_aarch64.whl \
https://github.com/paperless-ngx/builder/releases/download/zxing-${ZXING_VERSION}/zxing_cpp-${ZXING_VERSION}-cp312-cp312-linux_x86_64.whl \
&& python3 -m pip install --default-timeout=1000 --find-links . --requirement requirements.txt \
&& echo "Installing NLTK data" \ && echo "Installing NLTK data" \
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \ && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \ && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \

99
Pipfile
View File

@ -1,99 +0,0 @@
[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true
name = "pypi"
[packages]
dateparser = "~=1.2"
# WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes.
django = "~=5.1.5"
django-allauth = {extras = ["mfa", "socialaccount"], version = "*"}
django-auditlog = "*"
django-celery-results = "*"
django-compression-middleware = "*"
django-cors-headers = "*"
django-extensions = "*"
django-filter = "~=25.1"
django-guardian = "*"
django-multiselectfield = "*"
django-soft-delete = "*"
djangorestframework = "~=3.15.2"
djangorestframework-guardian = "*"
drf-spectacular = "*"
drf-spectacular-sidecar = "*"
drf-writable-nested = "*"
bleach = "*"
celery = {extras = ["redis"], version = "*"}
channels = "~=4.2"
channels-redis = "*"
concurrent-log-handler = "*"
filelock = "*"
flower = "*"
gotenberg-client = "*"
granian = "*"
httpx-oauth = "*"
imap-tools = "*"
inotifyrecursive = "~=0.3"
jinja2 = "~=3.1"
langdetect = "*"
mysqlclient = "*"
nltk = "*"
ocrmypdf = "~=16.9"
pathvalidate = "*"
pdf2image = "*"
psycopg = {version = "*", extras = ["c"]}
python-dateutil = "*"
python-dotenv = "*"
python-gnupg = "*"
python-ipware = "*"
python-magic = "*"
pyzbar = "*"
rapidfuzz = "*"
redis = {extras = ["hiredis"], version = "*"}
scikit-learn = "~=1.6"
setproctitle = "*"
tika-client = "*"
tqdm = "*"
watchdog = "~=6.0"
whitenoise = "~=6.9"
whoosh = "~=2.7"
zxing-cpp = "*"
[dev-packages]
# Linting
pre-commit = "*"
ruff = "*"
# Testing
factory-boy = "*"
pytest = "*"
pytest-cov = "*"
pytest-django = "*"
pytest-httpx = "*"
pytest-env = "*"
pytest-sugar = "*"
pytest-xdist = "*"
pytest-mock = "*"
pytest-rerunfailures = "*"
imagehash = "*"
daphne = "*"
# Documentation
mkdocs-material = "*"
mkdocs-glightbox = "*"
[typing-dev]
mypy = "*"
types-Pillow = "*"
django-filter-stubs = "*"
types-python-dateutil = "*"
djangorestframework-stubs = {extras= ["compatible-mypy"], version="*"}
celery-types = "*"
django-stubs = {extras= ["compatible-mypy"], version="*"}
types-dateparser = "*"
types-bleach = "*"
types-redis = "*"
types-tqdm = "*"
types-Markdown = "*"
types-Pygments = "*"
types-colorama = "*"
types-setuptools = "*"

4812
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -60,7 +60,7 @@ first-time setup.
Every command is executed directly from the root folder of the project unless specified otherwise. Every command is executed directly from the root folder of the project unless specified otherwise.
1. Install prerequisites + pipenv as mentioned in 1. Install prerequisites + [uv](https://github.com/astral-sh/uv) as mentioned in
[Bare metal route](setup.md#bare_metal). [Bare metal route](setup.md#bare_metal).
2. Copy `paperless.conf.example` to `paperless.conf` and enable debug 2. Copy `paperless.conf.example` to `paperless.conf` and enable debug
@ -75,17 +75,13 @@ first-time setup.
4. Install the Python dependencies: 4. Install the Python dependencies:
```bash ```bash
pipenv install --dev $ uv sync --dev
``` ```
!!! note
Using a virtual environment is highly recommended. You can spawn one via `pipenv shell`.
5. Install pre-commit hooks: 5. Install pre-commit hooks:
```bash ```bash
pre-commit install $ uv run pre-commit install
``` ```
6. Apply migrations and create a superuser for your development instance: 6. Apply migrations and create a superuser for your development instance:
@ -93,8 +89,8 @@ first-time setup.
```bash ```bash
# src/ # src/
python3 manage.py migrate $ uv run manage.py migrate
python3 manage.py createsuperuser $ uv run manage.py createsuperuser
``` ```
7. You can now either ... 7. You can now either ...
@ -164,6 +160,19 @@ $ ng build --configuration production
complicated IF cases. Append `# noqa: E501` to disable this check complicated IF cases. Append `# noqa: E501` to disable this check
for certain lines. for certain lines.
### Package Management
Paperless uses `uv` to manage packages and virtual environments for both development and production.
To accomplish some common tasks using `uv`, follow the shortcuts below:
To upgrade all locked packages to the latest allowed versions: `uv lock --upgrade`
To upgrade a single locked package: `uv lock --upgrade-package <package>`
To add a new package: `uv add <package>`
To add a new development package `uv add --dev <package>`
## Front end development ## Front end development
The front end is built using AngularJS. In order to get started, you need Node.js (version 14.15+) and The front end is built using AngularJS. In order to get started, you need Node.js (version 14.15+) and
@ -332,27 +341,21 @@ LANGUAGES = [
The documentation is built using material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/). The documentation is built using material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/).
If you want to build the documentation locally, this is how you do it: If you want to build the documentation locally, this is how you do it:
1. Have an active pipenv shell (`pipenv shell`) and install Python dependencies: 1. Build the documentation
```bash ```bash
pipenv install --dev $ uv run mkdocs build --config-file mkdocs.yml
```
2. Build the documentation
```bash
mkdocs build --config-file mkdocs.yml
``` ```
_alternatively..._ _alternatively..._
3. Serve the documentation. This will spin up a 2. Serve the documentation. This will spin up a
copy of the documentation at http://127.0.0.1:8000 copy of the documentation at http://127.0.0.1:8000
that will automatically refresh every time you change that will automatically refresh every time you change
something. something.
```bash ```bash
mkdocs serve $ uv run mkdocs serve
``` ```
## Building the Docker image ## Building the Docker image

View File

@ -380,6 +380,12 @@ are released, dependency support is confirmed, etc.
dependencies. This is an alternative to the above and may require adjusting dependencies. This is an alternative to the above and may require adjusting
the example scripts to utilize the virtual environment paths the example scripts to utilize the virtual environment paths
!!! tip
If you use modern Python tooling, such as `uv`, installation will not include
dependencies for Postgres or Mariadb. You can select those extras with `--extra <EXTRA>`
or all with `--all-extras`
9. Go to `/opt/paperless/src`, and execute the following commands: 9. Go to `/opt/paperless/src`, and execute the following commands:
```bash ```bash

355
pyproject.toml Normal file
View File

@ -0,0 +1,355 @@
[project]
name = "paperless-ngx"
version = "2.14.7"
description = "A community-supported supercharged version of paperless: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.10"
classifiers = [
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
# TODO: Move certain things to groups and then utilize that further
# This will allow testing to not install a webserver, mysql, etc
dependencies = [
"bleach~=6.2.0",
"celery[redis]~=5.4.0",
"channels~=4.2",
"channels-redis~=4.2",
"concurrent-log-handler~=0.9.25",
"dateparser~=1.2",
# WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes.
"django~=5.1.6",
"django-allauth[socialaccount,mfa]~=65.4.0",
"django-auditlog~=3.0.0",
"django-celery-results~=2.5.1",
"django-compression-middleware~=0.5.0",
"django-cors-headers~=4.7.0",
"django-extensions~=3.2.3",
"django-filter~=25.1",
"django-guardian~=2.4.0",
"django-multiselectfield~=0.1.13",
"django-soft-delete~=1.0.18",
"djangorestframework~=3.15",
"djangorestframework-guardian~=0.3.0",
"drf-spectacular~=0.28",
"drf-spectacular-sidecar~=2025.2.1",
"drf-writable-nested~=0.7.1",
"filelock~=3.17.0",
"flower~=2.0.1",
"gotenberg-client~=0.9.0",
"httpx-oauth~=0.16",
"imap-tools~=1.10.0",
"inotifyrecursive~=0.3",
"jinja2~=3.1.5",
"langdetect~=1.0.9",
"nltk~=3.9.1",
"ocrmypdf~=16.9.0",
"pathvalidate~=3.2.3",
"pdf2image~=1.17.0",
"python-dateutil~=2.9.0",
"python-dotenv~=1.0.1",
"python-gnupg~=0.5.4",
"python-ipware~=3.0.0",
"python-magic~=0.4.27",
"pyzbar~=0.1.9",
"rapidfuzz~=3.12.1",
"redis[hiredis]~=5.2.1",
"scikit-learn~=1.6.1",
"setproctitle~=1.3.4",
"tika-client~=0.9.0",
"tqdm~=4.67.1",
"watchdog~=6.0",
"whitenoise~=6.9",
"whoosh~=2.7",
"zxing-cpp~=2.3.0",
]
optional-dependencies.mariadb = [
"mysqlclient~=2.2.7",
]
optional-dependencies.postgres = [
"psycopg[c]==3.2.4",
# Direct dependency for proper resolution of the pre-built wheels
"psycopg-c==3.2.4",
]
optional-dependencies.webserver = [
"granian~=1.7.6",
]
[dependency-groups]
dev = [
{ "include-group" = "docs" },
{ "include-group" = "testing" },
{ "include-group" = "lint" },
]
docs = [
"mkdocs-glightbox~=0.4.0",
"mkdocs-material~=9.6.4",
]
testing = [
"daphne",
"factory-boy~=3.3.1",
"imagehash",
"pytest~=8.3.3",
"pytest-cov~=6.0.0",
"pytest-django~=4.10.0",
"pytest-env",
"pytest-httpx",
"pytest-mock",
"pytest-rerunfailures",
"pytest-sugar",
"pytest-xdist",
]
lint = [
"pre-commit~=4.1.0",
"pre-commit-uv~=4.1.3",
"ruff~=0.9.9",
]
typing = [
"celery-types",
"django-filter-stubs",
"django-stubs[compatible-mypy]",
"djangorestframework-stubs[compatible-mypy]",
"mypy",
"types-bleach",
"types-colorama",
"types-dateparser",
"types-markdown",
"types-pygments",
"types-python-dateutil",
"types-redis",
"types-setuptools",
"types-tqdm",
]
[tool.ruff]
target-version = "py310"
line-length = 88
src = [
"src",
]
respect-gitignore = true
# https://docs.astral.sh/ruff/settings/
fix = true
show-fixes = true
output-format = "grouped"
# https://docs.astral.sh/ruff/rules/
lint.extend-select = [
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
"I", # https://docs.astral.sh/ruff/rules/#isort-i
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
"ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
"PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
"PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
"PLE", # https://docs.astral.sh/ruff/rules/#pylint-pl
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
"Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q
"RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
"T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
"TC", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc
"TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
]
lint.ignore = [
"DJ001",
"RUF012",
"SIM105",
]
# Migrations
lint.per-file-ignores."*/migrations/*.py" = [
"E501",
"SIM",
"T201",
]
# Testing
lint.per-file-ignores."*/tests/*.py" = [
"E501",
"SIM117",
]
lint.per-file-ignores.".github/scripts/*.py" = [
"E501",
"INP001",
"SIM117",
]
# Docker specific
lint.per-file-ignores."docker/rootfs/usr/local/bin/wait-for-redis.py" = [
"INP001",
"T201",
]
lint.per-file-ignores."docker/wait-for-redis.py" = [
"INP001",
"T201",
]
lint.per-file-ignores."src/documents/file_handling.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/management/commands/document_consumer.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/management/commands/document_exporter.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/migrations/1012_fix_archive_files.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/models.py" = [
"SIM115",
]
lint.per-file-ignores."src/documents/parsers.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/signals/handlers.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_consumer.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_file_handling.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_management.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_management_consumer.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_management_exporter.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_migration_archive_files.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_migration_document_pages_count.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_migration_mime_type.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/tests/test_sanity_check.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/documents/views.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/paperless/checks.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/paperless/settings.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/paperless/views.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/paperless_mail/mail.py" = [
"PTH",
] # TODO Enable & remove
lint.per-file-ignores."src/paperless_tesseract/tests/test_parser.py" = [
"PTH",
"RUF001",
] # TODO PTH Enable & remove
lint.isort.force-single-line = true
[tool.pytest.ini_options]
minversion = "8.0"
pythonpath = [
"src",
]
testpaths = [
"src/documents/tests/",
"src/paperless/tests/",
"src/paperless_mail/tests/",
"src/paperless_tesseract/tests/",
"src/paperless_tika/tests",
]
addopts = [
"--pythonwarnings=all",
"--cov",
"--cov-report=html",
"--cov-report=xml",
"--numprocesses=auto",
"--maxprocesses=16",
"--quiet",
"--durations=50",
"--junitxml=junit.xml",
"-o junit_family=legacy",
]
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
DJANGO_SETTINGS_MODULE = "paperless.settings"
[tool.pytest_env]
PAPERLESS_DISABLE_DBHANDLER = "true"
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
[tool.coverage.run]
source = [
"src/",
]
omit = [
"*/tests/*",
"manage.py",
"paperless/wsgi.py",
"paperless/auth.py",
]
[tool.coverage.report]
exclude_also = [
"if settings.AUDIT_LOG_ENABLED:",
"if AUDIT_LOG_ENABLED:",
"if TYPE_CHECKING:",
]
[tool.mypy]
plugins = [
"mypy_django_plugin.main",
"mypy_drf_plugin.main",
"numpy.typing.mypy_plugin",
]
check_untyped_defs = true
disallow_any_generics = true
disallow_incomplete_defs = true
disallow_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
[tool.uv]
required-version = ">=0.5.14"
package = false
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
]
[tool.uv.sources]
# Markers are chosen to select these almost exclusively when building the Docker image
psycopg-c = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.4/psycopg_c-3.2.4-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.4/psycopg_c-3.2.4-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
]
zxing-cpp = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
]
[tool.django-stubs]
django_settings_module = "paperless.settings"

View File

@ -83,10 +83,17 @@ test('date filtering', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' }) await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
await page.goto('/documents') await page.goto('/documents')
await page.getByRole('button', { name: 'Dates' }).click() await page.getByRole('button', { name: 'Dates' }).click()
await page.getByRole('menuitem', { name: 'Within 3 months' }).first().click() await page.locator('.ng-arrow-wrapper').first().click()
await page.getByRole('option', { name: 'Within 3 months' }).click()
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i) await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
await page.getByRole('menuitem', { name: 'Within 3 months' }).first().click() await page
await page.getByLabel('Datesselected').getByRole('button').first().click() .getByRole('menuitem', { name: 'Relative dates' })
.locator('span')
.first()
.click()
await page.getByRole('option', { name: 'Within 3 months' }).click()
await page.getByLabel('Dates selected').locator('button').first().click()
await page.getByLabel('Dates selected').locator('button').first().click()
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12') await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022') await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
await page.getByText('11', { exact: true }).click() await page.getByText('11', { exact: true }).click()

View File

@ -12,4 +12,13 @@ module.exports = {
'^src/(.*)': '<rootDir>/src/$1', '^src/(.*)': '<rootDir>/src/$1',
}, },
workerIdleMemoryLimit: '512MB', workerIdleMemoryLimit: '512MB',
reporters: [
'default',
[
'jest-junit',
{
classNameTemplate: '{filepath}/{classname}: {title}',
},
],
],
} }

View File

@ -1120,7 +1120,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">173</context> <context context-type="linenumber">193</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8508424367627989968" datatype="html"> <trans-unit id="8508424367627989968" datatype="html">
@ -1198,7 +1198,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
<context context-type="linenumber">105</context> <context context-type="linenumber">106</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
@ -1284,19 +1284,19 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">200</context> <context context-type="linenumber">201</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">219</context> <context context-type="linenumber">220</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">286</context> <context context-type="linenumber">287</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">305</context> <context context-type="linenumber">306</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context> <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
@ -1319,19 +1319,19 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">208</context> <context context-type="linenumber">209</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">227</context> <context context-type="linenumber">228</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">294</context> <context context-type="linenumber">295</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">313</context> <context context-type="linenumber">314</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context> <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
@ -1357,11 +1357,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">233</context> <context context-type="linenumber">234</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">319</context> <context context-type="linenumber">320</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context> <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
@ -1732,11 +1732,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
<context context-type="linenumber">11</context> <context context-type="linenumber">8</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">87</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@ -3246,18 +3246,25 @@
<context context-type="linenumber">24</context> <context context-type="linenumber">24</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2710430925353472741" datatype="html">
<source>Try to include archive version in merge for non-PDF files</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
<trans-unit id="5612366187076076264" datatype="html"> <trans-unit id="5612366187076076264" datatype="html">
<source>Delete original documents after successful merge</source> <source>Delete original documents after successful merge</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
<context context-type="linenumber">32</context> <context context-type="linenumber">36</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5138283234724909648" datatype="html"> <trans-unit id="5138283234724909648" datatype="html">
<source>Note that only PDFs will be included.</source> <source>Note that only PDFs will be included.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component.html</context>
<context context-type="linenumber">34</context> <context context-type="linenumber">39</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8157388568390631653" datatype="html"> <trans-unit id="8157388568390631653" datatype="html">
@ -3344,7 +3351,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
<context context-type="linenumber">50</context> <context context-type="linenumber">52</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
@ -3352,12 +3359,16 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
<context context-type="linenumber">126</context> <context context-type="linenumber">128</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
<context context-type="linenumber">152</context> <context context-type="linenumber">152</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.ts</context>
<context context-type="linenumber">103</context>
</context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/date/date.component.html</context> <context context-type="sourcefile">src/app/components/common/input/date/date.component.html</context>
<context context-type="linenumber">21</context> <context context-type="linenumber">21</context>
@ -3371,7 +3382,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
<context context-type="linenumber">51</context> <context context-type="linenumber">53</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
@ -3379,7 +3390,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
<context context-type="linenumber">127</context> <context context-type="linenumber">129</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
@ -3502,8 +3513,8 @@
<context context-type="linenumber">168</context> <context context-type="linenumber">168</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6052766076365105714" datatype="html"> <trans-unit id="6312759212949884929" datatype="html">
<source>now</source> <source>Relative dates</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
<context context-type="linenumber">25</context> <context context-type="linenumber">25</context>
@ -3513,15 +3524,26 @@
<context context-type="linenumber">101</context> <context context-type="linenumber">101</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6052766076365105714" datatype="html">
<source>now</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
<context context-type="linenumber">29</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
<context context-type="linenumber">105</context>
</context-group>
</trans-unit>
<trans-unit id="5203279511751768967" datatype="html"> <trans-unit id="5203279511751768967" datatype="html">
<source>From</source> <source>From</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
<context context-type="linenumber">42</context> <context context-type="linenumber">44</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
<context context-type="linenumber">118</context> <context context-type="linenumber">120</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1640609344969975994" datatype="html"> <trans-unit id="1640609344969975994" datatype="html">
@ -3539,11 +3561,11 @@
<source>Added</source> <source>Added</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.html</context>
<context context-type="linenumber">86</context> <context context-type="linenumber">84</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">83</context> <context context-type="linenumber">84</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@ -3562,28 +3584,53 @@
<source>Within 1 week</source> <source>Within 1 week</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.ts</context>
<context context-type="linenumber">67</context> <context context-type="linenumber">73</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="123064370501514576" datatype="html"> <trans-unit id="123064370501514576" datatype="html">
<source>Within 1 month</source> <source>Within 1 month</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.ts</context>
<context context-type="linenumber">72</context> <context context-type="linenumber">78</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1027161426440526546" datatype="html"> <trans-unit id="1027161426440526546" datatype="html">
<source>Within 3 months</source> <source>Within 3 months</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.ts</context>
<context context-type="linenumber">77</context> <context context-type="linenumber">83</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="226779700214642230" datatype="html"> <trans-unit id="226779700214642230" datatype="html">
<source>Within 1 year</source> <source>Within 1 year</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.ts</context>
<context context-type="linenumber">82</context> <context context-type="linenumber">88</context>
</context-group>
</trans-unit>
<trans-unit id="8462417627724236320" datatype="html">
<source>This year</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.ts</context>
<context context-type="linenumber">93</context>
</context-group>
</trans-unit>
<trans-unit id="842657237693374355" datatype="html">
<source>This month</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.ts</context>
<context context-type="linenumber">98</context>
</context-group>
</trans-unit>
<trans-unit id="4498682414491138092" datatype="html">
<source>Yesterday</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/dates-dropdown/dates-dropdown.component.ts</context>
<context context-type="linenumber">108</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pipes/custom-date.pipe.ts</context>
<context context-type="linenumber">29</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8743659855412792665" datatype="html"> <trans-unit id="8743659855412792665" datatype="html">
@ -4396,7 +4443,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">129</context> <context context-type="linenumber">130</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
@ -4787,227 +4834,227 @@
<source>Assign owner</source> <source>Assign owner</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">194</context> <context context-type="linenumber">195</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1749184201773078639" datatype="html"> <trans-unit id="1749184201773078639" datatype="html">
<source>Assign view permissions</source> <source>Assign view permissions</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">196</context> <context context-type="linenumber">197</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1744964187586405039" datatype="html"> <trans-unit id="1744964187586405039" datatype="html">
<source>Assign edit permissions</source> <source>Assign edit permissions</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">215</context> <context context-type="linenumber">216</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6236311670364192011" datatype="html"> <trans-unit id="6236311670364192011" datatype="html">
<source>Remove tags</source> <source>Remove tags</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">242</context> <context context-type="linenumber">243</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7890599006071681081" datatype="html"> <trans-unit id="7890599006071681081" datatype="html">
<source>Remove all</source> <source>Remove all</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">243</context> <context context-type="linenumber">244</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">249</context> <context context-type="linenumber">250</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">255</context> <context context-type="linenumber">256</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">261</context> <context context-type="linenumber">262</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">267</context> <context context-type="linenumber">268</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">274</context> <context context-type="linenumber">275</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">280</context> <context context-type="linenumber">281</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8636414563726517994" datatype="html"> <trans-unit id="8636414563726517994" datatype="html">
<source>Remove correspondents</source> <source>Remove correspondents</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">248</context> <context context-type="linenumber">249</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5305293055593064952" datatype="html"> <trans-unit id="5305293055593064952" datatype="html">
<source>Remove document types</source> <source>Remove document types</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">254</context> <context context-type="linenumber">255</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2400388879708187" datatype="html"> <trans-unit id="2400388879708187" datatype="html">
<source>Remove storage paths</source> <source>Remove storage paths</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">260</context> <context context-type="linenumber">261</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4324304327041955720" datatype="html"> <trans-unit id="4324304327041955720" datatype="html">
<source>Remove custom fields</source> <source>Remove custom fields</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">266</context> <context context-type="linenumber">267</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8367536502602515064" datatype="html"> <trans-unit id="8367536502602515064" datatype="html">
<source>Remove owners</source> <source>Remove owners</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">273</context> <context context-type="linenumber">274</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3393772184866313281" datatype="html"> <trans-unit id="3393772184866313281" datatype="html">
<source>Remove permissions</source> <source>Remove permissions</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">279</context> <context context-type="linenumber">280</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3145629643370481114" datatype="html"> <trans-unit id="3145629643370481114" datatype="html">
<source>View permissions</source> <source>View permissions</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">282</context> <context context-type="linenumber">283</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1946660694635960249" datatype="html"> <trans-unit id="1946660694635960249" datatype="html">
<source>Edit permissions</source> <source>Edit permissions</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">301</context> <context context-type="linenumber">302</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8987736563240025468" datatype="html"> <trans-unit id="8987736563240025468" datatype="html">
<source>Email subject</source> <source>Email subject</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">329</context> <context context-type="linenumber">330</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8239445959209739142" datatype="html"> <trans-unit id="8239445959209739142" datatype="html">
<source>Email body</source> <source>Email body</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">330</context> <context context-type="linenumber">331</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1222152280703048012" datatype="html"> <trans-unit id="1222152280703048012" datatype="html">
<source>Email recipients</source> <source>Email recipients</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">331</context> <context context-type="linenumber">332</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7916910101279824329" datatype="html"> <trans-unit id="7916910101279824329" datatype="html">
<source>Attach document</source> <source>Attach document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">332</context> <context context-type="linenumber">333</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5028001922785731600" datatype="html"> <trans-unit id="5028001922785731600" datatype="html">
<source>Webhook url</source> <source>Webhook url</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">340</context> <context context-type="linenumber">341</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7491983459027245019" datatype="html"> <trans-unit id="7491983459027245019" datatype="html">
<source>Use parameters for webhook body</source> <source>Use parameters for webhook body</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">342</context> <context context-type="linenumber">343</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4078214298308732810" datatype="html"> <trans-unit id="4078214298308732810" datatype="html">
<source>Send webhook payload as JSON</source> <source>Send webhook payload as JSON</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">343</context> <context context-type="linenumber">344</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6806149889743731985" datatype="html"> <trans-unit id="6806149889743731985" datatype="html">
<source>Webhook params</source> <source>Webhook params</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">346</context> <context context-type="linenumber">347</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7089924379374330" datatype="html"> <trans-unit id="7089924379374330" datatype="html">
<source>Webhook body</source> <source>Webhook body</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">348</context> <context context-type="linenumber">349</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3829826512656746316" datatype="html"> <trans-unit id="3829826512656746316" datatype="html">
<source>Webhook headers</source> <source>Webhook headers</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">350</context> <context context-type="linenumber">351</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2114525789021600887" datatype="html"> <trans-unit id="2114525789021600887" datatype="html">
<source>Include document</source> <source>Include document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">351</context> <context context-type="linenumber">352</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4626030417479279989" datatype="html"> <trans-unit id="4626030417479279989" datatype="html">
<source>Consume Folder</source> <source>Consume Folder</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">64</context> <context context-type="linenumber">65</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="526966086395145275" datatype="html"> <trans-unit id="526966086395145275" datatype="html">
<source>API Upload</source> <source>API Upload</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">68</context> <context context-type="linenumber">69</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7502272564743467653" datatype="html"> <trans-unit id="7502272564743467653" datatype="html">
<source>Mail Fetch</source> <source>Mail Fetch</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">72</context> <context context-type="linenumber">73</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="235571817610183244" datatype="html"> <trans-unit id="235571817610183244" datatype="html">
<source>Web UI</source> <source>Web UI</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">76</context> <context context-type="linenumber">77</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3553216189604488439" datatype="html"> <trans-unit id="3553216189604488439" datatype="html">
<source>Modified</source> <source>Modified</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">91</context> <context context-type="linenumber">92</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context> <context context-type="sourcefile">src/app/data/document.ts</context>
@ -5018,70 +5065,70 @@
<source>Custom Field</source> <source>Custom Field</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">95</context> <context context-type="linenumber">96</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8696908693776094667" datatype="html"> <trans-unit id="8696908693776094667" datatype="html">
<source>Consumption Started</source> <source>Consumption Started</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">102</context> <context context-type="linenumber">103</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7858311467093621703" datatype="html"> <trans-unit id="7858311467093621703" datatype="html">
<source>Document Added</source> <source>Document Added</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">106</context> <context context-type="linenumber">107</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7955486237346046731" datatype="html"> <trans-unit id="7955486237346046731" datatype="html">
<source>Document Updated</source> <source>Document Updated</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">110</context> <context context-type="linenumber">111</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9172233176401579786" datatype="html"> <trans-unit id="9172233176401579786" datatype="html">
<source>Scheduled</source> <source>Scheduled</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">114</context> <context context-type="linenumber">115</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5502398334173581061" datatype="html"> <trans-unit id="5502398334173581061" datatype="html">
<source>Assignment</source> <source>Assignment</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">121</context> <context context-type="linenumber">122</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6234812824772766804" datatype="html"> <trans-unit id="6234812824772766804" datatype="html">
<source>Removal</source> <source>Removal</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">125</context> <context context-type="linenumber">126</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4206419737792796794" datatype="html"> <trans-unit id="4206419737792796794" datatype="html">
<source>Webhook</source> <source>Webhook</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">133</context> <context context-type="linenumber">134</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3138206142174978019" datatype="html"> <trans-unit id="3138206142174978019" datatype="html">
<source>Create new workflow</source> <source>Create new workflow</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">229</context> <context context-type="linenumber">231</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5996779210524133604" datatype="html"> <trans-unit id="5996779210524133604" datatype="html">
<source>Edit workflow</source> <source>Edit workflow</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">233</context> <context context-type="linenumber">235</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7376342558017986274" datatype="html"> <trans-unit id="7376342558017986274" datatype="html">
@ -6508,7 +6555,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">160</context> <context context-type="linenumber">180</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context> <context context-type="sourcefile">src/app/data/document.ts</context>
@ -7136,7 +7183,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">168</context> <context context-type="linenumber">188</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6475890479659129881" datatype="html"> <trans-unit id="6475890479659129881" datatype="html">
@ -7431,21 +7478,21 @@
<source>Merged document will be queued for consumption.</source> <source>Merged document will be queued for consumption.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">863</context> <context context-type="linenumber">866</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="476913782630693351" datatype="html"> <trans-unit id="476913782630693351" datatype="html">
<source>Custom fields updated.</source> <source>Custom fields updated.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">885</context> <context context-type="linenumber">888</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3873496751167944011" datatype="html"> <trans-unit id="3873496751167944011" datatype="html">
<source>Error updating custom fields.</source> <source>Error updating custom fields.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">894</context> <context context-type="linenumber">897</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6307402210351946694" datatype="html"> <trans-unit id="6307402210351946694" datatype="html">
@ -7713,7 +7760,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.html</context>
<context context-type="linenumber">111</context> <context context-type="linenumber">112</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1559883523769732271" datatype="html"> <trans-unit id="1559883523769732271" datatype="html">
@ -7738,7 +7785,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">165</context> <context context-type="linenumber">185</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/data/document.ts</context> <context context-type="sourcefile">src/app/data/document.ts</context>
@ -7934,154 +7981,154 @@
<source>Title &amp; content</source> <source>Title &amp; content</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">163</context> <context context-type="linenumber">183</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7408932238599462499" datatype="html"> <trans-unit id="7408932238599462499" datatype="html">
<source>File type</source> <source>File type</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">170</context> <context context-type="linenumber">190</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2649431021108393503" datatype="html"> <trans-unit id="2649431021108393503" datatype="html">
<source>More like</source> <source>More like</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">179</context> <context context-type="linenumber">199</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3697582909018473071" datatype="html"> <trans-unit id="3697582909018473071" datatype="html">
<source>equals</source> <source>equals</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">185</context> <context context-type="linenumber">205</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5325481293405718739" datatype="html"> <trans-unit id="5325481293405718739" datatype="html">
<source>is empty</source> <source>is empty</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">189</context> <context context-type="linenumber">209</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6166785695326182482" datatype="html"> <trans-unit id="6166785695326182482" datatype="html">
<source>is not empty</source> <source>is not empty</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">193</context> <context context-type="linenumber">213</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4686622206659266699" datatype="html"> <trans-unit id="4686622206659266699" datatype="html">
<source>greater than</source> <source>greater than</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">197</context> <context context-type="linenumber">217</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8014012170270529279" datatype="html"> <trans-unit id="8014012170270529279" datatype="html">
<source>less than</source> <source>less than</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">201</context> <context context-type="linenumber">221</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5195932016807797291" datatype="html"> <trans-unit id="5195932016807797291" datatype="html">
<source>Correspondent: <x id="PH" equiv-text="this.correspondents.find((c) =&gt; c.id == +rule.value)?.name"/></source> <source>Correspondent: <x id="PH" equiv-text="this.correspondents.find((c) =&gt; c.id == +rule.value)?.name"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">233,235</context> <context context-type="linenumber">253,255</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8170755470576301659" datatype="html"> <trans-unit id="8170755470576301659" datatype="html">
<source>Without correspondent</source> <source>Without correspondent</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">237</context> <context context-type="linenumber">257</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="317796810569008208" datatype="html"> <trans-unit id="317796810569008208" datatype="html">
<source>Document type: <x id="PH" equiv-text="this.documentTypes.find((dt) =&gt; dt.id == +rule.value)?.name"/></source> <source>Document type: <x id="PH" equiv-text="this.documentTypes.find((dt) =&gt; dt.id == +rule.value)?.name"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">243,245</context> <context context-type="linenumber">263,265</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4362173610367509215" datatype="html"> <trans-unit id="4362173610367509215" datatype="html">
<source>Without document type</source> <source>Without document type</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">247</context> <context context-type="linenumber">267</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="232202047340644471" datatype="html"> <trans-unit id="232202047340644471" datatype="html">
<source>Storage path: <x id="PH" equiv-text="this.storagePaths.find((sp) =&gt; sp.id == +rule.value)?.name"/></source> <source>Storage path: <x id="PH" equiv-text="this.storagePaths.find((sp) =&gt; sp.id == +rule.value)?.name"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">253,255</context> <context context-type="linenumber">273,275</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1562820715074533164" datatype="html"> <trans-unit id="1562820715074533164" datatype="html">
<source>Without storage path</source> <source>Without storage path</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">257</context> <context context-type="linenumber">277</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8180755793012580465" datatype="html"> <trans-unit id="8180755793012580465" datatype="html">
<source>Tag: <x id="PH" equiv-text="this.tags.find((t) =&gt; t.id == +rule.value)?.name"/></source> <source>Tag: <x id="PH" equiv-text="this.tags.find((t) =&gt; t.id == +rule.value)?.name"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">261,263</context> <context context-type="linenumber">281,283</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6494566478302448576" datatype="html"> <trans-unit id="6494566478302448576" datatype="html">
<source>Without any tag</source> <source>Without any tag</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">267</context> <context context-type="linenumber">287</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8644099678903817943" datatype="html"> <trans-unit id="8644099678903817943" datatype="html">
<source>Custom fields query</source> <source>Custom fields query</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">271</context> <context context-type="linenumber">291</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6523384805359286307" datatype="html"> <trans-unit id="6523384805359286307" datatype="html">
<source>Title: <x id="PH" equiv-text="rule.value"/></source> <source>Title: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">274</context> <context context-type="linenumber">294</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1872523635812236432" datatype="html"> <trans-unit id="1872523635812236432" datatype="html">
<source>ASN: <x id="PH" equiv-text="rule.value"/></source> <source>ASN: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">277</context> <context context-type="linenumber">297</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="102674688969746976" datatype="html"> <trans-unit id="102674688969746976" datatype="html">
<source>Owner: <x id="PH" equiv-text="rule.value"/></source> <source>Owner: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">280</context> <context context-type="linenumber">300</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3550877650686009106" datatype="html"> <trans-unit id="3550877650686009106" datatype="html">
<source>Owner not in: <x id="PH" equiv-text="rule.value"/></source> <source>Owner not in: <x id="PH" equiv-text="rule.value"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">283</context> <context context-type="linenumber">303</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1082034558646673343" datatype="html"> <trans-unit id="1082034558646673343" datatype="html">
<source>Without an owner</source> <source>Without an owner</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/filter-editor/filter-editor.component.ts</context>
<context context-type="linenumber">286</context> <context context-type="linenumber">306</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7210076240260527720" datatype="html"> <trans-unit id="7210076240260527720" datatype="html">
@ -9415,13 +9462,6 @@
<context context-type="linenumber">25</context> <context context-type="linenumber">25</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4498682414491138092" datatype="html">
<source>Yesterday</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/pipes/custom-date.pipe.ts</context>
<context context-type="linenumber">29</context>
</context-group>
</trans-unit>
<trans-unit id="5601594741748068208" datatype="html"> <trans-unit id="5601594741748068208" datatype="html">
<source>%s days ago</source> <source>%s days ago</source>
<context-group purpose="location"> <context-group purpose="location">

245
src-ui/package-lock.json generated
View File

@ -44,23 +44,24 @@
"@angular-devkit/build-angular": "^19.0.4", "@angular-devkit/build-angular": "^19.0.4",
"@angular-devkit/core": "^19.2.0", "@angular-devkit/core": "^19.2.0",
"@angular-devkit/schematics": "^19.2.0", "@angular-devkit/schematics": "^19.2.0",
"@angular-eslint/builder": "19.1.0", "@angular-eslint/builder": "19.2.0",
"@angular-eslint/eslint-plugin": "19.1.0", "@angular-eslint/eslint-plugin": "19.2.0",
"@angular-eslint/eslint-plugin-template": "19.1.0", "@angular-eslint/eslint-plugin-template": "19.2.0",
"@angular-eslint/schematics": "19.1.0", "@angular-eslint/schematics": "19.2.0",
"@angular-eslint/template-parser": "19.1.0", "@angular-eslint/template-parser": "19.2.0",
"@angular/cli": "~19.2.0", "@angular/cli": "~19.2.0",
"@angular/compiler-cli": "~19.2.0", "@angular/compiler-cli": "~19.2.0",
"@codecov/webpack-plugin": "^1.9.0", "@codecov/webpack-plugin": "^1.9.0",
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.13.8", "@types/node": "^22.13.9",
"@typescript-eslint/eslint-plugin": "^8.25.0", "@typescript-eslint/eslint-plugin": "^8.26.0",
"@typescript-eslint/parser": "^8.25.0", "@typescript-eslint/parser": "^8.26.0",
"@typescript-eslint/utils": "^8.0.0", "@typescript-eslint/utils": "^8.0.0",
"eslint": "^9.21.0", "eslint": "^9.21.0",
"jest": "29.7.0", "jest": "29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-junit": "^16.0.0",
"jest-preset-angular": "^14.5.3", "jest-preset-angular": "^14.5.3",
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
@ -1148,9 +1149,9 @@
} }
}, },
"node_modules/@angular-eslint/builder": { "node_modules/@angular-eslint/builder": {
"version": "19.1.0", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-19.1.0.tgz", "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-19.2.0.tgz",
"integrity": "sha512-LWdQMTES/7GySlpTNFJn3k33ZGmjjWlHI/+IHV7B3xHQ9hj4MPK4ACmE/PNOAIQ9LwQm7sKS+3cTMxOZQ/cvSg==", "integrity": "sha512-8Lx24MrMJT8RlgDtwqfiLiJo4DzSaktjco6RmELUdWO2chJgRe9y+2iIgOeB2pmyD9UCsubwsfjBXlrnV/MPhQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1163,21 +1164,21 @@
} }
}, },
"node_modules/@angular-eslint/bundled-angular-compiler": { "node_modules/@angular-eslint/bundled-angular-compiler": {
"version": "19.1.0", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-19.1.0.tgz", "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-19.2.0.tgz",
"integrity": "sha512-HUJyukRvnh8Z9lIdxdblBRuBaPYEVv4iAYZMw3d+dn4rrM27Nt5oh3/zkwYrrPkt36tZdeXdDWrOuz9jgjVN5w==", "integrity": "sha512-hmmAogTpYGbBvnJ0j7DNLi8YQ+YEEuwFdx0heU8XjTpZlRoSRIP7MJJVlaQCt+ZT5f5XwdGtqi9lOXqqcyGHLA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@angular-eslint/eslint-plugin": { "node_modules/@angular-eslint/eslint-plugin": {
"version": "19.1.0", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-19.1.0.tgz", "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-19.2.0.tgz",
"integrity": "sha512-TDO0+Ry+oNkxnaLHogKp1k2aey6IkJef5d7hathE4UFT6owjRizltWaRoX6bGw7Qu1yagVLL8L2Se8SddxSPAQ==", "integrity": "sha512-QQWWDrTdJ22tBd7RLFG/FdPwNyYEhg7YwWgn29z6XcdnV00ZFtf7FRbv/te1kqVNPvfjtht7bvtHcPQ432aUdQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@angular-eslint/bundled-angular-compiler": "19.1.0", "@angular-eslint/bundled-angular-compiler": "19.2.0",
"@angular-eslint/utils": "19.1.0" "@angular-eslint/utils": "19.2.0"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/utils": "^7.11.0 || ^8.0.0", "@typescript-eslint/utils": "^7.11.0 || ^8.0.0",
@ -1186,14 +1187,14 @@
} }
}, },
"node_modules/@angular-eslint/eslint-plugin-template": { "node_modules/@angular-eslint/eslint-plugin-template": {
"version": "19.1.0", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-19.1.0.tgz", "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-19.2.0.tgz",
"integrity": "sha512-bIUizkCY40mnU8oAO1tLV7uN2H/cHf1evLlhpqlb9JYwc5dT2moiEhNDo61OtOgkJmDGNuThAeO9Xk9hGQc7nA==", "integrity": "sha512-lUSzmk5/Dr0bNc2Omb5CZDu3zQZh70bJyuXnN5MKd00V1b3u90eqvMSveFzWFJ6Eot8Hh8+FxtiozPwGqOE+Og==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@angular-eslint/bundled-angular-compiler": "19.1.0", "@angular-eslint/bundled-angular-compiler": "19.2.0",
"@angular-eslint/utils": "19.1.0", "@angular-eslint/utils": "19.2.0",
"aria-query": "5.3.2", "aria-query": "5.3.2",
"axobject-query": "4.1.0" "axobject-query": "4.1.0"
}, },
@ -1204,17 +1205,47 @@
"typescript": "*" "typescript": "*"
} }
}, },
"node_modules/@angular-eslint/eslint-plugin-template/node_modules/@angular-eslint/utils": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-19.2.0.tgz",
"integrity": "sha512-1XQXzIqYadKUxcAgW1DPev56SVbR8Uld6TthgolU7rfIX23RYMIIRtQlrQCk7zoXLXm5fzcGqjTR4wHfoD+iWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-eslint/bundled-angular-compiler": "19.2.0"
},
"peerDependencies": {
"@typescript-eslint/utils": "^7.11.0 || ^8.0.0",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": "*"
}
},
"node_modules/@angular-eslint/eslint-plugin/node_modules/@angular-eslint/utils": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-19.2.0.tgz",
"integrity": "sha512-1XQXzIqYadKUxcAgW1DPev56SVbR8Uld6TthgolU7rfIX23RYMIIRtQlrQCk7zoXLXm5fzcGqjTR4wHfoD+iWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-eslint/bundled-angular-compiler": "19.2.0"
},
"peerDependencies": {
"@typescript-eslint/utils": "^7.11.0 || ^8.0.0",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": "*"
}
},
"node_modules/@angular-eslint/schematics": { "node_modules/@angular-eslint/schematics": {
"version": "19.1.0", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-19.1.0.tgz", "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-19.2.0.tgz",
"integrity": "sha512-6S1FjmM7rZxc0u0W0KjqWYOkFQ0q89IGyjPkdUt1a8NwRnWg3VoXp4WYfeuZOjda/FEYuBS/E6rckLAMp0h6Aw==", "integrity": "sha512-SQfbKgPEJNkK5TVXRsdnWp6TjvVZOczvf8lELF1n+I/Uwmp7ulUjTRgTo59ZQnXoPSs2qCPgS4gAOVR6CD91zQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@angular-devkit/core": ">= 19.0.0 < 20.0.0", "@angular-devkit/core": ">= 19.0.0 < 20.0.0",
"@angular-devkit/schematics": ">= 19.0.0 < 20.0.0", "@angular-devkit/schematics": ">= 19.0.0 < 20.0.0",
"@angular-eslint/eslint-plugin": "19.1.0", "@angular-eslint/eslint-plugin": "19.2.0",
"@angular-eslint/eslint-plugin-template": "19.1.0", "@angular-eslint/eslint-plugin-template": "19.2.0",
"ignore": "7.0.3", "ignore": "7.0.3",
"semver": "7.7.1", "semver": "7.7.1",
"strip-json-comments": "3.1.1" "strip-json-comments": "3.1.1"
@ -1231,13 +1262,13 @@
} }
}, },
"node_modules/@angular-eslint/template-parser": { "node_modules/@angular-eslint/template-parser": {
"version": "19.1.0", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-19.1.0.tgz", "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-19.2.0.tgz",
"integrity": "sha512-wbMi7adlC+uYqZo7NHNBShpNhFJRZsXLqihqvFpAUt1Ei6uDX8HR6MyMEDZ9tUnlqtPVW5nmbedPyLVG7HkjAA==", "integrity": "sha512-VqgvFrILhoMe0GHZrx+Bjy8kx7/LJfJTd+x/wzE/X1cCChSU81MBZFMVeFMnoI75OOQUf4fwaaKrtUhUvAkVyw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@angular-eslint/bundled-angular-compiler": "19.1.0", "@angular-eslint/bundled-angular-compiler": "19.2.0",
"eslint-scope": "^8.0.2" "eslint-scope": "^8.0.2"
}, },
"peerDependencies": { "peerDependencies": {
@ -1245,21 +1276,6 @@
"typescript": "*" "typescript": "*"
} }
}, },
"node_modules/@angular-eslint/utils": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-19.1.0.tgz",
"integrity": "sha512-mcb7hPMH/u6wwUwvsewrmgb9y9NWN6ZacvpUvKlTOxF/jOtTdsu0XfV4YB43sp2A8NWzYzX0Str4c8K1xSmuBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-eslint/bundled-angular-compiler": "19.1.0"
},
"peerDependencies": {
"@typescript-eslint/utils": "^7.11.0 || ^8.0.0",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": "*"
}
},
"node_modules/@angular/cdk": { "node_modules/@angular/cdk": {
"version": "19.2.1", "version": "19.2.1",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.1.tgz", "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.1.tgz",
@ -7150,9 +7166,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.8", "version": "22.13.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.8.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz",
"integrity": "sha512-G3EfaZS+iOGYWLLRCEAXdWK9my08oHNZ+FHluRiggIYJPOXzhOiDgpVCUHaUvyIC5/fj7C/p637jdzC666AOKQ==", "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -7271,17 +7287,17 @@
"dev": true "dev": true
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.25.0", "version": "8.26.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.25.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.0.tgz",
"integrity": "sha512-VM7bpzAe7JO/BFf40pIT1lJqS/z1F8OaSsUB3rpFJucQA4cOSuH2RVVVkFULN+En0Djgr29/jb4EQnedUo95KA==", "integrity": "sha512-cLr1J6pe56zjKYajK6SSSre6nl1Gj6xDp1TY0trpgPzjVbgDwd09v2Ws37LABxzkicmUjhEeg/fAUjPJJB1v5Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.10.0", "@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.25.0", "@typescript-eslint/scope-manager": "8.26.0",
"@typescript-eslint/type-utils": "8.25.0", "@typescript-eslint/type-utils": "8.26.0",
"@typescript-eslint/utils": "8.25.0", "@typescript-eslint/utils": "8.26.0",
"@typescript-eslint/visitor-keys": "8.25.0", "@typescript-eslint/visitor-keys": "8.26.0",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"ignore": "^5.3.1", "ignore": "^5.3.1",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@ -7297,20 +7313,20 @@
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0",
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.8.0" "typescript": ">=4.8.4 <5.9.0"
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.25.0", "version": "8.26.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.25.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.26.0.tgz",
"integrity": "sha512-4gbs64bnbSzu4FpgMiQ1A+D+urxkoJk/kqlDJ2W//5SygaEiAP2B4GoS7TEdxgwol2el03gckFV9lJ4QOMiiHg==", "integrity": "sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.25.0", "@typescript-eslint/scope-manager": "8.26.0",
"@typescript-eslint/types": "8.25.0", "@typescript-eslint/types": "8.26.0",
"@typescript-eslint/typescript-estree": "8.25.0", "@typescript-eslint/typescript-estree": "8.26.0",
"@typescript-eslint/visitor-keys": "8.25.0", "@typescript-eslint/visitor-keys": "8.26.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -7322,18 +7338,18 @@
}, },
"peerDependencies": { "peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.8.0" "typescript": ">=4.8.4 <5.9.0"
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.25.0", "version": "8.26.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.25.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.26.0.tgz",
"integrity": "sha512-6PPeiKIGbgStEyt4NNXa2ru5pMzQ8OYKO1hX1z53HMomrmiSB+R5FmChgQAP1ro8jMtNawz+TRQo/cSXrauTpg==", "integrity": "sha512-E0ntLvsfPqnPwng8b8y4OGuzh/iIOm2z8U3S9zic2TeMLW61u5IH2Q1wu0oSTkfrSzwbDJIB/Lm8O3//8BWMPA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.25.0", "@typescript-eslint/types": "8.26.0",
"@typescript-eslint/visitor-keys": "8.25.0" "@typescript-eslint/visitor-keys": "8.26.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -7344,14 +7360,14 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.25.0", "version": "8.26.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.25.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.26.0.tgz",
"integrity": "sha512-d77dHgHWnxmXOPJuDWO4FDWADmGQkN5+tt6SFRZz/RtCWl4pHgFl3+WdYCn16+3teG09DY6XtEpf3gGD0a186g==", "integrity": "sha512-ruk0RNChLKz3zKGn2LwXuVoeBcUMh+jaqzN461uMMdxy5H9epZqIBtYj7UiPXRuOpaALXGbmRuZQhmwHhaS04Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/typescript-estree": "8.25.0", "@typescript-eslint/typescript-estree": "8.26.0",
"@typescript-eslint/utils": "8.25.0", "@typescript-eslint/utils": "8.26.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^2.0.1" "ts-api-utils": "^2.0.1"
}, },
@ -7364,13 +7380,13 @@
}, },
"peerDependencies": { "peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.8.0" "typescript": ">=4.8.4 <5.9.0"
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.25.0", "version": "8.26.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.25.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.26.0.tgz",
"integrity": "sha512-+vUe0Zb4tkNgznQwicsvLUJgZIRs6ITeWSCclX1q85pR1iOiaj+4uZJIUp//Z27QWu5Cseiw3O3AR8hVpax7Aw==", "integrity": "sha512-89B1eP3tnpr9A8L6PZlSjBvnJhWXtYfZhECqlBl1D9Lme9mHO6iWlsprBtVenQvY1HMhax1mWOjhtL3fh/u+pA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -7382,14 +7398,14 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.25.0", "version": "8.26.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.25.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.0.tgz",
"integrity": "sha512-ZPaiAKEZ6Blt/TPAx5Ot0EIB/yGtLI2EsGoY6F7XKklfMxYQyvtL+gT/UCqkMzO0BVFHLDlzvFqQzurYahxv9Q==", "integrity": "sha512-tiJ1Hvy/V/oMVRTbEOIeemA2XoylimlDQ03CgPPNaHYZbpsc78Hmngnt+WXZfJX1pjQ711V7g0H7cSJThGYfPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.25.0", "@typescript-eslint/types": "8.26.0",
"@typescript-eslint/visitor-keys": "8.25.0", "@typescript-eslint/visitor-keys": "8.26.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@ -7405,7 +7421,7 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": ">=4.8.4 <5.8.0" "typescript": ">=4.8.4 <5.9.0"
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
@ -7435,16 +7451,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.25.0", "version": "8.26.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.25.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.26.0.tgz",
"integrity": "sha512-syqRbrEv0J1wywiLsK60XzHnQe/kRViI3zwFALrNEgnntn1l24Ra2KvOAWwWbWZ1lBZxZljPDGOq967dsl6fkA==", "integrity": "sha512-2L2tU3FVwhvU14LndnQCA2frYC8JnPDVKyQtWFPf8IYFMt/ykEN1bPolNhNbCVgOmdzTlWdusCTKA/9nKrf8Ig==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.25.0", "@typescript-eslint/scope-manager": "8.26.0",
"@typescript-eslint/types": "8.25.0", "@typescript-eslint/types": "8.26.0",
"@typescript-eslint/typescript-estree": "8.25.0" "@typescript-eslint/typescript-estree": "8.26.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -7455,17 +7471,17 @@
}, },
"peerDependencies": { "peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.8.0" "typescript": ">=4.8.4 <5.9.0"
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.25.0", "version": "8.26.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.25.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.0.tgz",
"integrity": "sha512-kCYXKAum9CecGVHGij7muybDfTS2sD3t0L4bJsEZLkyrXUImiCTq1M3LG2SRtOhiHFwMR9wAFplpT6XHYjTkwQ==", "integrity": "sha512-2z8JQJWAzPdDd51dRQ/oqIJxe99/hoLIqmf8RMCAJQtYDc535W/Jt2+RTP4bP0aKeBG1F65yjIZuczOXCmbWwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.25.0", "@typescript-eslint/types": "8.26.0",
"eslint-visitor-keys": "^4.2.0" "eslint-visitor-keys": "^4.2.0"
}, },
"engines": { "engines": {
@ -12403,6 +12419,32 @@
"fsevents": "^2.3.2" "fsevents": "^2.3.2"
} }
}, },
"node_modules/jest-junit": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/jest-junit/-/jest-junit-16.0.0.tgz",
"integrity": "sha512-A94mmw6NfJab4Fg/BlvVOUXzXgF0XIH6EmTgJ5NDPp4xoKq0Kr7sErb+4Xs9nZvu58pJojz5RFGpqnZYJTrRfQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"mkdirp": "^1.0.4",
"strip-ansi": "^6.0.1",
"uuid": "^8.3.2",
"xml": "^1.0.1"
},
"engines": {
"node": ">=10.12.0"
}
},
"node_modules/jest-junit/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/jest-leak-detector": { "node_modules/jest-leak-detector": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz",
@ -19250,6 +19292,13 @@
} }
} }
}, },
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"dev": true,
"license": "MIT"
},
"node_modules/xml-name-validator": { "node_modules/xml-name-validator": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",

View File

@ -46,23 +46,24 @@
"@angular-devkit/build-angular": "^19.0.4", "@angular-devkit/build-angular": "^19.0.4",
"@angular-devkit/core": "^19.2.0", "@angular-devkit/core": "^19.2.0",
"@angular-devkit/schematics": "^19.2.0", "@angular-devkit/schematics": "^19.2.0",
"@angular-eslint/builder": "19.1.0", "@angular-eslint/builder": "19.2.0",
"@angular-eslint/eslint-plugin": "19.1.0", "@angular-eslint/eslint-plugin": "19.2.0",
"@angular-eslint/eslint-plugin-template": "19.1.0", "@angular-eslint/eslint-plugin-template": "19.2.0",
"@angular-eslint/schematics": "19.1.0", "@angular-eslint/schematics": "19.2.0",
"@angular-eslint/template-parser": "19.1.0", "@angular-eslint/template-parser": "19.2.0",
"@angular/cli": "~19.2.0", "@angular/cli": "~19.2.0",
"@angular/compiler-cli": "~19.2.0", "@angular/compiler-cli": "~19.2.0",
"@codecov/webpack-plugin": "^1.9.0", "@codecov/webpack-plugin": "^1.9.0",
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.13.8", "@types/node": "^22.13.9",
"@typescript-eslint/eslint-plugin": "^8.25.0", "@typescript-eslint/eslint-plugin": "^8.26.0",
"@typescript-eslint/parser": "^8.25.0", "@typescript-eslint/parser": "^8.26.0",
"@typescript-eslint/utils": "^8.0.0", "@typescript-eslint/utils": "^8.0.0",
"eslint": "^9.21.0", "eslint": "^9.21.0",
"jest": "29.7.0", "jest": "29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-junit": "^16.0.0",
"jest-preset-angular": "^14.5.3", "jest-preset-angular": "^14.5.3",
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",

View File

@ -21,7 +21,7 @@
} }
<div class="scroll-list"> <div class="scroll-list">
@for (toast of toasts; track toast.id) { @for (toast of toasts; track toast.id) {
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (close)="toastService.closeToast(toast)"></pngx-toast> <pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
} }
</div> </div>
</div> </div>

View File

@ -28,10 +28,16 @@
</select> </select>
</div> </div>
<div class="form-check form-switch mt-4"> <div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" role="switch" id="archiveFallbackSwitch" [(ngModel)]="archiveFallback">
<label class="form-check-label" for="archiveFallbackSwitch" i18n>Try to include archive version in merge for non-PDF files</label>
</div>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalsSwitch" [(ngModel)]="deleteOriginals" [disabled]="!userOwnsAllDocuments"> <input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalsSwitch" [(ngModel)]="deleteOriginals" [disabled]="!userOwnsAllDocuments">
<label class="form-check-label" for="deleteOriginalsSwitch" i18n>Delete original documents after successful merge</label> <label class="form-check-label" for="deleteOriginalsSwitch" i18n>Delete original documents after successful merge</label>
</div> </div>
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p> @if (!archiveFallback) {
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled"> <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">

View File

@ -29,6 +29,7 @@ export class MergeConfirmDialogComponent
implements OnInit implements OnInit
{ {
public documentIDs: number[] = [] public documentIDs: number[] = []
public archiveFallback: boolean = false
public deleteOriginals: boolean = false public deleteOriginals: boolean = false
private _documents: Document[] = [] private _documents: Document[] = []
get documents(): Document[] { get documents(): Document[] {

View File

@ -34,7 +34,7 @@ import {
CustomFieldQueryElement, CustomFieldQueryElement,
CustomFieldQueryExpression, CustomFieldQueryExpression,
} from 'src/app/utils/custom-field-query-element' } from 'src/app/utils/custom-field-query-element'
import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options' import { pngxPopperOptions } from 'src/app/utils/popper-options'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
import { DocumentLinkComponent } from '../input/document-link/document-link.component' import { DocumentLinkComponent } from '../input/document-link/document-link.component'
@ -183,7 +183,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
public CustomFieldDataType = CustomFieldDataType public CustomFieldDataType = CustomFieldDataType
public CUSTOM_FIELD_QUERY_MAX_DEPTH = CUSTOM_FIELD_QUERY_MAX_DEPTH public CUSTOM_FIELD_QUERY_MAX_DEPTH = CUSTOM_FIELD_QUERY_MAX_DEPTH
public CUSTOM_FIELD_QUERY_MAX_ATOMS = CUSTOM_FIELD_QUERY_MAX_ATOMS public CUSTOM_FIELD_QUERY_MAX_ATOMS = CUSTOM_FIELD_QUERY_MAX_ATOMS
public popperOptions = popperOptionsReenablePreventOverflow public popperOptions = pngxPopperOptions
@Input() @Input()
title: string title: string

View File

@ -1,161 +1,158 @@
<div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions"> <div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions" [placement]="placement">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs> <i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div> <div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span> <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
</button> </button>
<div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}"> <div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
<div class="row d-flex"> <h6 class="dropdown-header border-bottom" i18n>Created</h6>
<div class="col border-end"> <div class="list-group list-group-flush">
<div class="list-group list-group-flush"> <div class="list-group-item d-flex p-2 select-item" role="menuitem">
<h6 class="dropdown-header border-bottom" i18n>Created</h6> <div class="selected-icon">
@for (rd of relativeDates; track rd) { @if (createdRelativeDate) {
<button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setCreatedRelativeDate(rd.id)"> <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()">
<div class="selected-icon"> <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
@if (createdRelativeDate === rd.id) { <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
<i-bs width="1em" height="1em" name="check"></i-bs> </a>
}
</div>
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
<div class="pe-4">
{{rd.name}}
</div>
<div class="text-muted small pe-2">
<span class="small">
{{ rd.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container>
</span>
</div>
</div>
</button>
} }
<div class="list-group-item d-flex p-2" role="menuitem"> </div>
<div class="input-group input-group-sm small ps-1 pe-2">
<div class="selected-icon"> <ng-select class="w-100" name="createdRelativeDate"
@if (createdDateFrom) { [items]="relativeDates" [(ngModel)]="createdRelativeDate"
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()"> bindValue="id"
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> bindLabel="name"
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> clearable="false"
</a> placeholder="Relative dates"
} i18n-placeholder
</div> (change)="onSetCreatedRelativeDate($event)">
<div class="input-group input-group-sm small ps-1 pe-2"> <ng-template ng-option-tmp let-item="item">
<span class="input-group-text w-25 small text-muted" i18n>From</span> <div class="d-flex">{{ item.name }}<span class="ms-auto text-muted small">{{ item.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container></span></div>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" </ng-template>
maxlength="10" [(ngModel)]="createdDateFrom" ngbDatepicker #createdDateFromPicker="ngbDatepicker" [footerTemplate]="createdFromFooterTemplate"> </ng-select>
<button class="btn btn-outline-secondary" (click)="createdDateFromPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
<ng-template #createdFromFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button class="btn btn-primary" (click)="createdDateFrom = today; onChangeDebounce()" i18n>Today</button>
<button class="btn btn-secondary ms-auto" (click)="createdDateFromPicker.close()" i18n>Close</button>
</div>
</ng-template>
</div>
</div>
<div class="list-group-item d-flex p-2" role="menuitem">
<div class="selected-icon">
@if (createdDateTo) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()">
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a>
}
</div>
<div class="input-group input-group-sm small ps-1 pe-2">
<span class="input-group-text w-25 small text-muted" i18n>To</span>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="createdDateTo" ngbDatepicker #createdDateToPicker="ngbDatepicker" [footerTemplate]="createdToFooterTemplate">
<button class="btn btn-outline-secondary" (click)="createdDateToPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
<ng-template #createdToFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button class="btn btn-primary" (click)="createdDateTo = today; onChangeDebounce()" i18n>Today</button>
<button class="btn btn-secondary ms-auto" (click)="createdDateToPicker.close()" i18n>Close</button>
</div>
</ng-template>
</div>
</div>
</div> </div>
</div> </div>
<div class="col"> <div class="list-group-item d-flex p-2" role="menuitem">
<h6 class="dropdown-header border-bottom" i18n>Added</h6> <div class="selected-icon">
<div class="list-group list-group-flush"> @if (createdDateFrom) {
@for (rd of relativeDates; track rd) { <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()">
<button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setAddedRelativeDate(rd.id)"> <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<div class="selected-icon"> <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
@if (addedRelativeDate === rd.id) { </a>
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
<div class="pe-4">
{{rd.name}}
</div>
<div class="text-muted small pe-2">
<span class="small">
{{ rd.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container>
</span>
</div>
</div>
</button>
} }
<div class="list-group-item d-flex p-2" role="menuitem"> </div>
<div class="input-group input-group-sm small ps-1 pe-2">
<div class="selected-icon"> <span class="input-group-text w-25 small text-muted" i18n>From</span>
@if (addedDateFrom) { <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()"> maxlength="10" [(ngModel)]="createdDateFrom" ngbDatepicker #createdDateFromPicker="ngbDatepicker" [footerTemplate]="createdFromFooterTemplate">
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> <button class="btn btn-outline-secondary" (click)="createdDateFromPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> <i-bs width="1em" height="1em" name="calendar"></i-bs>
</a> </button>
} <ng-template #createdFromFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button class="btn btn-primary" (click)="createdDateFrom = today; onChangeDebounce()" i18n>Today</button>
<button class="btn btn-secondary ms-auto" (click)="createdDateFromPicker.close()" i18n>Close</button>
</div> </div>
<div class="input-group input-group-sm small ps-1 pe-2"> </ng-template>
<span class="input-group-text w-25 small text-muted" i18n>From</span> </div>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" </div>
maxlength="10" [(ngModel)]="addedDateFrom" ngbDatepicker #addedDateFromPicker="ngbDatepicker" [footerTemplate]="addedFromFooterTemplate"> <div class="list-group-item d-flex p-2" role="menuitem">
<button class="btn btn-outline-secondary" (click)="addedDateFromPicker.toggle()" type="button"> <div class="selected-icon">
<i-bs width="1em" height="1em" name="calendar"></i-bs> @if (createdDateTo) {
</button> <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()">
<ng-template #addedFromFooterTemplate> <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<div class="btn-group-xs border-top p-2 d-flex"> <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
<button class="btn btn-primary" (click)="addedDateFrom = today; onChangeDebounce()" i18n>Today</button> </a>
<button class="btn btn-secondary ms-auto" (click)="addedDateFromPicker.close()" i18n>Close</button> }
</div> </div>
</ng-template> <div class="input-group input-group-sm small ps-1 pe-2">
<span class="input-group-text w-25 small text-muted" i18n>To</span>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="createdDateTo" ngbDatepicker #createdDateToPicker="ngbDatepicker" [footerTemplate]="createdToFooterTemplate">
<button class="btn btn-outline-secondary" (click)="createdDateToPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
<ng-template #createdToFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button class="btn btn-primary" (click)="createdDateTo = today; onChangeDebounce()" i18n>Today</button>
<button class="btn btn-secondary ms-auto" (click)="createdDateToPicker.close()" i18n>Close</button>
</div> </div>
</ng-template>
</div>
</div> </div>
<div class="list-group-item d-flex p-2" role="menuitem"> </div>
<h6 class="dropdown-header border-bottom" i18n>Added</h6>
<div class="selected-icon"> <div class="list-group list-group-flush">
@if (addedDateTo) { <div class="list-group-item d-flex p-2 select-item" role="menuitem">
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()"> <div class="selected-icon">
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> @if (addedRelativeDate) {
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedRelativeDate()">
</a> <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
} <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a>
}
</div>
<div class="input-group input-group-sm small ps-1 pe-2">
<ng-select class="w-100" name="addedRelativeDate"
[items]="relativeDates" [(ngModel)]="addedRelativeDate"
bindValue="id"
bindLabel="name"
clearable="false"
placeholder="Relative dates"
i18n-placeholder
(change)="onSetAddedRelativeDate($event)">
<ng-template ng-option-tmp let-item="item">
<div class="d-flex">{{ item.name }}<span class="ms-auto text-muted small">{{ item.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container></span></div>
</ng-template>
</ng-select>
</div>
</div>
<div class="list-group-item d-flex p-2" role="menuitem">
<div class="selected-icon">
@if (addedDateFrom) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()">
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a>
}
</div>
<div class="input-group input-group-sm small ps-1 pe-2">
<span class="input-group-text w-25 small text-muted" i18n>From</span>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="addedDateFrom" ngbDatepicker #addedDateFromPicker="ngbDatepicker" [footerTemplate]="addedFromFooterTemplate">
<button class="btn btn-outline-secondary" (click)="addedDateFromPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
<ng-template #addedFromFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button class="btn btn-primary" (click)="addedDateFrom = today; onChangeDebounce()" i18n>Today</button>
<button class="btn btn-secondary ms-auto" (click)="addedDateFromPicker.close()" i18n>Close</button>
</div> </div>
<div class="input-group input-group-sm small ps-1 pe-2"> </ng-template>
<span class="input-group-text w-25 small text-muted" i18n>To</span> </div>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" </div>
maxlength="10" [(ngModel)]="addedDateTo" ngbDatepicker #addedDateToPicker="ngbDatepicker" [footerTemplate]="addedToFooterTemplate"> <div class="list-group-item d-flex p-2" role="menuitem">
<button class="btn btn-outline-secondary" (click)="addedDateToPicker.toggle()" type="button"> <div class="selected-icon">
<i-bs width="1em" height="1em" name="calendar"></i-bs> @if (addedDateTo) {
</button> <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()">
<ng-template #addedToFooterTemplate> <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<div class="btn-group-xs border-top p-2 d-flex"> <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
<button class="btn btn-primary" (click)="addedDateTo = today; onChangeDebounce()" i18n>Today</button> </a>
<button class="btn btn-secondary ms-auto" (click)="addedDateToPicker.close()" i18n>Close</button> }
</div> </div>
</ng-template> <div class="input-group input-group-sm small ps-1 pe-2">
<span class="input-group-text w-25 small text-muted" i18n>To</span>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="addedDateTo" ngbDatepicker #addedDateToPicker="ngbDatepicker" [footerTemplate]="addedToFooterTemplate">
<button class="btn btn-outline-secondary" (click)="addedDateToPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
<ng-template #addedToFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button class="btn btn-primary" (click)="addedDateTo = today; onChangeDebounce()" i18n>Today</button>
<button class="btn btn-secondary ms-auto" (click)="addedDateToPicker.close()" i18n>Close</button>
</div> </div>
</ng-template>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,16 +1,7 @@
.date-dropdown { .date-dropdown {
--bs-dropdown-min-width: 22rem;
white-space: nowrap; white-space: nowrap;
@media(min-width: 768px) {
--bs-dropdown-min-width: 40rem;
}
@media screen and (max-width: 767px) {
.border-end {
border: none !important;
}
}
.btn-link { .btn-link {
line-height: 1; line-height: 1;
} }
@ -21,6 +12,10 @@
min-height: 1em; min-height: 1em;
} }
.select-item .selected-icon {
line-height: 2em;
}
.input-group-sm { .input-group-sm {
.form-control { .form-control {
font-size: 0.875rem; font-size: 0.875rem;

View File

@ -82,10 +82,12 @@ describe('DatesDropdownComponent', () => {
it('should support relative dates', fakeAsync(() => { it('should support relative dates', fakeAsync(() => {
let result: DateSelection let result: DateSelection
component.datesSet.subscribe((date) => (result = date)) component.datesSet.subscribe((date) => (result = date))
component.setCreatedRelativeDate(null) component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
component.setCreatedRelativeDate(RelativeDate.WITHIN_1_WEEK) component.onSetCreatedRelativeDate({
component.setAddedRelativeDate(null) id: RelativeDate.WITHIN_1_WEEK,
component.setAddedRelativeDate(RelativeDate.WITHIN_1_WEEK) } as any)
component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
component.onSetAddedRelativeDate({ id: RelativeDate.WITHIN_1_WEEK } as any)
tick(500) tick(500)
expect(result).toEqual({ expect(result).toEqual({
createdFrom: null, createdFrom: null,
@ -147,8 +149,19 @@ describe('DatesDropdownComponent', () => {
expect(component.addedDateTo).toBeNull() expect(component.addedDateTo).toBeNull()
}) })
it('should support clearRelativeDate', () => {
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK
component.clearCreatedRelativeDate()
expect(component.createdRelativeDate).toBeNull()
component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK
component.clearAddedRelativeDate()
expect(component.addedRelativeDate).toBeNull()
})
it('should limit keyboard events', () => { it('should limit keyboard events', () => {
const input: HTMLInputElement = fixture.nativeElement.querySelector('input') const input: HTMLInputElement =
fixture.nativeElement.querySelector('input.form-control')
let event: KeyboardEvent = new KeyboardEvent('keypress', { let event: KeyboardEvent = new KeyboardEvent('keypress', {
key: '9', key: '9',
}) })
@ -163,4 +176,19 @@ describe('DatesDropdownComponent', () => {
input.dispatchEvent(event) input.dispatchEvent(event)
expect(eventSpy).toHaveBeenCalled() expect(eventSpy).toHaveBeenCalled()
}) })
it('should support debounce', fakeAsync(() => {
let result: DateSelection
component.datesSet.subscribe((date) => (result = date))
component.onChangeDebounce()
tick(500)
expect(result).toEqual({
createdFrom: null,
createdTo: null,
createdRelativeDateID: null,
addedFrom: null,
addedTo: null,
addedRelativeDateID: null,
})
}))
}) })

View File

@ -13,13 +13,14 @@ import {
NgbDatepickerModule, NgbDatepickerModule,
NgbDropdownModule, NgbDropdownModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, Subscription } from 'rxjs' import { Subject, Subscription } from 'rxjs'
import { debounceTime } from 'rxjs/operators' import { debounceTime } from 'rxjs/operators'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options' import { pngxPopperOptions } from 'src/app/utils/popper-options'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
export interface DateSelection { export interface DateSelection {
@ -32,10 +33,14 @@ export interface DateSelection {
} }
export enum RelativeDate { export enum RelativeDate {
WITHIN_1_WEEK = 0, WITHIN_1_WEEK = 1,
WITHIN_1_MONTH = 1, WITHIN_1_MONTH = 2,
WITHIN_3_MONTHS = 2, WITHIN_3_MONTHS = 3,
WITHIN_1_YEAR = 3, WITHIN_1_YEAR = 4,
THIS_YEAR = 5,
THIS_MONTH = 6,
TODAY = 7,
YESTERDAY = 8,
} }
@Component({ @Component({
@ -49,13 +54,14 @@ export enum RelativeDate {
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
NgbDatepickerModule, NgbDatepickerModule,
NgbDropdownModule, NgbDropdownModule,
NgSelectModule,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
NgClass, NgClass,
], ],
}) })
export class DatesDropdownComponent implements OnInit, OnDestroy { export class DatesDropdownComponent implements OnInit, OnDestroy {
public popperOptions = popperOptionsReenablePreventOverflow public popperOptions = pngxPopperOptions
constructor(settings: SettingsService) { constructor(settings: SettingsService) {
this.datePlaceHolder = settings.getLocalizedDateInputFormat() this.datePlaceHolder = settings.getLocalizedDateInputFormat()
@ -82,44 +88,64 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
name: $localize`Within 1 year`, name: $localize`Within 1 year`,
date: new Date().setFullYear(new Date().getFullYear() - 1), date: new Date().setFullYear(new Date().getFullYear() - 1),
}, },
{
id: RelativeDate.THIS_YEAR,
name: $localize`This year`,
date: new Date('1/1/' + new Date().getFullYear()),
},
{
id: RelativeDate.THIS_MONTH,
name: $localize`This month`,
date: new Date().setDate(1),
},
{
id: RelativeDate.TODAY,
name: $localize`Today`,
date: new Date().setHours(0, 0, 0, 0),
},
{
id: RelativeDate.YESTERDAY,
name: $localize`Yesterday`,
date: new Date().setDate(new Date().getDate() - 1),
},
] ]
datePlaceHolder: string datePlaceHolder: string
// created // created
@Input() @Input()
createdDateTo: string createdDateTo: string = null
@Output() @Output()
createdDateToChange = new EventEmitter<string>() createdDateToChange = new EventEmitter<string>()
@Input() @Input()
createdDateFrom: string createdDateFrom: string = null
@Output() @Output()
createdDateFromChange = new EventEmitter<string>() createdDateFromChange = new EventEmitter<string>()
@Input() @Input()
createdRelativeDate: RelativeDate createdRelativeDate: RelativeDate = null
@Output() @Output()
createdRelativeDateChange = new EventEmitter<number>() createdRelativeDateChange = new EventEmitter<number>()
// added // added
@Input() @Input()
addedDateTo: string addedDateTo: string = null
@Output() @Output()
addedDateToChange = new EventEmitter<string>() addedDateToChange = new EventEmitter<string>()
@Input() @Input()
addedDateFrom: string addedDateFrom: string = null
@Output() @Output()
addedDateFromChange = new EventEmitter<string>() addedDateFromChange = new EventEmitter<string>()
@Input() @Input()
addedRelativeDate: RelativeDate addedRelativeDate: RelativeDate = null
@Output() @Output()
addedRelativeDateChange = new EventEmitter<number>() addedRelativeDateChange = new EventEmitter<number>()
@ -133,6 +159,9 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
@Input() @Input()
disabled: boolean = false disabled: boolean = false
@Input()
placement: string = 'bottom-start'
public readonly today: string = new Date().toISOString().split('T')[0] public readonly today: string = new Date().toISOString().split('T')[0]
get isActive(): boolean { get isActive(): boolean {
@ -172,17 +201,17 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
this.onChange() this.onChange()
} }
setCreatedRelativeDate(rd: RelativeDate) { onSetCreatedRelativeDate(rd: { id: number; name: string; date: number }) {
// createdRelativeDate is set by ngModel
this.createdDateTo = null this.createdDateTo = null
this.createdDateFrom = null this.createdDateFrom = null
this.createdRelativeDate = this.createdRelativeDate == rd ? null : rd
this.onChange() this.onChange()
} }
setAddedRelativeDate(rd: RelativeDate) { onSetAddedRelativeDate(rd: { id: number; name: string; date: number }) {
// addedRelativeDate is set by ngModel
this.addedDateTo = null this.addedDateTo = null
this.addedDateFrom = null this.addedDateFrom = null
this.addedRelativeDate = this.addedRelativeDate == rd ? null : rd
this.onChange() this.onChange()
} }
@ -224,6 +253,11 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
this.onChange() this.onChange()
} }
clearCreatedRelativeDate() {
this.createdRelativeDate = null
this.onChange()
}
clearAddedTo() { clearAddedTo() {
this.addedDateTo = null this.addedDateTo = null
this.onChange() this.onChange()
@ -234,6 +268,11 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
this.onChange() this.onChange()
} }
clearAddedRelativeDate() {
this.addedRelativeDate = null
this.onChange()
}
// prevent chars other than numbers and separators // prevent chars other than numbers and separators
onKeyPress(event: KeyboardEvent) { onKeyPress(event: KeyboardEvent) {
if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) { if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) {

View File

@ -189,6 +189,7 @@
<pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select> <pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
<pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select> <pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
<pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select> <pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select>
<pngx-input-custom-fields-values formControlName="assign_custom_fields_values" [selectedFields]="formGroup.get('assign_custom_fields').value" (removeSelectedField)="removeSelectedCustomField($event, formGroup)"></pngx-input-custom-fields-values>
</div> </div>
<div class="col"> <div class="col">
<pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select> <pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select>

View File

@ -2,7 +2,12 @@ import { CdkDragDrop } from '@angular/cdk/drag-drop'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
} from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs' import { of } from 'rxjs'
@ -369,4 +374,19 @@ describe('WorkflowEditDialogComponent', () => {
expect(component.objectForm.get('actions').value[0].email).toBeNull() expect(component.objectForm.get('actions').value[0].email).toBeNull()
expect(component.objectForm.get('actions').value[0].webhook).toBeNull() expect(component.objectForm.get('actions').value[0].webhook).toBeNull()
}) })
it('should remove selected custom field from the form group', () => {
const formGroup = new FormGroup({
assign_custom_fields: new FormControl([1, 2, 3]),
})
component.removeSelectedCustomField(2, formGroup)
expect(formGroup.get('assign_custom_fields').value).toEqual([1, 3])
component.removeSelectedCustomField(1, formGroup)
expect(formGroup.get('assign_custom_fields').value).toEqual([3])
component.removeSelectedCustomField(3, formGroup)
expect(formGroup.get('assign_custom_fields').value).toEqual([])
})
}) })

View File

@ -47,6 +47,7 @@ import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
import { CheckComponent } from '../../input/check/check.component' import { CheckComponent } from '../../input/check/check.component'
import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component'
import { EntriesComponent } from '../../input/entries/entries.component' import { EntriesComponent } from '../../input/entries/entries.component'
import { NumberComponent } from '../../input/number/number.component' import { NumberComponent } from '../../input/number/number.component'
import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component' import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component'
@ -151,6 +152,7 @@ const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
SelectComponent, SelectComponent,
TextAreaComponent, TextAreaComponent,
TagsComponent, TagsComponent,
CustomFieldsValuesComponent,
PermissionsGroupComponent, PermissionsGroupComponent,
PermissionsUserComponent, PermissionsUserComponent,
ConfirmButtonComponent, ConfirmButtonComponent,
@ -439,6 +441,9 @@ export class WorkflowEditDialogComponent
assign_change_users: new FormControl(action.assign_change_users), assign_change_users: new FormControl(action.assign_change_users),
assign_change_groups: new FormControl(action.assign_change_groups), assign_change_groups: new FormControl(action.assign_change_groups),
assign_custom_fields: new FormControl(action.assign_custom_fields), assign_custom_fields: new FormControl(action.assign_custom_fields),
assign_custom_fields_values: new FormControl(
action.assign_custom_fields_values
),
remove_tags: new FormControl(action.remove_tags), remove_tags: new FormControl(action.remove_tags),
remove_all_tags: new FormControl(action.remove_all_tags), remove_all_tags: new FormControl(action.remove_all_tags),
remove_document_types: new FormControl(action.remove_document_types), remove_document_types: new FormControl(action.remove_document_types),
@ -565,6 +570,7 @@ export class WorkflowEditDialogComponent
assign_change_users: [], assign_change_users: [],
assign_change_groups: [], assign_change_groups: [],
assign_custom_fields: [], assign_custom_fields: [],
assign_custom_fields_values: {},
remove_tags: [], remove_tags: [],
remove_all_tags: false, remove_all_tags: false,
remove_document_types: [], remove_document_types: [],
@ -643,4 +649,12 @@ export class WorkflowEditDialogComponent
}) })
super.save() super.save()
} }
public removeSelectedCustomField(fieldId: number, group: FormGroup) {
group
.get('assign_custom_fields')
.setValue(
group.get('assign_custom_fields').value.filter((id) => id !== fieldId)
)
}
} }

View File

@ -17,7 +17,7 @@ import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { FilterPipe } from 'src/app/pipes/filter.pipe' import { FilterPipe } from 'src/app/pipes/filter.pipe'
import { HotKeyService } from 'src/app/services/hot-key.service' import { HotKeyService } from 'src/app/services/hot-key.service'
import { SelectionDataItem } from 'src/app/services/rest/document.service' import { SelectionDataItem } from 'src/app/services/rest/document.service'
import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options' import { pngxPopperOptions } from 'src/app/utils/popper-options'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
import { import {
@ -380,7 +380,7 @@ export class FilterableDropdownComponent
@ViewChild('dropdown') dropdown: NgbDropdown @ViewChild('dropdown') dropdown: NgbDropdown
@ViewChild('buttonItems') buttonItems: ElementRef @ViewChild('buttonItems') buttonItems: ElementRef
public popperOptions = popperOptionsReenablePreventOverflow public popperOptions = pngxPopperOptions
filterText: string filterText: string

View File

@ -0,0 +1,77 @@
<div class="list-group mt-3 selected-fields">
@for (fieldId of selectedFields; track fieldId) {
<div class="list-group-item
d-flex
justify-content-between
align-items-center">
@switch (getCustomField(fieldId)?.data_type) {
@case (CustomFieldDataType.String) {
<pngx-input-text [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"></pngx-input-text>
}
@case (CustomFieldDataType.Date) {
<pngx-input-date [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"></pngx-input-date>
}
@case (CustomFieldDataType.Integer) {
<pngx-input-number [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"
[showAdd]="false"></pngx-input-number>
}
@case (CustomFieldDataType.Float) {
<pngx-input-number [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"
[showAdd]="false"
[step]=".1"></pngx-input-number>
}
@case (CustomFieldDataType.Monetary) {
<pngx-input-monetary [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[defaultCurrency]="getCustomField(fieldId)?.extra_data?.default_currency"
class="flex-grow-1"
[horizontal]="true"></pngx-input-monetary>
}
@case (CustomFieldDataType.Boolean) {
<pngx-input-check [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"></pngx-input-check>
}
@case (CustomFieldDataType.Url) {
<pngx-input-url [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"></pngx-input-url>
}
@case (CustomFieldDataType.DocumentLink) {
<pngx-input-document-link [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[horizontal]="true"></pngx-input-document-link>
}
@case (CustomFieldDataType.Select) {
<pngx-input-select [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
[title]="getCustomField(fieldId)?.name"
class="flex-grow-1"
[items]="getCustomField(fieldId)?.extra_data.select_options"
class="flex-grow-1"
bindLabel="label"
[allowNull]="true"
[horizontal]="true"></pngx-input-select>
}
}
<button type="button" class="btn btn-link text-danger" (click)="removeSelectedField.next(fieldId)">
<i-bs name="trash"></i-bs>
</button>
</div>
}
</div>

View File

@ -0,0 +1,3 @@
:host ::ng-deep .list-group-item .mb-3 {
margin-bottom: 0 !important;
}

View File

@ -0,0 +1,69 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
FormsModule,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms'
import { of } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomFieldsValuesComponent } from './custom-fields-values.component'
describe('CustomFieldsValuesComponent', () => {
let component: CustomFieldsValuesComponent
let fixture: ComponentFixture<CustomFieldsValuesComponent>
let customFieldsService: CustomFieldsService
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [FormsModule, ReactiveFormsModule, CustomFieldsValuesComponent],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(CustomFieldsValuesComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
customFieldsService = TestBed.inject(CustomFieldsService)
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({
all: [1],
count: 1,
results: [
{
id: 1,
name: 'Field 1',
data_type: CustomFieldDataType.String,
} as CustomField,
],
})
)
fixture.detectChanges()
})
beforeEach(() => {
fixture = TestBed.createComponent(CustomFieldsValuesComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should set selectedFields and map values correctly', () => {
component.value = { 1: 'value1' }
component.selectedFields = [1, 2]
expect(component.selectedFields).toEqual([1, 2])
expect(component.value).toEqual({ 1: 'value1', 2: null })
})
it('should return the correct custom field by id', () => {
const field = component.getCustomField(1)
expect(field).toEqual({
id: 1,
name: 'Field 1',
data_type: CustomFieldDataType.String,
} as CustomField)
})
})

View File

@ -0,0 +1,90 @@
import {
Component,
EventEmitter,
forwardRef,
Input,
Output,
} from '@angular/core'
import {
FormsModule,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms'
import { RouterModule } from '@angular/router'
import { NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { AbstractInputComponent } from '../abstract-input'
import { CheckComponent } from '../check/check.component'
import { DateComponent } from '../date/date.component'
import { DocumentLinkComponent } from '../document-link/document-link.component'
import { MonetaryComponent } from '../monetary/monetary.component'
import { NumberComponent } from '../number/number.component'
import { SelectComponent } from '../select/select.component'
import { TextComponent } from '../text/text.component'
import { UrlComponent } from '../url/url.component'
@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomFieldsValuesComponent),
multi: true,
},
],
selector: 'pngx-input-custom-fields-values',
templateUrl: './custom-fields-values.component.html',
styleUrl: './custom-fields-values.component.scss',
imports: [
TextComponent,
DateComponent,
NumberComponent,
DocumentLinkComponent,
UrlComponent,
SelectComponent,
MonetaryComponent,
CheckComponent,
NgSelectModule,
FormsModule,
ReactiveFormsModule,
RouterModule,
NgxBootstrapIconsModule,
],
})
export class CustomFieldsValuesComponent extends AbstractInputComponent<Object> {
public CustomFieldDataType = CustomFieldDataType
constructor(customFieldsService: CustomFieldsService) {
super()
customFieldsService.listAll().subscribe((items) => {
this.fields = items.results
})
}
private fields: CustomField[]
private _selectedFields: number[]
@Input()
set selectedFields(newFields: number[]) {
this._selectedFields = newFields
// map the selected fields to an object with field_id as key and value as value
this.value = newFields.reduce((acc, fieldId) => {
acc[fieldId] = this.value?.[fieldId] || null
return acc
}, {})
this.onChange(this.value)
}
get selectedFields(): number[] {
return this._selectedFields
}
@Output()
public removeSelectedField: EventEmitter<number> = new EventEmitter<number>()
public getCustomField(id: number): CustomField {
return this.fields.find((field) => field.id === id)
}
}

View File

@ -51,6 +51,6 @@
<p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="close.emit(toast); toast.action()">{{toast.actionName}}</button></p> <p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="close.emit(toast); toast.action()">{{toast.actionName}}</button></p>
} }
</div> </div>
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="close.emit(toast);"></button> <button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="closed.emit(toast);"></button>
</div> </div>
</ngb-toast> </ngb-toast>

View File

@ -27,7 +27,7 @@ export class ToastComponent {
@Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>() @Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>()
@Output() close: EventEmitter<Toast> = new EventEmitter<Toast>() @Output() closed: EventEmitter<Toast> = new EventEmitter<Toast>()
public copied: boolean = false public copied: boolean = false

View File

@ -1,3 +1,3 @@
@for (toast of toasts; track toast.id) { @for (toast of toasts; track toast.id) {
<pngx-toast [toast]="toast" [autohide]="true" (close)="closeToast()"></pngx-toast> <pngx-toast [toast]="toast" [autohide]="true" (closed)="closeToast()"></pngx-toast>
} }

View File

@ -1040,6 +1040,27 @@ describe('BulkEditorComponent', () => {
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds ) // listAllFilteredIds
expect(documentListViewService.selected.size).toEqual(0) expect(documentListViewService.selected.size).toEqual(0)
// Test with archiveFallback enabled
modal.componentInstance.deleteOriginals = false
modal.componentInstance.archiveFallback = true
modal.componentInstance.confirm()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'merge',
parameters: { metadata_document_id: 3, archive_fallback: true },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
expect(documentListViewService.selected.size).toEqual(0)
}) })
it('should support bulk download with archive, originals or both and file formatting', () => { it('should support bulk download with archive, originals or both and file formatting', () => {

View File

@ -857,6 +857,9 @@ export class BulkEditorComponent
if (mergeDialog.deleteOriginals) { if (mergeDialog.deleteOriginals) {
args['delete_originals'] = true args['delete_originals'] = true
} }
if (mergeDialog.archiveFallback) {
args['archive_fallback'] = true
}
mergeDialog.buttonsEnabled = false mergeDialog.buttonsEnabled = false
this.executeBulkOperation(modal, 'merge', args, mergeDialog.documentIDs) this.executeBulkOperation(modal, 'merge', args, mergeDialog.documentIDs)
this.toastService.showInfo( this.toastService.showInfo(

View File

@ -93,6 +93,7 @@
} }
<pngx-dates-dropdown class="flex-fill fade" [class.show]="show" <pngx-dates-dropdown class="flex-fill fade" [class.show]="show"
title="Dates" i18n-title title="Dates" i18n-title
placement="bottom-end"
(datesSet)="updateRules()" (datesSet)="updateRules()"
[(createdDateTo)]="dateCreatedTo" [(createdDateTo)]="dateCreatedTo"
[(createdDateFrom)]="dateCreatedFrom" [(createdDateFrom)]="dateCreatedFrom"

View File

@ -96,7 +96,10 @@ import {
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component' import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
import { CustomFieldsQueryDropdownComponent } from '../../common/custom-fields-query-dropdown/custom-fields-query-dropdown.component' import { CustomFieldsQueryDropdownComponent } from '../../common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import { DatesDropdownComponent } from '../../common/dates-dropdown/dates-dropdown.component' import {
DatesDropdownComponent,
RelativeDate,
} from '../../common/dates-dropdown/dates-dropdown.component'
import { import {
FilterableDropdownComponent, FilterableDropdownComponent,
Intersection, Intersection,
@ -422,7 +425,7 @@ describe('FilterEditorComponent', () => {
value: 'created:[-1 week to now]', value: 'created:[-1 week to now]',
}, },
] ]
expect(component.dateCreatedRelativeDate).toEqual(0) // RELATIVE_DATE_QUERYSTRINGS['-1 week to now'] expect(component.dateCreatedRelativeDate).toEqual(1) // RELATIVE_DATE_QUERYSTRINGS['-1 week to now']
expect(component.textFilter).toBeNull() expect(component.textFilter).toBeNull()
})) }))
@ -434,7 +437,7 @@ describe('FilterEditorComponent', () => {
value: 'added:[-1 week to now]', value: 'added:[-1 week to now]',
}, },
] ]
expect(component.dateAddedRelativeDate).toEqual(0) // RELATIVE_DATE_QUERYSTRINGS['-1 week to now'] expect(component.dateAddedRelativeDate).toEqual(1) // RELATIVE_DATE_QUERYSTRINGS['-1 week to now']
expect(component.textFilter).toBeNull() expect(component.textFilter).toBeNull()
})) }))
@ -1587,10 +1590,8 @@ describe('FilterEditorComponent', () => {
const dateCreatedDropdown = fixture.debugElement.queryAll( const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DatesDropdownComponent) By.directive(DatesDropdownComponent)
)[0] )[0]
const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll( component.dateCreatedRelativeDate = RelativeDate.WITHIN_1_WEEK
By.css('button') dateCreatedDropdown.triggerEventHandler('datesSet')
)[1]
dateCreatedBeforeRelativeButton.triggerEventHandler('click')
fixture.detectChanges() fixture.detectChanges()
tick(400) tick(400)
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
@ -1606,10 +1607,8 @@ describe('FilterEditorComponent', () => {
const dateCreatedDropdown = fixture.debugElement.queryAll( const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DatesDropdownComponent) By.directive(DatesDropdownComponent)
)[0] )[0]
const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll( component.dateCreatedRelativeDate = RelativeDate.WITHIN_1_WEEK
By.css('button') dateCreatedDropdown.triggerEventHandler('datesSet')
)[1]
dateCreatedBeforeRelativeButton.triggerEventHandler('click')
fixture.detectChanges() fixture.detectChanges()
tick(400) tick(400)
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
@ -1692,16 +1691,14 @@ describe('FilterEditorComponent', () => {
const datesDropdown = fixture.debugElement.query( const datesDropdown = fixture.debugElement.query(
By.directive(DatesDropdownComponent) By.directive(DatesDropdownComponent)
) )
const dateCreatedBeforeRelativeButton = datesDropdown.queryAll( component.dateAddedRelativeDate = RelativeDate.WITHIN_1_WEEK
By.css('button') datesDropdown.triggerEventHandler('datesSet')
)[1]
dateCreatedBeforeRelativeButton.triggerEventHandler('click')
fixture.detectChanges() fixture.detectChanges()
tick(400) tick(400)
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{ {
rule_type: FILTER_FULLTEXT_QUERY, rule_type: FILTER_FULLTEXT_QUERY,
value: 'created:[-1 week to now]', value: 'added:[-1 week to now]',
}, },
]) ])
})) }))
@ -1711,16 +1708,14 @@ describe('FilterEditorComponent', () => {
const datesDropdown = fixture.debugElement.query( const datesDropdown = fixture.debugElement.query(
By.directive(DatesDropdownComponent) By.directive(DatesDropdownComponent)
) )
const dateCreatedBeforeRelativeButton = datesDropdown.queryAll( component.dateAddedRelativeDate = RelativeDate.WITHIN_1_WEEK
By.css('button') datesDropdown.triggerEventHandler('datesSet')
)[1]
dateCreatedBeforeRelativeButton.triggerEventHandler('click')
fixture.detectChanges() fixture.detectChanges()
tick(400) tick(400)
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{ {
rule_type: FILTER_FULLTEXT_QUERY, rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo,created:[-1 week to now]', value: 'foo,added:[-1 week to now]',
}, },
]) ])
})) }))

View File

@ -135,24 +135,44 @@ const TEXT_FILTER_MODIFIER_NOTNULL = 'not null'
const TEXT_FILTER_MODIFIER_GT = 'greater' const TEXT_FILTER_MODIFIER_GT = 'greater'
const TEXT_FILTER_MODIFIER_LT = 'less' const TEXT_FILTER_MODIFIER_LT = 'less'
const RELATIVE_DATE_QUERY_REGEXP_CREATED = /created:\[([^\]]+)\]/g const RELATIVE_DATE_QUERY_REGEXP_CREATED = /created:[\["]([^\]]+)[\]"]/g
const RELATIVE_DATE_QUERY_REGEXP_ADDED = /added:\[([^\]]+)\]/g const RELATIVE_DATE_QUERY_REGEXP_ADDED = /added:[\["]([^\]]+)[\]"]/g
const RELATIVE_DATE_QUERYSTRINGS = [ const RELATIVE_DATE_QUERYSTRINGS = [
{ {
relativeDate: RelativeDate.WITHIN_1_WEEK, relativeDate: RelativeDate.WITHIN_1_WEEK,
dateQuery: '-1 week to now', dateQuery: '-1 week to now',
isRange: true,
}, },
{ {
relativeDate: RelativeDate.WITHIN_1_MONTH, relativeDate: RelativeDate.WITHIN_1_MONTH,
dateQuery: '-1 month to now', dateQuery: '-1 month to now',
isRange: true,
}, },
{ {
relativeDate: RelativeDate.WITHIN_3_MONTHS, relativeDate: RelativeDate.WITHIN_3_MONTHS,
dateQuery: '-3 month to now', dateQuery: '-3 month to now',
isRange: true,
}, },
{ {
relativeDate: RelativeDate.WITHIN_1_YEAR, relativeDate: RelativeDate.WITHIN_1_YEAR,
dateQuery: '-1 year to now', dateQuery: '-1 year to now',
isRange: true,
},
{
relativeDate: RelativeDate.THIS_YEAR,
dateQuery: 'this year',
},
{
relativeDate: RelativeDate.THIS_MONTH,
dateQuery: 'this month',
},
{
relativeDate: RelativeDate.TODAY,
dateQuery: 'today',
},
{
relativeDate: RelativeDate.YESTERDAY,
dateQuery: 'yesterday',
}, },
] ]
@ -907,12 +927,11 @@ export class FilterEditorComponent
let existingRuleArgs = existingRule?.value.split(',') let existingRuleArgs = existingRule?.value.split(',')
if (this.dateCreatedRelativeDate !== null) { if (this.dateCreatedRelativeDate !== null) {
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
(qS) => qS.relativeDate == this.dateCreatedRelativeDate
)
queryArgs.push( queryArgs.push(
`created:[${ `created:${rd.isRange ? `[${rd.dateQuery}]` : `"${rd.dateQuery}"`}`
RELATIVE_DATE_QUERYSTRINGS.find(
(qS) => qS.relativeDate == this.dateCreatedRelativeDate
).dateQuery
}]`
) )
if (existingRule) { if (existingRule) {
queryArgs = existingRuleArgs queryArgs = existingRuleArgs
@ -921,12 +940,11 @@ export class FilterEditorComponent
} }
} }
if (this.dateAddedRelativeDate !== null) { if (this.dateAddedRelativeDate !== null) {
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
(qS) => qS.relativeDate == this.dateAddedRelativeDate
)
queryArgs.push( queryArgs.push(
`added:[${ `added:${rd.isRange ? `[${rd.dateQuery}]` : `"${rd.dateQuery}"`}`
RELATIVE_DATE_QUERYSTRINGS.find(
(qS) => qS.relativeDate == this.dateAddedRelativeDate
).dateQuery
}]`
) )
if (existingRule) { if (existingRule) {
queryArgs = existingRuleArgs queryArgs = existingRuleArgs

View File

@ -58,6 +58,8 @@ export interface WorkflowAction extends ObjectWithId {
assign_custom_fields?: number[] // [CustomField.id] assign_custom_fields?: number[] // [CustomField.id]
assign_custom_fields_values?: object
remove_tags?: number[] // Tag.id remove_tags?: number[] // Tag.id
remove_all_tags?: boolean remove_all_tags?: boolean

View File

@ -1,11 +1,10 @@
import { Options } from '@popperjs/core' import { Options } from '@popperjs/core'
import { popperOptionsReenablePreventOverflow } from './popper-options' import { pngxPopperOptions } from './popper-options'
describe('popperOptionsReenablePreventOverflow', () => { describe('popperOptionsReenablePreventOverflow', () => {
it('should return the config without the empty fun preventOverflow, add padding to other', () => { it('should return the config with add padding', () => {
const config: Partial<Options> = { const config: Partial<Options> = {
modifiers: [ modifiers: [
{ name: 'preventOverflow', fn: function () {} },
{ {
name: 'preventOverflow', name: 'preventOverflow',
fn: function (arg0) { fn: function (arg0) {
@ -15,7 +14,7 @@ describe('popperOptionsReenablePreventOverflow', () => {
], ],
} }
const result = popperOptionsReenablePreventOverflow(config) const result = pngxPopperOptions(config)
expect(result.modifiers.length).toBe(1) expect(result.modifiers.length).toBe(1)
expect(result.modifiers[0].name).toBe('preventOverflow') expect(result.modifiers[0].name).toBe('preventOverflow')

View File

@ -1,16 +1,11 @@
import { Options } from '@popperjs/core' import { Options } from '@popperjs/core'
export function popperOptionsReenablePreventOverflow( export function pngxPopperOptions(config: Partial<Options>): Partial<Options> {
config: Partial<Options> const preventOverflowModifier = config.modifiers.find(
): Partial<Options> {
config.modifiers = config.modifiers?.filter(
(m) => !(m.name === 'preventOverflow' && m.fn?.length === 0)
)
const ogPreventOverflowModifier = config.modifiers.find(
(m) => m.name === 'preventOverflow' (m) => m.name === 'preventOverflow'
) )
if (ogPreventOverflowModifier) { if (preventOverflowModifier) {
ogPreventOverflowModifier.options = { preventOverflowModifier.options = {
padding: 10, padding: 10,
} }
} }

View File

@ -318,6 +318,7 @@ def merge(
*, *,
metadata_document_id: int | None = None, metadata_document_id: int | None = None,
delete_originals: bool = False, delete_originals: bool = False,
archive_fallback: bool = False,
user: User | None = None, user: User | None = None,
) -> Literal["OK"]: ) -> Literal["OK"]:
logger.info( logger.info(
@ -333,7 +334,14 @@ def merge(
for doc_id in doc_ids: for doc_id in doc_ids:
doc = qs.get(id=doc_id) doc = qs.get(id=doc_id)
try: try:
with pikepdf.open(str(doc.source_path)) as pdf: doc_path = (
doc.archive_path
if archive_fallback
and doc.mime_type != "application/pdf"
and doc.has_archive_version
else doc.source_path
)
with pikepdf.open(str(doc_path)) as pdf:
version = max(version, pdf.pdf_version) version = max(version, pdf.pdf_version)
merged_pdf.pages.extend(pdf.pages) merged_pdf.pages.extend(pdf.pages)
affected_docs.append(doc.id) affected_docs.append(doc.id)
@ -349,7 +357,7 @@ def merge(
Path( Path(
tempfile.mkdtemp(dir=settings.SCRATCH_DIR), tempfile.mkdtemp(dir=settings.SCRATCH_DIR),
) )
/ f"{'_'.join([str(doc_id) for doc_id in doc_ids])[:100]}_merged.pdf" / f"{'_'.join([str(doc_id) for doc_id in affected_docs])[:100]}_merged.pdf"
) )
merged_pdf.remove_unreferenced_resources() merged_pdf.remove_unreferenced_resources()
merged_pdf.save(filepath, min_version=version) merged_pdf.save(filepath, min_version=version)

View File

@ -26,7 +26,6 @@ from documents.models import CustomField
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import FileInfo
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
@ -705,8 +704,6 @@ class ConsumerPlugin(
) -> Document: ) -> Document:
# If someone gave us the original filename, use it instead of doc. # If someone gave us the original filename, use it instead of doc.
file_info = FileInfo.from_filename(self.filename)
self.log.debug("Saving record to database") self.log.debug("Saving record to database")
if self.metadata.created is not None: if self.metadata.created is not None:
@ -714,9 +711,6 @@ class ConsumerPlugin(
self.log.debug( self.log.debug(
f"Creation date from post_documents parameter: {create_date}", f"Creation date from post_documents parameter: {create_date}",
) )
elif file_info.created is not None:
create_date = file_info.created
self.log.debug(f"Creation date from FileInfo: {create_date}")
elif date is not None: elif date is not None:
create_date = date create_date = date
self.log.debug(f"Creation date from parse_date: {create_date}") self.log.debug(f"Creation date from parse_date: {create_date}")
@ -729,7 +723,11 @@ class ConsumerPlugin(
storage_type = Document.STORAGE_TYPE_UNENCRYPTED storage_type = Document.STORAGE_TYPE_UNENCRYPTED
title = file_info.title if self.metadata.filename:
title = Path(self.metadata.filename).stem
else:
title = self.input_doc.original_file.stem
if self.metadata.title is not None: if self.metadata.title is not None:
try: try:
title = self._parse_title_placeholders(self.metadata.title) title = self._parse_title_placeholders(self.metadata.title)
@ -808,13 +806,19 @@ class ConsumerPlugin(
} }
set_permissions_for_object(permissions=permissions, object=document) set_permissions_for_object(permissions=permissions, object=document)
if self.metadata.custom_field_ids: if self.metadata.custom_fields:
for field_id in self.metadata.custom_field_ids: for field in CustomField.objects.filter(
field = CustomField.objects.get(pk=field_id) id__in=self.metadata.custom_fields.keys(),
CustomFieldInstance.objects.create( ).distinct():
field=field, value_field_name = CustomFieldInstance.get_value_field_name(
document=document, data_type=field.data_type,
) # adds to document )
args = {
"field": field,
"document": document,
value_field_name: self.metadata.custom_fields.get(field.id, None),
}
CustomFieldInstance.objects.create(**args) # adds to document
def _write(self, storage_type, source, target): def _write(self, storage_type, source, target):
with ( with (

View File

@ -29,7 +29,7 @@ class DocumentMetadataOverrides:
view_groups: list[int] | None = None view_groups: list[int] | None = None
change_users: list[int] | None = None change_users: list[int] | None = None
change_groups: list[int] | None = None change_groups: list[int] | None = None
custom_field_ids: list[int] | None = None custom_fields: dict | None = None
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides": def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
""" """
@ -81,11 +81,10 @@ class DocumentMetadataOverrides:
self.change_groups.extend(other.change_groups) self.change_groups.extend(other.change_groups)
self.change_groups = list(set(self.change_groups)) self.change_groups = list(set(self.change_groups))
if self.custom_field_ids is None: if self.custom_fields is None:
self.custom_field_ids = other.custom_field_ids self.custom_fields = other.custom_fields
elif other.custom_field_ids is not None: elif other.custom_fields is not None:
self.custom_field_ids.extend(other.custom_field_ids) self.custom_fields.update(other.custom_fields)
self.custom_field_ids = list(set(self.custom_field_ids))
return self return self
@ -114,9 +113,10 @@ class DocumentMetadataOverrides:
only_with_perms_in=["change_document"], only_with_perms_in=["change_document"],
).values_list("id", flat=True), ).values_list("id", flat=True),
) )
overrides.custom_field_ids = list( overrides.custom_fields = {
doc.custom_fields.values_list("field", flat=True), custom_field.id: custom_field.value
) for custom_field in doc.custom_fields.all()
}
groups_with_perms = get_groups_with_perms( groups_with_perms = get_groups_with_perms(
doc, doc,

View File

@ -36,7 +36,6 @@ from documents.models import CustomField
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import Log
from documents.models import PaperlessTask from documents.models import PaperlessTask
from documents.models import ShareLink from documents.models import ShareLink
from documents.models import StoragePath from documents.models import StoragePath
@ -761,12 +760,6 @@ class DocumentFilterSet(FilterSet):
} }
class LogFilterSet(FilterSet):
class Meta:
model = Log
fields = {"level": INT_KWARGS, "created": DATE_KWARGS, "group": ID_KWARGS}
class ShareLinkFilterSet(FilterSet): class ShareLinkFilterSet(FilterSet):
class Meta: class Meta:
model = ShareLink model = ShareLink

View File

@ -5,6 +5,7 @@ import re
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
from pathlib import Path
import gnupg import gnupg
from django.conf import settings from django.conf import settings
@ -34,16 +35,16 @@ class GnuPG:
def move_documents_and_create_thumbnails(apps, schema_editor): def move_documents_and_create_thumbnails(apps, schema_editor):
os.makedirs( (Path(settings.MEDIA_ROOT) / "documents" / "originals").mkdir(
os.path.join(settings.MEDIA_ROOT, "documents", "originals"), parents=True,
exist_ok=True, exist_ok=True,
) )
os.makedirs( (Path(settings.MEDIA_ROOT) / "documents" / "thumbnails").mkdir(
os.path.join(settings.MEDIA_ROOT, "documents", "thumbnails"), parents=True,
exist_ok=True, exist_ok=True,
) )
documents = os.listdir(os.path.join(settings.MEDIA_ROOT, "documents")) documents: list[str] = os.listdir(Path(settings.MEDIA_ROOT) / "documents")
if set(documents) == {"originals", "thumbnails"}: if set(documents) == {"originals", "thumbnails"}:
return return
@ -60,10 +61,7 @@ def move_documents_and_create_thumbnails(apps, schema_editor):
), ),
) )
try: Path(settings.SCRATCH_DIR).mkdir(parents=True, exists_ok=True)
os.makedirs(settings.SCRATCH_DIR)
except FileExistsError:
pass
for f in sorted(documents): for f in sorted(documents):
if not f.endswith("gpg"): if not f.endswith("gpg"):
@ -77,15 +75,14 @@ def move_documents_and_create_thumbnails(apps, schema_editor):
), ),
) )
thumb_temp = tempfile.mkdtemp(prefix="paperless", dir=settings.SCRATCH_DIR) thumb_temp: str = tempfile.mkdtemp(prefix="paperless", dir=settings.SCRATCH_DIR)
orig_temp = tempfile.mkdtemp(prefix="paperless", dir=settings.SCRATCH_DIR) orig_temp: str = tempfile.mkdtemp(prefix="paperless", dir=settings.SCRATCH_DIR)
orig_source = os.path.join(settings.MEDIA_ROOT, "documents", f) orig_source: Path = Path(settings.MEDIA_ROOT) / "documents" / f
orig_target = os.path.join(orig_temp, f.replace(".gpg", "")) orig_target: Path = Path(orig_temp) / f.replace(".gpg", "")
with open(orig_source, "rb") as encrypted: with orig_source.open("rb") as encrypted, orig_target.open("wb") as unencrypted:
with open(orig_target, "wb") as unencrypted: unencrypted.write(GnuPG.decrypted(encrypted))
unencrypted.write(GnuPG.decrypted(encrypted))
subprocess.Popen( subprocess.Popen(
( (
@ -95,27 +92,29 @@ def move_documents_and_create_thumbnails(apps, schema_editor):
"-alpha", "-alpha",
"remove", "remove",
orig_target, orig_target,
os.path.join(thumb_temp, "convert-%04d.png"), Path(thumb_temp) / "convert-%04d.png",
), ),
).wait() ).wait()
thumb_source = os.path.join(thumb_temp, "convert-0000.png") thumb_source: Path = Path(thumb_temp) / "convert-0000.png"
thumb_target = os.path.join( thumb_target: Path = (
settings.MEDIA_ROOT, Path(settings.MEDIA_ROOT)
"documents", / "documents"
"thumbnails", / "thumbnails"
re.sub(r"(\d+)\.\w+(\.gpg)", "\\1.png\\2", f), / re.sub(r"(\d+)\.\w+(\.gpg)", "\\1.png\\2", f)
) )
with open(thumb_source, "rb") as unencrypted: with (
with open(thumb_target, "wb") as encrypted: thumb_source.open("rb") as unencrypted,
encrypted.write(GnuPG.encrypted(unencrypted)) thumb_target.open("wb") as encrypted,
):
encrypted.write(GnuPG.encrypted(unencrypted))
shutil.rmtree(thumb_temp) shutil.rmtree(thumb_temp)
shutil.rmtree(orig_temp) shutil.rmtree(orig_temp)
shutil.move( shutil.move(
os.path.join(settings.MEDIA_ROOT, "documents", f), Path(settings.MEDIA_ROOT) / "documents" / f,
os.path.join(settings.MEDIA_ROOT, "documents", "originals", f), Path(settings.MEDIA_ROOT) / "documents" / "originals" / f,
) )

View File

@ -1,7 +1,7 @@
# Generated by Django 1.9.4 on 2016-03-28 19:09 # Generated by Django 1.9.4 on 2016-03-28 19:09
import hashlib import hashlib
import os from pathlib import Path
import django.utils.timezone import django.utils.timezone
import gnupg import gnupg
@ -58,16 +58,16 @@ class Document:
@property @property
def source_path(self): def source_path(self):
return os.path.join( return (
settings.MEDIA_ROOT, Path(settings.MEDIA_ROOT)
"documents", / "documents"
"originals", / "originals"
f"{self.pk:07}.{self.file_type}.gpg", / f"{self.pk:07}.{self.file_type}.gpg"
) ).as_posix()
@property @property
def source_file(self): def source_file(self):
return open(self.source_path, "rb") return Path(self.source_path).open("rb")
@property @property
def file_name(self): def file_name(self):

View File

@ -1,5 +1,5 @@
# Generated by Django 3.1.3 on 2020-11-20 11:21 # Generated by Django 3.1.3 on 2020-11-20 11:21
import os from pathlib import Path
import magic import magic
from django.conf import settings from django.conf import settings
@ -12,15 +12,15 @@ STORAGE_TYPE_UNENCRYPTED = "unencrypted"
STORAGE_TYPE_GPG = "gpg" STORAGE_TYPE_GPG = "gpg"
def source_path(self): def source_path(self) -> Path:
if self.filename: if self.filename:
fname = str(self.filename) fname: str = str(self.filename)
else: else:
fname = f"{self.pk:07}.{self.file_type}" fname = f"{self.pk:07}.{self.file_type}"
if self.storage_type == STORAGE_TYPE_GPG: if self.storage_type == STORAGE_TYPE_GPG:
fname += ".gpg" fname += ".gpg"
return os.path.join(settings.ORIGINALS_DIR, fname) return Path(settings.ORIGINALS_DIR) / fname
def add_mime_types(apps, schema_editor): def add_mime_types(apps, schema_editor):
@ -28,24 +28,22 @@ def add_mime_types(apps, schema_editor):
documents = Document.objects.all() documents = Document.objects.all()
for d in documents: for d in documents:
f = open(source_path(d), "rb") with Path(source_path(d)).open("rb") as f:
if d.storage_type == STORAGE_TYPE_GPG: if d.storage_type == STORAGE_TYPE_GPG:
data = GnuPG.decrypted(f) data = GnuPG.decrypted(f)
else: else:
data = f.read(1024) data = f.read(1024)
d.mime_type = magic.from_buffer(data, mime=True) d.mime_type = magic.from_buffer(data, mime=True)
d.save() d.save()
f.close()
def add_file_extensions(apps, schema_editor): def add_file_extensions(apps, schema_editor):
Document = apps.get_model("documents", "Document") Document = apps.get_model("documents", "Document")
documents = Document.objects.all() documents = Document.objects.all()
for d in documents: for d in documents:
d.file_type = os.path.splitext(d.filename)[1].strip(".") d.file_type = Path(d.filename).suffix.lstrip(".")
d.save() d.save()

View File

@ -0,0 +1,15 @@
# Generated by Django 5.1.6 on 2025-02-28 15:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("documents", "1063_paperlesstask_type_alter_paperlesstask_task_name_and_more"),
]
operations = [
migrations.DeleteModel(
name="Log",
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 5.1.6 on 2025-03-01 18:10
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "1064_delete_log"),
]
operations = [
migrations.AddField(
model_name="workflowaction",
name="assign_custom_fields_values",
field=models.JSONField(
blank=True,
help_text="Optional values to assign to the custom fields.",
null=True,
verbose_name="custom field values",
default=dict,
),
),
]

View File

@ -1,12 +1,7 @@
import datetime import datetime
import logging
import os
import re
from collections import OrderedDict
from pathlib import Path from pathlib import Path
from typing import Final from typing import Final
import dateutil.parser
import pathvalidate import pathvalidate
from celery import states from celery import states
from django.conf import settings from django.conf import settings
@ -320,7 +315,7 @@ class Document(SoftDeleteModel, ModelWithOwner):
@property @property
def source_file(self): def source_file(self):
return open(self.source_path, "rb") return Path(self.source_path).open("rb")
@property @property
def has_archive_version(self) -> bool: def has_archive_version(self) -> bool:
@ -335,7 +330,7 @@ class Document(SoftDeleteModel, ModelWithOwner):
@property @property
def archive_file(self): def archive_file(self):
return open(self.archive_path, "rb") return Path(self.archive_path).open("rb")
def get_public_filename(self, *, archive=False, counter=0, suffix=None) -> str: def get_public_filename(self, *, archive=False, counter=0, suffix=None) -> str:
""" """
@ -372,43 +367,13 @@ class Document(SoftDeleteModel, ModelWithOwner):
@property @property
def thumbnail_file(self): def thumbnail_file(self):
return open(self.thumbnail_path, "rb") return Path(self.thumbnail_path).open("rb")
@property @property
def created_date(self): def created_date(self):
return timezone.localdate(self.created) return timezone.localdate(self.created)
class Log(models.Model):
LEVELS = (
(logging.DEBUG, _("debug")),
(logging.INFO, _("information")),
(logging.WARNING, _("warning")),
(logging.ERROR, _("error")),
(logging.CRITICAL, _("critical")),
)
group = models.UUIDField(_("group"), blank=True, null=True)
message = models.TextField(_("message"))
level = models.PositiveIntegerField(
_("level"),
choices=LEVELS,
default=logging.INFO,
)
created = models.DateTimeField(_("created"), auto_now_add=True)
class Meta:
ordering = ("-created",)
verbose_name = _("log")
verbose_name_plural = _("logs")
def __str__(self):
return self.message
class SavedView(ModelWithOwner): class SavedView(ModelWithOwner):
class DisplayMode(models.TextChoices): class DisplayMode(models.TextChoices):
TABLE = ("table", _("Table")) TABLE = ("table", _("Table"))
@ -548,91 +513,6 @@ class SavedViewFilterRule(models.Model):
return f"SavedViewFilterRule: {self.rule_type} : {self.value}" return f"SavedViewFilterRule: {self.rule_type} : {self.value}"
# TODO: why is this in the models file?
# TODO: how about, what is this and where is it documented?
# It appears to parsing JSON from an environment variable to get a title and date from
# the filename, if possible, as a higher priority than either document filename or
# content parsing
class FileInfo:
REGEXES = OrderedDict(
[
(
"created-title",
re.compile(
r"^(?P<created>\d{8}(\d{6})?Z) - (?P<title>.*)$",
flags=re.IGNORECASE,
),
),
("title", re.compile(r"(?P<title>.*)$", flags=re.IGNORECASE)),
],
)
def __init__(
self,
created=None,
correspondent=None,
title=None,
tags=(),
extension=None,
):
self.created = created
self.title = title
self.extension = extension
self.correspondent = correspondent
self.tags = tags
@classmethod
def _get_created(cls, created):
try:
return dateutil.parser.parse(f"{created[:-1]:0<14}Z")
except ValueError:
return None
@classmethod
def _get_title(cls, title):
return title
@classmethod
def _mangle_property(cls, properties, name):
if name in properties:
properties[name] = getattr(cls, f"_get_{name}")(properties[name])
@classmethod
def from_filename(cls, filename) -> "FileInfo":
# Mutate filename in-place before parsing its components
# by applying at most one of the configured transformations.
for pattern, repl in settings.FILENAME_PARSE_TRANSFORMS:
(filename, count) = pattern.subn(repl, filename)
if count:
break
# do this after the transforms so that the transforms can do whatever
# with the file extension.
filename_no_ext = os.path.splitext(filename)[0]
if filename_no_ext == filename and filename.startswith("."):
# This is a very special case where there is no text before the
# file type.
# TODO: this should be handled better. The ext is not removed
# because usually, files like '.pdf' are just hidden files
# with the name pdf, but in our case, its more likely that
# there's just no name to begin with.
filename = ""
# This isn't too bad either, since we'll just not match anything
# and return an empty title. TODO: actually, this is kinda bad.
else:
filename = filename_no_ext
# Parse filename components.
for regex in cls.REGEXES.values():
m = regex.match(filename)
if m:
properties = m.groupdict()
cls._mangle_property(properties, "created")
cls._mangle_property(properties, "title")
return cls(**properties)
# Extending User Model Using a One-To-One Link # Extending User Model Using a One-To-One Link
class UiSettings(models.Model): class UiSettings(models.Model):
user = models.OneToOneField( user = models.OneToOneField(
@ -1391,6 +1271,16 @@ class WorkflowAction(models.Model):
verbose_name=_("assign these custom fields"), verbose_name=_("assign these custom fields"),
) )
assign_custom_fields_values = models.JSONField(
_("custom field values"),
null=True,
blank=True,
help_text=_(
"Optional values to assign to the custom fields.",
),
default=dict,
)
remove_tags = models.ManyToManyField( remove_tags = models.ManyToManyField(
Tag, Tag,
blank=True, blank=True,

View File

@ -1446,6 +1446,11 @@ class BulkEditSerializer(
raise serializers.ValidationError("delete_originals must be a boolean") raise serializers.ValidationError("delete_originals must be a boolean")
else: else:
parameters["delete_originals"] = False parameters["delete_originals"] = False
if "archive_fallback" in parameters:
if not isinstance(parameters["archive_fallback"], bool):
raise serializers.ValidationError("archive_fallback must be a boolean")
else:
parameters["archive_fallback"] = False
def validate(self, attrs): def validate(self, attrs):
method = attrs["method"] method = attrs["method"]
@ -2018,6 +2023,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
"assign_change_users", "assign_change_users",
"assign_change_groups", "assign_change_groups",
"assign_custom_fields", "assign_custom_fields",
"assign_custom_fields_values",
"remove_all_tags", "remove_all_tags",
"remove_tags", "remove_tags",
"remove_all_correspondents", "remove_all_correspondents",

View File

@ -632,7 +632,7 @@ def send_webhook(
else: else:
httpx.post( httpx.post(
url, url,
data=data, content=data,
files=files, files=files,
headers=headers, headers=headers,
).raise_for_status() ).raise_for_status()
@ -770,23 +770,40 @@ def run_workflows(
if action.assign_custom_fields.exists(): if action.assign_custom_fields.exists():
if not use_overrides: if not use_overrides:
for field in action.assign_custom_fields.all(): for field in action.assign_custom_fields.all():
if not CustomFieldInstance.objects.filter( value_field_name = CustomFieldInstance.get_value_field_name(
data_type=field.data_type,
)
args = {
value_field_name: action.assign_custom_fields_values.get(
str(field.pk),
None,
),
}
# for some reason update_or_create doesn't work here
instance = CustomFieldInstance.objects.filter(
field=field, field=field,
document=document, document=document,
).exists(): ).first()
# can be triggered on existing docs, so only add the field if it doesn't already exist if instance:
setattr(instance, value_field_name, args[value_field_name])
instance.save()
else:
CustomFieldInstance.objects.create( CustomFieldInstance.objects.create(
**args,
field=field, field=field,
document=document, document=document,
) )
else: else:
overrides.custom_field_ids = list( if overrides.custom_fields is None:
set( overrides.custom_fields = {}
(overrides.custom_field_ids or []) overrides.custom_fields.update(
+ list( {
action.assign_custom_fields.values_list("pk", flat=True), field.pk: action.assign_custom_fields_values.get(
), str(field.pk),
), None,
)
for field in action.assign_custom_fields.all()
},
) )
def removal_action(): def removal_action():
@ -944,18 +961,18 @@ def run_workflows(
if not use_overrides: if not use_overrides:
CustomFieldInstance.objects.filter(document=document).delete() CustomFieldInstance.objects.filter(document=document).delete()
else: else:
overrides.custom_field_ids = None overrides.custom_fields = None
elif action.remove_custom_fields.exists(): elif action.remove_custom_fields.exists():
if not use_overrides: if not use_overrides:
CustomFieldInstance.objects.filter( CustomFieldInstance.objects.filter(
field__in=action.remove_custom_fields.all(), field__in=action.remove_custom_fields.all(),
document=document, document=document,
).delete() ).delete()
elif overrides.custom_field_ids: elif overrides.custom_fields:
for field in action.remove_custom_fields.filter( for field in action.remove_custom_fields.filter(
pk__in=overrides.custom_field_ids, pk__in=overrides.custom_fields.keys(),
): ):
overrides.custom_field_ids.remove(field.pk) overrides.custom_fields.pop(field.pk, None)
def email_action(): def email_action():
if not settings.EMAIL_ENABLED: if not settings.EMAIL_ENABLED:

View File

@ -272,7 +272,7 @@ def update_document_content_maybe_archive_file(document_id):
with transaction.atomic(): with transaction.atomic():
oldDocument = Document.objects.get(pk=document.pk) oldDocument = Document.objects.get(pk=document.pk)
if parser.get_archive_path(): if parser.get_archive_path():
with open(parser.get_archive_path(), "rb") as f: with Path(parser.get_archive_path()).open("rb") as f:
checksum = hashlib.md5(f.read()).hexdigest() checksum = hashlib.md5(f.read()).hexdigest()
# I'm going to save first so that in case the file move # I'm going to save first so that in case the file move
# fails, the database is rolled back. # fails, the database is rolled back.

View File

@ -1,5 +1,5 @@
import json import json
import os from pathlib import Path
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework import status from rest_framework import status
@ -136,10 +136,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
THEN: THEN:
- old app_logo file is deleted - old app_logo file is deleted
""" """
with open( with (Path(__file__).parent / "samples" / "simple.jpg").open("rb") as f:
os.path.join(os.path.dirname(__file__), "samples", "simple.jpg"),
"rb",
) as f:
self.client.patch( self.client.patch(
f"{self.ENDPOINT}1/", f"{self.ENDPOINT}1/",
{ {
@ -148,15 +145,12 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
) )
config = ApplicationConfiguration.objects.first() config = ApplicationConfiguration.objects.first()
old_logo = config.app_logo old_logo = config.app_logo
self.assertTrue(os.path.exists(old_logo.path)) self.assertTrue(Path(old_logo.path).exists())
with open( with (Path(__file__).parent / "samples" / "simple.png").open("rb") as f:
os.path.join(os.path.dirname(__file__), "samples", "simple.png"),
"rb",
) as f:
self.client.patch( self.client.patch(
f"{self.ENDPOINT}1/", f"{self.ENDPOINT}1/",
{ {
"app_logo": f, "app_logo": f,
}, },
) )
self.assertFalse(os.path.exists(old_logo.path)) self.assertFalse(Path(old_logo.path).exists())

View File

@ -28,6 +28,7 @@ from documents.caching import CACHE_50_MINUTES
from documents.caching import CLASSIFIER_HASH_KEY from documents.caching import CLASSIFIER_HASH_KEY
from documents.caching import CLASSIFIER_MODIFIED_KEY from documents.caching import CLASSIFIER_MODIFIED_KEY
from documents.caching import CLASSIFIER_VERSION_KEY from documents.caching import CLASSIFIER_VERSION_KEY
from documents.data_models import DocumentSource
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
@ -39,7 +40,10 @@ from documents.models import SavedView
from documents.models import ShareLink from documents.models import ShareLink
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.signals.handlers import run_workflows
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import DocumentConsumeDelayMixin from documents.tests.utils import DocumentConsumeDelayMixin
@ -1362,7 +1366,69 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.assertEqual(input_doc.original_file.name, "simple.pdf") self.assertEqual(input_doc.original_file.name, "simple.pdf")
self.assertEqual(overrides.filename, "simple.pdf") self.assertEqual(overrides.filename, "simple.pdf")
self.assertEqual(overrides.custom_field_ids, [custom_field.id]) self.assertEqual(overrides.custom_fields, {custom_field.id: None})
def test_upload_with_custom_fields_and_workflow(self):
"""
GIVEN: A document with a source file
WHEN: Upload the document with custom fields and a workflow
THEN: Metadata is set correctly, mimicking what happens in the real consumer plugin
"""
self.consume_file_mock.return_value = celery.result.AsyncResult(
id=str(uuid.uuid4()),
)
cf = CustomField.objects.create(
name="stringfield",
data_type=CustomField.FieldDataType.STRING,
)
cf2 = CustomField.objects.create(
name="intfield",
data_type=CustomField.FieldDataType.INT,
)
trigger1 = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
)
action1 = WorkflowAction.objects.create(
assign_title="Doc title",
)
action1.assign_custom_fields.add(cf2)
action1.assign_custom_fields_values = {cf2.id: 123}
action1.save()
w1 = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w1.triggers.add(trigger1)
w1.actions.add(action1)
w1.save()
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post(
"/api/documents/post_document/",
{
"document": f,
"custom_fields": [cf.id],
},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.consume_file_mock.assert_called_once()
input_doc, overrides = self.get_last_consume_delay_call_args()
new_overrides, msg = run_workflows(
trigger_type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
document=input_doc,
logging_group=None,
overrides=overrides,
)
overrides.update(new_overrides)
self.assertEqual(overrides.custom_fields, {cf.id: None, cf2.id: 123})
def test_upload_with_webui_source(self): def test_upload_with_webui_source(self):
""" """

View File

@ -514,12 +514,23 @@ class TestPDFActions(DirectoriesMixin, TestCase):
Path(__file__).parent / "samples" / "simple.jpg", Path(__file__).parent / "samples" / "simple.jpg",
img_doc, img_doc,
) )
img_doc_archive = self.dirs.archive_dir / "sample_image.pdf"
shutil.copy(
Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf",
img_doc_archive,
)
self.img_doc = Document.objects.create( self.img_doc = Document.objects.create(
checksum="D", checksum="D",
title="D", title="D",
filename=img_doc, filename=img_doc,
mime_type="image/jpeg", mime_type="image/jpeg",
) )
self.img_doc.archive_filename = img_doc_archive
self.img_doc.save()
@mock.patch("documents.tasks.consume_file.s") @mock.patch("documents.tasks.consume_file.s")
def test_merge(self, mock_consume_file): def test_merge(self, mock_consume_file):
@ -605,6 +616,32 @@ class TestPDFActions(DirectoriesMixin, TestCase):
doc_ids, doc_ids,
) )
@mock.patch("documents.tasks.consume_file.s")
def test_merge_with_archive_fallback(self, mock_consume_file):
"""
GIVEN:
- Existing documents
WHEN:
- Merge action is called with 2 documents, one of which is an image and archive_fallback is set to True
THEN:
- Image document should be included
"""
doc_ids = [self.doc2.id, self.img_doc.id]
result = bulk_edit.merge(doc_ids, archive_fallback=True)
self.assertEqual(result, "OK")
expected_filename = (
f"{'_'.join([str(doc_id) for doc_id in doc_ids])[:100]}_merged.pdf"
)
mock_consume_file.assert_called()
consume_file_args, _ = mock_consume_file.call_args
self.assertEqual(
Path(consume_file_args[0].original_file).name,
expected_filename,
)
@mock.patch("documents.tasks.consume_file.delay") @mock.patch("documents.tasks.consume_file.delay")
@mock.patch("pikepdf.open") @mock.patch("pikepdf.open")
def test_merge_with_errors(self, mock_open_pdf, mock_consume_file): def test_merge_with_errors(self, mock_open_pdf, mock_consume_file):

View File

@ -1,4 +1,3 @@
import os
import re import re
import shutil import shutil
from pathlib import Path from pathlib import Path
@ -617,7 +616,7 @@ class TestClassifier(DirectoriesMixin, TestCase):
self.assertListEqual(self.classifier.predict_tags(doc2.content), []) self.assertListEqual(self.classifier.predict_tags(doc2.content), [])
def test_load_classifier_not_exists(self): def test_load_classifier_not_exists(self):
self.assertFalse(os.path.exists(settings.MODEL_FILE)) self.assertFalse(Path(settings.MODEL_FILE).exists())
self.assertIsNone(load_classifier()) self.assertIsNone(load_classifier())
@mock.patch("documents.classifier.DocumentClassifier.load") @mock.patch("documents.classifier.DocumentClassifier.load")
@ -632,7 +631,7 @@ class TestClassifier(DirectoriesMixin, TestCase):
}, },
) )
@override_settings( @override_settings(
MODEL_FILE=os.path.join(os.path.dirname(__file__), "data", "model.pickle"), MODEL_FILE=(Path(__file__).parent / "data" / "model.pickle").as_posix(),
) )
@pytest.mark.skip( @pytest.mark.skip(
reason="Disabled caching due to high memory usage - need to investigate.", reason="Disabled caching due to high memory usage - need to investigate.",
@ -648,24 +647,24 @@ class TestClassifier(DirectoriesMixin, TestCase):
@mock.patch("documents.classifier.DocumentClassifier.load") @mock.patch("documents.classifier.DocumentClassifier.load")
def test_load_classifier_incompatible_version(self, load): def test_load_classifier_incompatible_version(self, load):
Path(settings.MODEL_FILE).touch() Path(settings.MODEL_FILE).touch()
self.assertTrue(os.path.exists(settings.MODEL_FILE)) self.assertTrue(Path(settings.MODEL_FILE).exists())
load.side_effect = IncompatibleClassifierVersionError("Dummy Error") load.side_effect = IncompatibleClassifierVersionError("Dummy Error")
self.assertIsNone(load_classifier()) self.assertIsNone(load_classifier())
self.assertFalse(os.path.exists(settings.MODEL_FILE)) self.assertFalse(Path(settings.MODEL_FILE).exists())
@mock.patch("documents.classifier.DocumentClassifier.load") @mock.patch("documents.classifier.DocumentClassifier.load")
def test_load_classifier_os_error(self, load): def test_load_classifier_os_error(self, load):
Path(settings.MODEL_FILE).touch() Path(settings.MODEL_FILE).touch()
self.assertTrue(os.path.exists(settings.MODEL_FILE)) self.assertTrue(Path(settings.MODEL_FILE).exists())
load.side_effect = OSError() load.side_effect = OSError()
self.assertIsNone(load_classifier()) self.assertIsNone(load_classifier())
self.assertTrue(os.path.exists(settings.MODEL_FILE)) self.assertTrue(Path(settings.MODEL_FILE).exists())
def test_load_old_classifier_version(self): def test_load_old_classifier_version(self):
shutil.copy( shutil.copy(
os.path.join(os.path.dirname(__file__), "data", "v1.17.4.model.pickle"), Path(__file__).parent / "data" / "v1.17.4.model.pickle",
self.dirs.scratch_dir, self.dirs.scratch_dir,
) )
with override_settings( with override_settings(

View File

@ -1,12 +1,10 @@
import datetime import datetime
import os import os
import re
import shutil import shutil
import stat import stat
import tempfile import tempfile
import zoneinfo import zoneinfo
from pathlib import Path from pathlib import Path
from unittest import TestCase as UnittestTestCase
from unittest import mock from unittest import mock
from unittest.mock import MagicMock from unittest.mock import MagicMock
@ -26,7 +24,6 @@ from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import FileInfo
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.parsers import DocumentParser from documents.parsers import DocumentParser
@ -40,143 +37,6 @@ from paperless_mail.models import MailRule
from paperless_mail.parsers import MailDocumentParser from paperless_mail.parsers import MailDocumentParser
class TestAttributes(UnittestTestCase):
TAGS = ("tag1", "tag2", "tag3")
def _test_guess_attributes_from_name(self, filename, sender, title, tags):
file_info = FileInfo.from_filename(filename)
if sender:
self.assertEqual(file_info.correspondent.name, sender, filename)
else:
self.assertIsNone(file_info.correspondent, filename)
self.assertEqual(file_info.title, title, filename)
self.assertEqual(tuple(t.name for t in file_info.tags), tags, filename)
def test_guess_attributes_from_name_when_title_starts_with_dash(self):
self._test_guess_attributes_from_name(
"- weird but should not break.pdf",
None,
"- weird but should not break",
(),
)
def test_guess_attributes_from_name_when_title_ends_with_dash(self):
self._test_guess_attributes_from_name(
"weird but should not break -.pdf",
None,
"weird but should not break -",
(),
)
class TestFieldPermutations(TestCase):
valid_dates = (
"20150102030405Z",
"20150102Z",
)
valid_correspondents = ["timmy", "Dr. McWheelie", "Dash Gor-don", "o Θεpμaoτής", ""]
valid_titles = ["title", "Title w Spaces", "Title a-dash", "Tίτλoς", ""]
valid_tags = ["tag", "tig,tag", "tag1,tag2,tag-3"]
def _test_guessed_attributes(
self,
filename,
created=None,
correspondent=None,
title=None,
tags=None,
):
info = FileInfo.from_filename(filename)
# Created
if created is None:
self.assertIsNone(info.created, filename)
else:
self.assertEqual(info.created.year, int(created[:4]), filename)
self.assertEqual(info.created.month, int(created[4:6]), filename)
self.assertEqual(info.created.day, int(created[6:8]), filename)
# Correspondent
if correspondent:
self.assertEqual(info.correspondent.name, correspondent, filename)
else:
self.assertEqual(info.correspondent, None, filename)
# Title
self.assertEqual(info.title, title, filename)
# Tags
if tags is None:
self.assertEqual(info.tags, (), filename)
else:
self.assertEqual([t.name for t in info.tags], tags.split(","), filename)
def test_just_title(self):
template = "{title}.pdf"
for title in self.valid_titles:
spec = dict(title=title)
filename = template.format(**spec)
self._test_guessed_attributes(filename, **spec)
def test_created_and_title(self):
template = "{created} - {title}.pdf"
for created in self.valid_dates:
for title in self.valid_titles:
spec = {"created": created, "title": title}
self._test_guessed_attributes(template.format(**spec), **spec)
def test_invalid_date_format(self):
info = FileInfo.from_filename("06112017Z - title.pdf")
self.assertEqual(info.title, "title")
self.assertIsNone(info.created)
def test_filename_parse_transforms(self):
filename = "tag1,tag2_20190908_180610_0001.pdf"
all_patt = re.compile("^.*$")
none_patt = re.compile("$a")
re.compile("^([a-z0-9,]+)_(\\d{8})_(\\d{6})_([0-9]+)\\.")
# No transformations configured (= default)
info = FileInfo.from_filename(filename)
self.assertEqual(info.title, "tag1,tag2_20190908_180610_0001")
self.assertEqual(info.tags, ())
self.assertIsNone(info.created)
# Pattern doesn't match (filename unaltered)
with self.settings(FILENAME_PARSE_TRANSFORMS=[(none_patt, "none.gif")]):
info = FileInfo.from_filename(filename)
self.assertEqual(info.title, "tag1,tag2_20190908_180610_0001")
# Simple transformation (match all)
with self.settings(FILENAME_PARSE_TRANSFORMS=[(all_patt, "all.gif")]):
info = FileInfo.from_filename(filename)
self.assertEqual(info.title, "all")
# Multiple transformations configured (first pattern matches)
with self.settings(
FILENAME_PARSE_TRANSFORMS=[
(all_patt, "all.gif"),
(all_patt, "anotherall.gif"),
],
):
info = FileInfo.from_filename(filename)
self.assertEqual(info.title, "all")
# Multiple transformations configured (second pattern matches)
with self.settings(
FILENAME_PARSE_TRANSFORMS=[
(none_patt, "none.gif"),
(all_patt, "anotherall.gif"),
],
):
info = FileInfo.from_filename(filename)
self.assertEqual(info.title, "anotherall")
class _BaseTestParser(DocumentParser): class _BaseTestParser(DocumentParser):
def get_settings(self): def get_settings(self):
""" """
@ -548,7 +408,9 @@ class TestConsumer(
with self.get_consumer( with self.get_consumer(
self.get_test_file(), self.get_test_file(),
DocumentMetadataOverrides(custom_field_ids=[cf1.id, cf3.id]), DocumentMetadataOverrides(
custom_fields={cf1.id: "value1", cf3.id: "http://example.com"},
),
) as consumer: ) as consumer:
consumer.run() consumer.run()
@ -560,6 +422,11 @@ class TestConsumer(
self.assertIn(cf1, fields_used) self.assertIn(cf1, fields_used)
self.assertNotIn(cf2, fields_used) self.assertNotIn(cf2, fields_used)
self.assertIn(cf3, fields_used) self.assertIn(cf3, fields_used)
self.assertEqual(document.custom_fields.get(field=cf1).value, "value1")
self.assertEqual(
document.custom_fields.get(field=cf3).value,
"http://example.com",
)
self._assert_first_last_send_progress() self._assert_first_last_send_progress()
def testOverrideAsn(self): def testOverrideAsn(self):

View File

@ -1,5 +1,5 @@
import os
import shutil import shutil
from pathlib import Path
from unittest import mock from unittest import mock
from django.core.management import call_command from django.core.management import call_command
@ -22,7 +22,7 @@ class TestMakeThumbnails(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
filename="test.pdf", filename="test.pdf",
) )
shutil.copy( shutil.copy(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"), Path(__file__).parent / "samples" / "simple.pdf",
self.d1.source_path, self.d1.source_path,
) )
@ -34,7 +34,7 @@ class TestMakeThumbnails(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
filename="test2.pdf", filename="test2.pdf",
) )
shutil.copy( shutil.copy(
os.path.join(os.path.dirname(__file__), "samples", "simple.pdf"), Path(__file__).parent / "samples" / "simple.pdf",
self.d2.source_path, self.d2.source_path,
) )
@ -46,7 +46,7 @@ class TestMakeThumbnails(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
filename="test3.pdf", filename="test3.pdf",
) )
shutil.copy( shutil.copy(
os.path.join(os.path.dirname(__file__), "samples", "password-is-test.pdf"), Path(__file__).parent / "samples" / "password-is-test.pdf",
self.d3.source_path, self.d3.source_path,
) )

View File

@ -1,4 +1,3 @@
import os
import shutil import shutil
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
@ -88,18 +87,18 @@ class TestClassifier(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
tasks.train_classifier() tasks.train_classifier()
self.assertIsFile(settings.MODEL_FILE) self.assertIsFile(settings.MODEL_FILE)
mtime = os.stat(settings.MODEL_FILE).st_mtime mtime = Path(settings.MODEL_FILE).stat().st_mtime
tasks.train_classifier() tasks.train_classifier()
self.assertIsFile(settings.MODEL_FILE) self.assertIsFile(settings.MODEL_FILE)
mtime2 = os.stat(settings.MODEL_FILE).st_mtime mtime2 = Path(settings.MODEL_FILE).stat().st_mtime
self.assertEqual(mtime, mtime2) self.assertEqual(mtime, mtime2)
doc.content = "test2" doc.content = "test2"
doc.save() doc.save()
tasks.train_classifier() tasks.train_classifier()
self.assertIsFile(settings.MODEL_FILE) self.assertIsFile(settings.MODEL_FILE)
mtime3 = os.stat(settings.MODEL_FILE).st_mtime mtime3 = Path(settings.MODEL_FILE).stat().st_mtime
self.assertNotEqual(mtime2, mtime3) self.assertNotEqual(mtime2, mtime3)

View File

@ -1,6 +1,6 @@
import os
import tempfile import tempfile
from datetime import timedelta from datetime import timedelta
from pathlib import Path
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
@ -107,12 +107,12 @@ class TestViews(DirectoriesMixin, TestCase):
content = b"This is a test" content = b"This is a test"
with open(filename, "wb") as f: with Path(filename).open("wb") as f:
f.write(content) f.write(content)
doc = Document.objects.create( doc = Document.objects.create(
title="none", title="none",
filename=os.path.basename(filename), filename=Path(filename).name,
mime_type="application/pdf", mime_type="application/pdf",
) )

View File

@ -133,6 +133,9 @@ class TestWorkflows(
action.assign_change_groups.add(self.group1.pk) action.assign_change_groups.add(self.group1.pk)
action.assign_custom_fields.add(self.cf1.pk) action.assign_custom_fields.add(self.cf1.pk)
action.assign_custom_fields.add(self.cf2.pk) action.assign_custom_fields.add(self.cf2.pk)
action.assign_custom_fields_values = {
self.cf2.pk: 42,
}
action.save() action.save()
w = Workflow.objects.create( w = Workflow.objects.create(
name="Workflow 1", name="Workflow 1",
@ -209,6 +212,10 @@ class TestWorkflows(
list(document.custom_fields.all().values_list("field", flat=True)), list(document.custom_fields.all().values_list("field", flat=True)),
[self.cf1.pk, self.cf2.pk], [self.cf1.pk, self.cf2.pk],
) )
self.assertEqual(
document.custom_fields.get(field=self.cf2.pk).value,
42,
)
info = cm.output[0] info = cm.output[0]
expected_str = f"Document matched {trigger} from {w}" expected_str = f"Document matched {trigger} from {w}"
@ -1215,11 +1222,11 @@ class TestWorkflows(
def test_document_updated_workflow_existing_custom_field(self): def test_document_updated_workflow_existing_custom_field(self):
""" """
GIVEN: GIVEN:
- Existing workflow with UPDATED trigger and action that adds a custom field - Existing workflow with UPDATED trigger and action that assigns a custom field with a value
WHEN: WHEN:
- Document is updated that already contains the field - Document is updated that already contains the field
THEN: THEN:
- Document update succeeds without trying to re-create the field - Document update succeeds and updates the field
""" """
trigger = WorkflowTrigger.objects.create( trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
@ -1227,6 +1234,8 @@ class TestWorkflows(
) )
action = WorkflowAction.objects.create() action = WorkflowAction.objects.create()
action.assign_custom_fields.add(self.cf1) action.assign_custom_fields.add(self.cf1)
action.assign_custom_fields_values = {self.cf1.pk: "new value"}
action.save()
w = Workflow.objects.create( w = Workflow.objects.create(
name="Workflow 1", name="Workflow 1",
order=0, order=0,
@ -1251,6 +1260,9 @@ class TestWorkflows(
format="json", format="json",
) )
doc.refresh_from_db()
self.assertEqual(doc.custom_fields.get(field=self.cf1).value, "new value")
def test_document_updated_workflow_merge_permissions(self): def test_document_updated_workflow_merge_permissions(self):
""" """
GIVEN: GIVEN:
@ -2603,7 +2615,7 @@ class TestWorkflows(
mock_post.assert_called_once_with( mock_post.assert_called_once_with(
"http://paperless-ngx.com", "http://paperless-ngx.com",
data="Test message", content="Test message",
headers={}, headers={},
files=None, files=None,
) )

View File

@ -1471,7 +1471,10 @@ class PostDocumentView(GenericAPIView):
created=created, created=created,
asn=archive_serial_number, asn=archive_serial_number,
owner_id=request.user.id, owner_id=request.user.id,
custom_field_ids=custom_field_ids, # TODO: set values
custom_fields={cf_id: None for cf_id in custom_field_ids}
if custom_field_ids
else None,
) )
async_task = consume_file.delay( async_task = consume_file.delay(

View File

@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-25 11:07-0800\n" "POT-Creation-Date: 2025-03-01 21:03-0800\n"
"PO-Revision-Date: 2022-02-17 04:17\n" "PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: English\n" "Language-Team: English\n"
@ -21,39 +21,39 @@ msgstr ""
msgid "Documents" msgid "Documents"
msgstr "" msgstr ""
#: documents/filters.py:370 #: documents/filters.py:375
msgid "Value must be valid JSON." msgid "Value must be valid JSON."
msgstr "" msgstr ""
#: documents/filters.py:389 #: documents/filters.py:394
msgid "Invalid custom field query expression" msgid "Invalid custom field query expression"
msgstr "" msgstr ""
#: documents/filters.py:399 #: documents/filters.py:404
msgid "Invalid expression list. Must be nonempty." msgid "Invalid expression list. Must be nonempty."
msgstr "" msgstr ""
#: documents/filters.py:420 #: documents/filters.py:425
msgid "Invalid logical operator {op!r}" msgid "Invalid logical operator {op!r}"
msgstr "" msgstr ""
#: documents/filters.py:434 #: documents/filters.py:439
msgid "Maximum number of query conditions exceeded." msgid "Maximum number of query conditions exceeded."
msgstr "" msgstr ""
#: documents/filters.py:499 #: documents/filters.py:504
msgid "{name!r} is not a valid custom field." msgid "{name!r} is not a valid custom field."
msgstr "" msgstr ""
#: documents/filters.py:536 #: documents/filters.py:541
msgid "{data_type} does not support query expr {expr!r}." msgid "{data_type} does not support query expr {expr!r}."
msgstr "" msgstr ""
#: documents/filters.py:644 #: documents/filters.py:649
msgid "Maximum nesting depth exceeded." msgid "Maximum nesting depth exceeded."
msgstr "" msgstr ""
#: documents/filters.py:829 #: documents/filters.py:834
msgid "Custom field not found" msgid "Custom field not found"
msgstr "" msgstr ""
@ -89,7 +89,7 @@ msgstr ""
msgid "Automatic" msgid "Automatic"
msgstr "" msgstr ""
#: documents/models.py:67 documents/models.py:433 documents/models.py:1526 #: documents/models.py:67 documents/models.py:433 documents/models.py:1536
#: paperless_mail/models.py:23 paperless_mail/models.py:143 #: paperless_mail/models.py:23 paperless_mail/models.py:143
msgid "name" msgid "name"
msgstr "" msgstr ""
@ -256,7 +256,7 @@ msgid "The position of this document in your physical document archive."
msgstr "" msgstr ""
#: documents/models.py:295 documents/models.py:761 documents/models.py:815 #: documents/models.py:295 documents/models.py:761 documents/models.py:815
#: documents/models.py:1569 #: documents/models.py:1579
msgid "document" msgid "document"
msgstr "" msgstr ""
@ -1088,141 +1088,149 @@ msgstr ""
msgid "assign these custom fields" msgid "assign these custom fields"
msgstr "" msgstr ""
#: documents/models.py:1398 #: documents/models.py:1395
msgid "custom field values"
msgstr ""
#: documents/models.py:1399
msgid "Optional values to assign to the custom fields."
msgstr ""
#: documents/models.py:1408
msgid "remove these tag(s)" msgid "remove these tag(s)"
msgstr "" msgstr ""
#: documents/models.py:1403 #: documents/models.py:1413
msgid "remove all tags" msgid "remove all tags"
msgstr "" msgstr ""
#: documents/models.py:1410 #: documents/models.py:1420
msgid "remove these document type(s)" msgid "remove these document type(s)"
msgstr "" msgstr ""
#: documents/models.py:1415 #: documents/models.py:1425
msgid "remove all document types" msgid "remove all document types"
msgstr "" msgstr ""
#: documents/models.py:1422 #: documents/models.py:1432
msgid "remove these correspondent(s)" msgid "remove these correspondent(s)"
msgstr "" msgstr ""
#: documents/models.py:1427 #: documents/models.py:1437
msgid "remove all correspondents" msgid "remove all correspondents"
msgstr "" msgstr ""
#: documents/models.py:1434 #: documents/models.py:1444
msgid "remove these storage path(s)" msgid "remove these storage path(s)"
msgstr "" msgstr ""
#: documents/models.py:1439 #: documents/models.py:1449
msgid "remove all storage paths" msgid "remove all storage paths"
msgstr "" msgstr ""
#: documents/models.py:1446 #: documents/models.py:1456
msgid "remove these owner(s)" msgid "remove these owner(s)"
msgstr "" msgstr ""
#: documents/models.py:1451 #: documents/models.py:1461
msgid "remove all owners" msgid "remove all owners"
msgstr "" msgstr ""
#: documents/models.py:1458 #: documents/models.py:1468
msgid "remove view permissions for these users" msgid "remove view permissions for these users"
msgstr "" msgstr ""
#: documents/models.py:1465 #: documents/models.py:1475
msgid "remove view permissions for these groups" msgid "remove view permissions for these groups"
msgstr "" msgstr ""
#: documents/models.py:1472 #: documents/models.py:1482
msgid "remove change permissions for these users" msgid "remove change permissions for these users"
msgstr "" msgstr ""
#: documents/models.py:1479 #: documents/models.py:1489
msgid "remove change permissions for these groups" msgid "remove change permissions for these groups"
msgstr "" msgstr ""
#: documents/models.py:1484 #: documents/models.py:1494
msgid "remove all permissions" msgid "remove all permissions"
msgstr "" msgstr ""
#: documents/models.py:1491 #: documents/models.py:1501
msgid "remove these custom fields" msgid "remove these custom fields"
msgstr "" msgstr ""
#: documents/models.py:1496 #: documents/models.py:1506
msgid "remove all custom fields" msgid "remove all custom fields"
msgstr "" msgstr ""
#: documents/models.py:1505 #: documents/models.py:1515
msgid "email" msgid "email"
msgstr "" msgstr ""
#: documents/models.py:1514 #: documents/models.py:1524
msgid "webhook" msgid "webhook"
msgstr "" msgstr ""
#: documents/models.py:1518 #: documents/models.py:1528
msgid "workflow action" msgid "workflow action"
msgstr "" msgstr ""
#: documents/models.py:1519 #: documents/models.py:1529
msgid "workflow actions" msgid "workflow actions"
msgstr "" msgstr ""
#: documents/models.py:1528 paperless_mail/models.py:145 #: documents/models.py:1538 paperless_mail/models.py:145
msgid "order" msgid "order"
msgstr "" msgstr ""
#: documents/models.py:1534 #: documents/models.py:1544
msgid "triggers" msgid "triggers"
msgstr "" msgstr ""
#: documents/models.py:1541 #: documents/models.py:1551
msgid "actions" msgid "actions"
msgstr "" msgstr ""
#: documents/models.py:1544 paperless_mail/models.py:154 #: documents/models.py:1554 paperless_mail/models.py:154
msgid "enabled" msgid "enabled"
msgstr "" msgstr ""
#: documents/models.py:1555 #: documents/models.py:1565
msgid "workflow" msgid "workflow"
msgstr "" msgstr ""
#: documents/models.py:1559 #: documents/models.py:1569
msgid "workflow trigger type" msgid "workflow trigger type"
msgstr "" msgstr ""
#: documents/models.py:1573 #: documents/models.py:1583
msgid "date run" msgid "date run"
msgstr "" msgstr ""
#: documents/models.py:1579 #: documents/models.py:1589
msgid "workflow run" msgid "workflow run"
msgstr "" msgstr ""
#: documents/models.py:1580 #: documents/models.py:1590
msgid "workflow runs" msgid "workflow runs"
msgstr "" msgstr ""
#: documents/serialisers.py:128 #: documents/serialisers.py:134
#, python-format #, python-format
msgid "Invalid regular expression: %(error)s" msgid "Invalid regular expression: %(error)s"
msgstr "" msgstr ""
#: documents/serialisers.py:554 #: documents/serialisers.py:560
msgid "Invalid color." msgid "Invalid color."
msgstr "" msgstr ""
#: documents/serialisers.py:1570 #: documents/serialisers.py:1576
#, python-format #, python-format
msgid "File type %(type)s not supported" msgid "File type %(type)s not supported"
msgstr "" msgstr ""
#: documents/serialisers.py:1659 #: documents/serialisers.py:1665
msgid "Invalid variable detected." msgid "Invalid variable detected."
msgstr "" msgstr ""
@ -1463,7 +1471,7 @@ msgstr ""
msgid "Unable to parse URI {value}" msgid "Unable to parse URI {value}"
msgstr "" msgstr ""
#: paperless/apps.py:10 #: paperless/apps.py:11
msgid "Paperless" msgid "Paperless"
msgstr "" msgstr ""
@ -1611,139 +1619,139 @@ msgstr ""
msgid "paperless application settings" msgid "paperless application settings"
msgstr "" msgstr ""
#: paperless/settings.py:721 #: paperless/settings.py:724
msgid "English (US)" msgid "English (US)"
msgstr "" msgstr ""
#: paperless/settings.py:722 #: paperless/settings.py:725
msgid "Arabic" msgid "Arabic"
msgstr "" msgstr ""
#: paperless/settings.py:723 #: paperless/settings.py:726
msgid "Afrikaans" msgid "Afrikaans"
msgstr "" msgstr ""
#: paperless/settings.py:724 #: paperless/settings.py:727
msgid "Belarusian" msgid "Belarusian"
msgstr "" msgstr ""
#: paperless/settings.py:725 #: paperless/settings.py:728
msgid "Bulgarian" msgid "Bulgarian"
msgstr "" msgstr ""
#: paperless/settings.py:726 #: paperless/settings.py:729
msgid "Catalan" msgid "Catalan"
msgstr "" msgstr ""
#: paperless/settings.py:727 #: paperless/settings.py:730
msgid "Czech" msgid "Czech"
msgstr "" msgstr ""
#: paperless/settings.py:728 #: paperless/settings.py:731
msgid "Danish" msgid "Danish"
msgstr "" msgstr ""
#: paperless/settings.py:729 #: paperless/settings.py:732
msgid "German" msgid "German"
msgstr "" msgstr ""
#: paperless/settings.py:730 #: paperless/settings.py:733
msgid "Greek" msgid "Greek"
msgstr "" msgstr ""
#: paperless/settings.py:731 #: paperless/settings.py:734
msgid "English (GB)" msgid "English (GB)"
msgstr "" msgstr ""
#: paperless/settings.py:732 #: paperless/settings.py:735
msgid "Spanish" msgid "Spanish"
msgstr "" msgstr ""
#: paperless/settings.py:733 #: paperless/settings.py:736
msgid "Finnish" msgid "Finnish"
msgstr "" msgstr ""
#: paperless/settings.py:734 #: paperless/settings.py:737
msgid "French" msgid "French"
msgstr "" msgstr ""
#: paperless/settings.py:735 #: paperless/settings.py:738
msgid "Hungarian" msgid "Hungarian"
msgstr "" msgstr ""
#: paperless/settings.py:736 #: paperless/settings.py:739
msgid "Italian" msgid "Italian"
msgstr "" msgstr ""
#: paperless/settings.py:737 #: paperless/settings.py:740
msgid "Japanese" msgid "Japanese"
msgstr "" msgstr ""
#: paperless/settings.py:738 #: paperless/settings.py:741
msgid "Korean" msgid "Korean"
msgstr "" msgstr ""
#: paperless/settings.py:739 #: paperless/settings.py:742
msgid "Luxembourgish" msgid "Luxembourgish"
msgstr "" msgstr ""
#: paperless/settings.py:740 #: paperless/settings.py:743
msgid "Norwegian" msgid "Norwegian"
msgstr "" msgstr ""
#: paperless/settings.py:741 #: paperless/settings.py:744
msgid "Dutch" msgid "Dutch"
msgstr "" msgstr ""
#: paperless/settings.py:742 #: paperless/settings.py:745
msgid "Polish" msgid "Polish"
msgstr "" msgstr ""
#: paperless/settings.py:743 #: paperless/settings.py:746
msgid "Portuguese (Brazil)" msgid "Portuguese (Brazil)"
msgstr "" msgstr ""
#: paperless/settings.py:744 #: paperless/settings.py:747
msgid "Portuguese" msgid "Portuguese"
msgstr "" msgstr ""
#: paperless/settings.py:745 #: paperless/settings.py:748
msgid "Romanian" msgid "Romanian"
msgstr "" msgstr ""
#: paperless/settings.py:746 #: paperless/settings.py:749
msgid "Russian" msgid "Russian"
msgstr "" msgstr ""
#: paperless/settings.py:747 #: paperless/settings.py:750
msgid "Slovak" msgid "Slovak"
msgstr "" msgstr ""
#: paperless/settings.py:748 #: paperless/settings.py:751
msgid "Slovenian" msgid "Slovenian"
msgstr "" msgstr ""
#: paperless/settings.py:749 #: paperless/settings.py:752
msgid "Serbian" msgid "Serbian"
msgstr "" msgstr ""
#: paperless/settings.py:750 #: paperless/settings.py:753
msgid "Swedish" msgid "Swedish"
msgstr "" msgstr ""
#: paperless/settings.py:751 #: paperless/settings.py:754
msgid "Turkish" msgid "Turkish"
msgstr "" msgstr ""
#: paperless/settings.py:752 #: paperless/settings.py:755
msgid "Ukrainian" msgid "Ukrainian"
msgstr "" msgstr ""
#: paperless/settings.py:753 #: paperless/settings.py:756
msgid "Chinese Simplified" msgid "Chinese Simplified"
msgstr "" msgstr ""
#: paperless/settings.py:754 #: paperless/settings.py:757
msgid "Chinese Traditional" msgid "Chinese Traditional"
msgstr "" msgstr ""

View File

@ -3,7 +3,6 @@ import json
import math import math
import multiprocessing import multiprocessing
import os import os
import re
import tempfile import tempfile
from os import PathLike from os import PathLike
from pathlib import Path from pathlib import Path
@ -1089,11 +1088,6 @@ FILENAME_DATE_ORDER = os.getenv("PAPERLESS_FILENAME_DATE_ORDER")
# fewer dates shown. # fewer dates shown.
NUMBER_OF_SUGGESTED_DATES = __get_int("PAPERLESS_NUMBER_OF_SUGGESTED_DATES", 3) NUMBER_OF_SUGGESTED_DATES = __get_int("PAPERLESS_NUMBER_OF_SUGGESTED_DATES", 3)
# Transformations applied before filename parsing
FILENAME_PARSE_TRANSFORMS = []
for t in json.loads(os.getenv("PAPERLESS_FILENAME_PARSE_TRANSFORMS", "[]")):
FILENAME_PARSE_TRANSFORMS.append((re.compile(t["pattern"]), t["repl"]))
# Specify the filename format for out files # Specify the filename format for out files
FILENAME_FORMAT = os.getenv("PAPERLESS_FILENAME_FORMAT") FILENAME_FORMAT = os.getenv("PAPERLESS_FILENAME_FORMAT")

View File

@ -38,9 +38,9 @@ class TestChecks(DirectoriesMixin, TestCase):
self.assertTrue(msg.msg.endswith("is set but doesn't exist.")) self.assertTrue(msg.msg.endswith("is set but doesn't exist."))
def test_paths_check_no_access(self): def test_paths_check_no_access(self):
os.chmod(self.dirs.data_dir, 0o000) Path(self.dirs.data_dir).chmod(0o000)
os.chmod(self.dirs.media_dir, 0o000) Path(self.dirs.media_dir).chmod(0o000)
os.chmod(self.dirs.consumption_dir, 0o000) Path(self.dirs.consumption_dir).chmod(0o000)
self.addCleanup(os.chmod, self.dirs.data_dir, 0o777) self.addCleanup(os.chmod, self.dirs.data_dir, 0o777)
self.addCleanup(os.chmod, self.dirs.media_dir, 0o777) self.addCleanup(os.chmod, self.dirs.media_dir, 0o777)

View File

@ -1,4 +1,4 @@
import os from pathlib import Path
from allauth.account import views as allauth_account_views from allauth.account import views as allauth_account_views
from allauth.mfa.base import views as allauth_mfa_views from allauth.mfa.base import views as allauth_mfa_views
@ -270,7 +270,7 @@ urlpatterns = [
re_path( re_path(
r"^logo(?P<path>.*)$", r"^logo(?P<path>.*)$",
serve, serve,
kwargs={"document_root": os.path.join(settings.MEDIA_ROOT, "logo")}, kwargs={"document_root": Path(settings.MEDIA_ROOT) / "logo"},
), ),
# allauth # allauth
path( path(

View File

@ -1,8 +1,8 @@
import abc import abc
import os
from email import message_from_bytes from email import message_from_bytes
from email import policy from email import policy
from email.message import Message from email.message import Message
from pathlib import Path
from django.conf import settings from django.conf import settings
from gnupg import GPG from gnupg import GPG
@ -50,7 +50,7 @@ class MailMessageDecryptor(MailMessagePreprocessor, LoggingMixin):
return False return False
if settings.EMAIL_GNUPG_HOME is None: if settings.EMAIL_GNUPG_HOME is None:
return True return True
return os.path.isdir(settings.EMAIL_GNUPG_HOME) return Path(settings.EMAIL_GNUPG_HOME).is_dir()
def run(self, message: MailMessage) -> MailMessage: def run(self, message: MailMessage) -> MailMessage:
if not hasattr(message, "obj"): if not hasattr(message, "obj"):

View File

@ -159,7 +159,7 @@ class RasterisedDocumentParser(DocumentParser):
# the whole text, so do not utilize it in that case # the whole text, so do not utilize it in that case
if ( if (
sidecar_file is not None sidecar_file is not None
and os.path.isfile(sidecar_file) and sidecar_file.is_file()
and self.settings.mode != "redo" and self.settings.mode != "redo"
): ):
text = self.read_file_handle_unicode_errors(sidecar_file) text = self.read_file_handle_unicode_errors(sidecar_file)
@ -174,7 +174,7 @@ class RasterisedDocumentParser(DocumentParser):
# no success with the sidecar file, try PDF # no success with the sidecar file, try PDF
if not os.path.isfile(pdf_file): if not Path(pdf_file).is_file():
return None return None
try: try:
@ -368,8 +368,8 @@ class RasterisedDocumentParser(DocumentParser):
from ocrmypdf import SubprocessOutputError from ocrmypdf import SubprocessOutputError
from ocrmypdf.exceptions import DigitalSignatureError from ocrmypdf.exceptions import DigitalSignatureError
archive_path = Path(os.path.join(self.tempdir, "archive.pdf")) archive_path = Path(self.tempdir) / "archive.pdf"
sidecar_file = Path(os.path.join(self.tempdir, "sidecar.txt")) sidecar_file = Path(self.tempdir) / "sidecar.txt"
args = self.construct_ocrmypdf_parameters( args = self.construct_ocrmypdf_parameters(
document_path, document_path,
@ -412,12 +412,8 @@ class RasterisedDocumentParser(DocumentParser):
f"Attempting force OCR to get the text.", f"Attempting force OCR to get the text.",
) )
archive_path_fallback = Path( archive_path_fallback = Path(self.tempdir) / "archive-fallback.pdf"
os.path.join(self.tempdir, "archive-fallback.pdf"), sidecar_file_fallback = Path(self.tempdir) / "sidecar-fallback.txt"
)
sidecar_file_fallback = Path(
os.path.join(self.tempdir, "sidecar-fallback.txt"),
)
# Attempt to run OCR with safe settings. # Attempt to run OCR with safe settings.

View File

@ -75,7 +75,7 @@ class TestTikaParserAgainstServer:
== "This is an DOCX test document, also made September 14, 2022" == "This is an DOCX test document, also made September 14, 2022"
) )
assert tika_parser.archive_path is not None assert tika_parser.archive_path is not None
with open(tika_parser.archive_path, "rb") as f: with Path(tika_parser.archive_path).open("rb") as f:
assert b"PDF-" in f.read()[:10] assert b"PDF-" in f.read()[:10]
# self.assertEqual(tika_parser.date, datetime.datetime(2022, 9, 14)) # self.assertEqual(tika_parser.date, datetime.datetime(2022, 9, 14))
@ -104,7 +104,7 @@ class TestTikaParserAgainstServer:
in tika_parser.text in tika_parser.text
) )
assert tika_parser.archive_path is not None assert tika_parser.archive_path is not None
with open(tika_parser.archive_path, "rb") as f: with Path(tika_parser.archive_path).open("rb") as f:
assert b"PDF-" in f.read()[:10] assert b"PDF-" in f.read()[:10]
def test_tika_fails_multi_part( def test_tika_fails_multi_part(
@ -130,5 +130,5 @@ class TestTikaParserAgainstServer:
) )
assert tika_parser.archive_path is not None assert tika_parser.archive_path is not None
with open(tika_parser.archive_path, "rb") as f: with Path(tika_parser.archive_path).open("rb") as f:
assert b"PDF-" in f.read()[:10] assert b"PDF-" in f.read()[:10]

View File

@ -38,7 +38,7 @@ class TestTikaParser:
assert tika_parser.text == "the content" assert tika_parser.text == "the content"
assert tika_parser.archive_path is not None assert tika_parser.archive_path is not None
with open(tika_parser.archive_path, "rb") as f: with Path(tika_parser.archive_path).open("rb") as f:
assert f.read() == b"PDF document" assert f.read() == b"PDF document"
assert tika_parser.date == datetime.datetime( assert tika_parser.date == datetime.datetime(

View File

@ -1,35 +0,0 @@
[tool:pytest]
DJANGO_SETTINGS_MODULE = paperless.settings
addopts = --pythonwarnings=all --cov --cov-report=html --cov-report=xml --numprocesses auto --maxprocesses=16 --quiet --durations=50
env =
PAPERLESS_DISABLE_DBHANDLER=true
PAPERLESS_CACHE_BACKEND=django.core.cache.backends.locmem.LocMemCache
norecursedirs = locale/*
[coverage:run]
source =
./
omit =
*/tests/*
manage.py
paperless/workers.py
paperless/wsgi.py
paperless/auth.py
[coverage:report]
exclude_also =
if settings.AUDIT_LOG_ENABLED:
if AUDIT_LOG_ENABLED:
if TYPE_CHECKING:
[mypy]
plugins = mypy_django_plugin.main, mypy_drf_plugin.main, numpy.typing.mypy_plugin
check_untyped_defs = true
disallow_any_generics = true
disallow_incomplete_defs = true
disallow_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
[mypy.plugins.django-stubs]
django_settings_module = "paperless.settings"

3901
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@ if __name__ == "__main__":
Granian( Granian(
"paperless.asgi:application", "paperless.asgi:application",
interface=Interfaces.ASGI, interface=Interfaces.ASGINL,
address=os.getenv("GRANIAN_HOST") or os.getenv("PAPERLESS_BIND_ADDR", "::"), address=os.getenv("GRANIAN_HOST") or os.getenv("PAPERLESS_BIND_ADDR", "::"),
port=int(os.getenv("GRANIAN_PORT") or os.getenv("PAPERLESS_PORT") or 8000), port=int(os.getenv("GRANIAN_PORT") or os.getenv("PAPERLESS_PORT") or 8000),
workers=int( workers=int(