mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-03 03:16:10 -06:00 
			
		
		
		
	Compare commits
	
		
			99 Commits
		
	
	
		
			feature-pa
			...
			7a07f1e81d
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					7a07f1e81d | ||
| 
						 | 
					92524ae97a | ||
| 
						 | 
					1c89f6da24 | ||
| 
						 | 
					d1a3e3b859 | ||
| 
						 | 
					79ae594d54 | ||
| 
						 | 
					97fe5c4176 | ||
| 
						 | 
					e60bd3a132 | ||
| 
						 | 
					5d6cfa7349 | ||
| 
						 | 
					3105317137 | ||
| 
						 | 
					1456169d7f | ||
| 
						 | 
					22a6fe5e10 | ||
| 
						 | 
					a0c1a19263 | ||
| 
						 | 
					1f5d1b6f26 | ||
| 
						 | 
					3b19a727b8 | ||
| 
						 | 
					7146a5f4fc | ||
| 
						 | 
					6babc61ba2 | ||
| 
						 | 
					db5e54c6e5 | ||
| 
						 | 
					4ef5fbfb6e | ||
| 
						 | 
					b0390a92ea | ||
| 
						 | 
					70898a5064 | ||
| 
						 | 
					d07d425618 | ||
| 
						 | 
					1f60f4a636 | ||
| 
						 | 
					b29395cde7 | ||
| 
						 | 
					2f70d58219 | ||
| 
						 | 
					9944f81512 | ||
| 
						 | 
					f4413e0a08 | ||
| 
						 | 
					169aa8c8bd | ||
| 
						 | 
					94556a2607 | ||
| 
						 | 
					dcd50d5359 | ||
| 
						 | 
					376823598e | ||
| 
						 | 
					54bcbfa546 | ||
| 
						 | 
					5421e54cb0 | ||
| 
						 | 
					032bada221 | ||
| 
						 | 
					670ee6c5b0 | ||
| 
						 | 
					309fb199f2 | ||
| 
						 | 
					79328b1cec | ||
| 
						 | 
					5570d20625 | ||
| 
						 | 
					ba2cb1dec8 | ||
| 
						 | 
					4d15544a3e | ||
| 
						 | 
					955ff32dcd | ||
| 
						 | 
					b746b6f2d6 | ||
| 
						 | 
					b4b0f802e1 | ||
| 
						 | 
					5f16d5f5f1 | ||
| 
						 | 
					8db04398c7 | ||
| 
						 | 
					485dad01b7 | ||
| 
						 | 
					cf48f47a8c | ||
| 
						 | 
					f2667f5afa | ||
| 
						 | 
					d73118d226 | ||
| 
						 | 
					dd9e9a8c56 | ||
| 
						 | 
					76d363f22d | ||
| 
						 | 
					aaaa6c1393 | ||
| 
						 | 
					bed82215a0 | ||
| 
						 | 
					f8aaa5cb32 | ||
| 
						 | 
					1e489a0666 | ||
| 
						 | 
					edc7181843 | ||
| 
						 | 
					89e5c08a1f | ||
| 
						 | 
					0faa9e8865 | ||
| 
						 | 
					f205c4d0e2 | ||
| 
						 | 
					344b2bc0eb | ||
| 
						 | 
					817aad7c8b | ||
| 
						 | 
					d82555e644 | ||
| 
						 | 
					f3e6ed56b9 | ||
| 
						 | 
					780d1c67e9 | ||
| 
						 | 
					2b72397a4d | ||
| 
						 | 
					6c13ffaa01 | ||
| 
						 | 
					eb8e124971 | ||
| 
						 | 
					1bc77546eb | ||
| 
						 | 
					5a453653e2 | ||
| 
						 | 
					16f17829b6 | ||
| 
						 | 
					3cf1c04a83 | ||
| 
						 | 
					bc90ccc555 | ||
| 
						 | 
					90a332a02c | ||
| 
						 | 
					0098d1bdd5 | ||
| 
						 | 
					f6fef18a73 | ||
| 
						 | 
					6563ec6770 | ||
| 
						 | 
					755cf8619f | ||
| 
						 | 
					c6d389100c | ||
| 
						 | 
					20c4b65273 | ||
| 
						 | 
					86c94c7508 | ||
| 
						 | 
					798ece411e | ||
| 
						 | 
					654c9ca273 | ||
| 
						 | 
					628d85080f | ||
| 
						 | 
					865e9fe233 | ||
| 
						 | 
					0eb765c3e8 | ||
| 
						 | 
					ddeb741a85 | ||
| 
						 | 
					b9bcff22f8 | ||
| 
						 | 
					2d52226732 | ||
| 
						 | 
					ec34197b59 | ||
| 
						 | 
					edc0e6f859 | ||
| 
						 | 
					61cb5103ed | ||
| 
						 | 
					d364436817 | ||
| 
						 | 
					827fcba277 | ||
| 
						 | 
					3104417076 | ||
| 
						 | 
					047f7c3619 | ||
| 
						 | 
					a548c32c1f | ||
| 
						 | 
					ea911e73c6 | ||
| 
						 | 
					6b7fb286f7 | ||
| 
						 | 
					b40479632b | ||
| 
						 | 
					c122c60d3f | 
							
								
								
									
										16
									
								
								.codecov.yml
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								.codecov.yml
									
									
									
									
									
								
							@@ -1,18 +1,18 @@
 | 
			
		||||
codecov:
 | 
			
		||||
  require_ci_to_pass: true
 | 
			
		||||
# https://docs.codecov.com/docs/flags#recommended-automatic-flag-management
 | 
			
		||||
# Require each flag to have 1 upload before notification
 | 
			
		||||
flag_management:
 | 
			
		||||
  individual_flags:
 | 
			
		||||
    - name: backend
 | 
			
		||||
  # https://docs.codecov.com/docs/components
 | 
			
		||||
component_management:
 | 
			
		||||
  individual_components:
 | 
			
		||||
    - component_id: backend
 | 
			
		||||
      paths:
 | 
			
		||||
        - src/
 | 
			
		||||
    - name: frontend
 | 
			
		||||
        - src/**
 | 
			
		||||
    - component_id: frontend
 | 
			
		||||
      paths:
 | 
			
		||||
        - src-ui/
 | 
			
		||||
        - src-ui/**
 | 
			
		||||
# https://docs.codecov.com/docs/pull-request-comments
 | 
			
		||||
# codecov will only comment if coverage changes
 | 
			
		||||
comment:
 | 
			
		||||
  layout: "header, diff, components, flags, files"
 | 
			
		||||
  require_changes: true
 | 
			
		||||
  # https://docs.codecov.com/docs/javascript-bundle-analysis
 | 
			
		||||
  require_bundle_changes: true
 | 
			
		||||
 
 | 
			
		||||
@@ -76,18 +76,15 @@ RUN set -eux \
 | 
			
		||||
    && apt-get update \
 | 
			
		||||
    && apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES}
 | 
			
		||||
 | 
			
		||||
ARG PYTHON_PACKAGES="\
 | 
			
		||||
  python3 \
 | 
			
		||||
  python3-pip \
 | 
			
		||||
  python3-wheel \
 | 
			
		||||
  pipenv \
 | 
			
		||||
  ca-certificates"
 | 
			
		||||
ARG PYTHON_PACKAGES="ca-certificates"
 | 
			
		||||
 | 
			
		||||
RUN set -eux \
 | 
			
		||||
  echo "Installing python packages" \
 | 
			
		||||
    && apt-get update \
 | 
			
		||||
    && apt-get install --yes --quiet ${PYTHON_PACKAGES}
 | 
			
		||||
 | 
			
		||||
COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /bin/uv
 | 
			
		||||
 | 
			
		||||
RUN set -eux \
 | 
			
		||||
  && echo "Installing pre-built updates" \
 | 
			
		||||
    && echo "Installing qpdf ${QPDF_VERSION}" \
 | 
			
		||||
@@ -123,13 +120,15 @@ RUN set -eux \
 | 
			
		||||
WORKDIR /usr/src/paperless/src/docker/
 | 
			
		||||
 | 
			
		||||
COPY [ \
 | 
			
		||||
  "docker/imagemagick-policy.xml", \
 | 
			
		||||
  "docker/rootfs/etc/ImageMagick-6/paperless-policy.xml", \
 | 
			
		||||
  "./" \
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
RUN set -eux \
 | 
			
		||||
  && echo "Configuring ImageMagick" \
 | 
			
		||||
    && mv imagemagick-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
 | 
			
		||||
# dependencies
 | 
			
		||||
@@ -140,18 +139,17 @@ ARG BUILD_PACKAGES="\
 | 
			
		||||
  libpq-dev \
 | 
			
		||||
  # https://github.com/PyMySQL/mysqlclient#linux
 | 
			
		||||
  default-libmysqlclient-dev \
 | 
			
		||||
  pkg-config \
 | 
			
		||||
  pre-commit"
 | 
			
		||||
  pkg-config"
 | 
			
		||||
 | 
			
		||||
# 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 \
 | 
			
		||||
  && echo "Installing build system packages" \
 | 
			
		||||
    && apt-get update \
 | 
			
		||||
    && apt-get install --yes --quiet ${BUILD_PACKAGES}
 | 
			
		||||
 | 
			
		||||
RUN set -eux \
 | 
			
		||||
  && npm update npm -g
 | 
			
		||||
  && npm update -g pnpm
 | 
			
		||||
 | 
			
		||||
# add users, setup scripts
 | 
			
		||||
# Mount the compiled frontend to expected location
 | 
			
		||||
@@ -169,9 +167,6 @@ RUN set -eux \
 | 
			
		||||
    && mkdir --parents --verbose /usr/src/paperless/paperless-ngx/.venv \
 | 
			
		||||
  && echo "Adjusting all permissions" \
 | 
			
		||||
    && 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", \
 | 
			
		||||
        "/usr/src/paperless/paperless-ngx/media", \
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										117
									
								
								.devcontainer/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								.devcontainer/README.md
									
									
									
									
									
										Normal 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!
 | 
			
		||||
@@ -3,7 +3,7 @@
 | 
			
		||||
    "dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml",
 | 
			
		||||
    "service": "paperless-development",
 | 
			
		||||
    "workspaceFolder": "/usr/src/paperless/paperless-ngx",
 | 
			
		||||
    "postCreateCommand": "pipenv install --dev && pipenv run pre-commit install",
 | 
			
		||||
    "postCreateCommand": "/bin/bash -c 'uv sync --group dev && uv run pre-commit install'",
 | 
			
		||||
    "customizations": {
 | 
			
		||||
        "vscode": {
 | 
			
		||||
          "extensions": [
 | 
			
		||||
 
 | 
			
		||||
@@ -43,7 +43,7 @@ services:
 | 
			
		||||
    volumes:
 | 
			
		||||
      - ..:/usr/src/paperless/paperless-ngx:delegated
 | 
			
		||||
      - ../.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/.pytest_cache
 | 
			
		||||
      - /usr/src/paperless/paperless-ngx/.ruff_cache
 | 
			
		||||
@@ -65,7 +65,7 @@ services:
 | 
			
		||||
    command: /bin/sh -c "chown -R paperless:paperless /usr/src/paperless/paperless-ngx/src/documents/static/frontend && chown -R paperless:paperless /usr/src/paperless/paperless-ngx/.ruff_cache && while sleep 1000; do :; done"
 | 
			
		||||
 | 
			
		||||
  gotenberg:
 | 
			
		||||
    image: docker.io/gotenberg/gotenberg:7.10
 | 
			
		||||
    image: docker.io/gotenberg/gotenberg:8.17
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
 | 
			
		||||
    # The Gotenberg Chromium route is used to convert .eml files. We do not
 | 
			
		||||
@@ -80,4 +80,7 @@ services:
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
 | 
			
		||||
volumes:
 | 
			
		||||
  pipenv:
 | 
			
		||||
  data:
 | 
			
		||||
  media:
 | 
			
		||||
  redisdata:
 | 
			
		||||
  virtualenv:
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@
 | 
			
		||||
			"label": "Start: Celery Worker",
 | 
			
		||||
			"description": "Start the Celery Worker which processes background and consume tasks",
 | 
			
		||||
			"type": "shell",
 | 
			
		||||
			"command": "pipenv run celery --app paperless worker -l DEBUG",
 | 
			
		||||
			"command": "uv run celery --app paperless worker -l DEBUG",
 | 
			
		||||
			"isBackground": true,
 | 
			
		||||
			"options": {
 | 
			
		||||
				"cwd": "${workspaceFolder}/src"
 | 
			
		||||
@@ -33,7 +33,7 @@
 | 
			
		||||
			"label": "Start: Frontend Angular",
 | 
			
		||||
			"description": "Start the Frontend Angular Dev Server",
 | 
			
		||||
			"type": "shell",
 | 
			
		||||
			"command": "npm start",
 | 
			
		||||
			"command": "pnpm start",
 | 
			
		||||
			"isBackground": true,
 | 
			
		||||
			"options": {
 | 
			
		||||
				"cwd": "${workspaceFolder}/src-ui"
 | 
			
		||||
@@ -61,7 +61,7 @@
 | 
			
		||||
			"label": "Start: Consumer Service (manage.py document_consumer)",
 | 
			
		||||
			"description": "Start the Consumer Service which processes files from a directory",
 | 
			
		||||
			"type": "shell",
 | 
			
		||||
			"command": "pipenv run python manage.py document_consumer",
 | 
			
		||||
			"command": "uv run python manage.py document_consumer",
 | 
			
		||||
			"group": "build",
 | 
			
		||||
			"presentation": {
 | 
			
		||||
				"echo": true,
 | 
			
		||||
@@ -80,7 +80,7 @@
 | 
			
		||||
			"label": "Start: Backend Server (manage.py runserver)",
 | 
			
		||||
			"description": "Start the Backend Server which serves the Django API and the compiled Angular frontend",
 | 
			
		||||
			"type": "shell",
 | 
			
		||||
			"command": "pipenv run python manage.py runserver",
 | 
			
		||||
			"command": "uv run python manage.py runserver",
 | 
			
		||||
			"group": "build",
 | 
			
		||||
			"presentation": {
 | 
			
		||||
				"echo": true,
 | 
			
		||||
@@ -99,7 +99,7 @@
 | 
			
		||||
			"label": "Maintenance: manage.py migrate",
 | 
			
		||||
			"description": "Apply database migrations",
 | 
			
		||||
			"type": "shell",
 | 
			
		||||
			"command": "pipenv run python manage.py migrate",
 | 
			
		||||
			"command": "uv run python manage.py migrate",
 | 
			
		||||
			"group": "none",
 | 
			
		||||
			"presentation": {
 | 
			
		||||
				"echo": true,
 | 
			
		||||
@@ -118,7 +118,7 @@
 | 
			
		||||
			"label": "Maintenance: Build Documentation",
 | 
			
		||||
			"description": "Build the documentation with MkDocs",
 | 
			
		||||
			"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",
 | 
			
		||||
			"presentation": {
 | 
			
		||||
				"echo": true,
 | 
			
		||||
@@ -137,7 +137,7 @@
 | 
			
		||||
			"label": "Maintenance: manage.py createsuperuser",
 | 
			
		||||
			"description": "Create a superuser",
 | 
			
		||||
			"type": "shell",
 | 
			
		||||
			"command": "pipenv run python manage.py createsuperuser",
 | 
			
		||||
			"command": "uv run python manage.py createsuperuser",
 | 
			
		||||
			"group": "none",
 | 
			
		||||
			"presentation": {
 | 
			
		||||
				"echo": true,
 | 
			
		||||
@@ -156,7 +156,7 @@
 | 
			
		||||
			"label": "Maintenance: recreate .venv",
 | 
			
		||||
			"description": "Recreate the python virtual environment and install python dependencies",
 | 
			
		||||
			"type": "shell",
 | 
			
		||||
			"command": "rm -R -v .venv/* || pipenv install --dev",
 | 
			
		||||
			"command": "rm -R -v .venv/* || uv install --dev",
 | 
			
		||||
			"group": "none",
 | 
			
		||||
			"presentation": {
 | 
			
		||||
				"echo": true,
 | 
			
		||||
@@ -173,8 +173,8 @@
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			"label": "Maintenance: Install Frontend Dependencies",
 | 
			
		||||
			"description": "Install frontend (npm) dependencies",
 | 
			
		||||
			"type": "npm",
 | 
			
		||||
			"description": "Install frontend (pnpm) dependencies",
 | 
			
		||||
			"type": "pnpm",
 | 
			
		||||
			"script": "install",
 | 
			
		||||
			"path": "src-ui",
 | 
			
		||||
			"group": "clean",
 | 
			
		||||
@@ -185,7 +185,7 @@
 | 
			
		||||
			"description": "Clean install frontend dependencies and build the frontend for production",
 | 
			
		||||
			"label": "Maintenance: Compile frontend for production",
 | 
			
		||||
			"type": "shell",
 | 
			
		||||
			"command": "npm ci && ./node_modules/.bin/ng build --configuration production",
 | 
			
		||||
			"command": "pnpm install && ./node_modules/.bin/ng build --configuration production",
 | 
			
		||||
			"group": "none",
 | 
			
		||||
			"presentation": {
 | 
			
		||||
				"echo": true,
 | 
			
		||||
 
 | 
			
		||||
@@ -27,9 +27,6 @@ indent_style = space
 | 
			
		||||
[*.md]
 | 
			
		||||
indent_style = space
 | 
			
		||||
 | 
			
		||||
[Pipfile.lock]
 | 
			
		||||
indent_style = space
 | 
			
		||||
 | 
			
		||||
# 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
 | 
			
		||||
# violate it.
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
github: [shamoon, stumpylog]
 | 
			
		||||
							
								
								
									
										66
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										66
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,12 +1,15 @@
 | 
			
		||||
# https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates#package-ecosystem
 | 
			
		||||
# Please see the documentation for all configuration options:
 | 
			
		||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
 | 
			
		||||
 | 
			
		||||
version: 2
 | 
			
		||||
# Required for uv support for now
 | 
			
		||||
enable-beta-ecosystems: true
 | 
			
		||||
updates:
 | 
			
		||||
 | 
			
		||||
  # Enable version updates for npm
 | 
			
		||||
  # Enable version updates for pnpm
 | 
			
		||||
  - package-ecosystem: "npm"
 | 
			
		||||
    target-branch: "dev"
 | 
			
		||||
    # Look for `package.json` and `lock` files in the `/src-ui` directory
 | 
			
		||||
    # Look for `pnpm-lock.yaml` file in the `/src-ui` directory
 | 
			
		||||
    directory: "/src-ui"
 | 
			
		||||
    open-pull-requests-limit: 10
 | 
			
		||||
    schedule:
 | 
			
		||||
@@ -34,9 +37,8 @@ updates:
 | 
			
		||||
          - "eslint"
 | 
			
		||||
 | 
			
		||||
  # Enable version updates for Python
 | 
			
		||||
  - package-ecosystem: "pip"
 | 
			
		||||
  - package-ecosystem: "uv"
 | 
			
		||||
    target-branch: "dev"
 | 
			
		||||
    # Look for a `Pipfile` in the `root` directory
 | 
			
		||||
    directory: "/"
 | 
			
		||||
    # Check for updates once a week
 | 
			
		||||
    schedule:
 | 
			
		||||
@@ -47,14 +49,13 @@ updates:
 | 
			
		||||
    # Add reviewers
 | 
			
		||||
    reviewers:
 | 
			
		||||
      - "paperless-ngx/backend"
 | 
			
		||||
    ignore:
 | 
			
		||||
      - dependency-name: "uvicorn"
 | 
			
		||||
    groups:
 | 
			
		||||
      development:
 | 
			
		||||
        patterns:
 | 
			
		||||
          - "*pytest*"
 | 
			
		||||
          - "ruff"
 | 
			
		||||
          - "mkdocs-material"
 | 
			
		||||
          - "pre-commit*"
 | 
			
		||||
      django:
 | 
			
		||||
        patterns:
 | 
			
		||||
          - "*django*"
 | 
			
		||||
@@ -65,6 +66,10 @@ updates:
 | 
			
		||||
        update-types:
 | 
			
		||||
          - "minor"
 | 
			
		||||
          - "patch"
 | 
			
		||||
      pre-built:
 | 
			
		||||
        patterns:
 | 
			
		||||
          - psycopg*
 | 
			
		||||
          - zxing-cpp
 | 
			
		||||
 | 
			
		||||
  # Enable updates for GitHub Actions
 | 
			
		||||
  - package-ecosystem: "github-actions"
 | 
			
		||||
@@ -85,3 +90,50 @@ updates:
 | 
			
		||||
          - "major"
 | 
			
		||||
          - "minor"
 | 
			
		||||
          - "patch"
 | 
			
		||||
 | 
			
		||||
  # Update Dockerfile in root directory
 | 
			
		||||
  - package-ecosystem: "docker"
 | 
			
		||||
    directory: "/"
 | 
			
		||||
    schedule:
 | 
			
		||||
      interval: "weekly"
 | 
			
		||||
    open-pull-requests-limit: 5
 | 
			
		||||
    reviewers:
 | 
			
		||||
      - "paperless-ngx/ci-cd"
 | 
			
		||||
    labels:
 | 
			
		||||
      - "ci-cd"
 | 
			
		||||
      - "dependencies"
 | 
			
		||||
    commit-message:
 | 
			
		||||
      prefix: "docker"
 | 
			
		||||
      include: "scope"
 | 
			
		||||
 | 
			
		||||
  # Update Docker Compose files in docker/compose directory
 | 
			
		||||
  - package-ecosystem: "docker-compose"
 | 
			
		||||
    directory: "/docker/compose/"
 | 
			
		||||
    schedule:
 | 
			
		||||
      interval: "weekly"
 | 
			
		||||
    open-pull-requests-limit: 5
 | 
			
		||||
    reviewers:
 | 
			
		||||
      - "paperless-ngx/ci-cd"
 | 
			
		||||
    labels:
 | 
			
		||||
      - "ci-cd"
 | 
			
		||||
      - "dependencies"
 | 
			
		||||
    commit-message:
 | 
			
		||||
      prefix: "docker-compose"
 | 
			
		||||
      include: "scope"
 | 
			
		||||
    groups:
 | 
			
		||||
      # Individual groups for each image
 | 
			
		||||
      gotenberg:
 | 
			
		||||
        patterns:
 | 
			
		||||
          - "docker.io/gotenberg/gotenberg*"
 | 
			
		||||
      tika:
 | 
			
		||||
        patterns:
 | 
			
		||||
          - "docker.io/apache/tika*"
 | 
			
		||||
      redis:
 | 
			
		||||
        patterns:
 | 
			
		||||
          - "docker.io/library/redis*"
 | 
			
		||||
      mariadb:
 | 
			
		||||
        patterns:
 | 
			
		||||
          - "docker.io/library/mariadb*"
 | 
			
		||||
      postgres:
 | 
			
		||||
        patterns:
 | 
			
		||||
          - "docker.io/library/postgres*"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										252
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										252
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							@@ -14,9 +14,7 @@ on:
 | 
			
		||||
      - 'translations**'
 | 
			
		||||
 | 
			
		||||
env:
 | 
			
		||||
  # This is the version of pipenv all the steps will use
 | 
			
		||||
  # If changing this, change Dockerfile
 | 
			
		||||
  DEFAULT_PIP_ENV_VERSION: "2024.4.1"
 | 
			
		||||
  DEFAULT_UV_VERSION: "0.6.x"
 | 
			
		||||
  # This is the default version of Python to use in most steps which aren't specific
 | 
			
		||||
  DEFAULT_PYTHON_VERSION: "3.11"
 | 
			
		||||
 | 
			
		||||
@@ -59,24 +57,25 @@ jobs:
 | 
			
		||||
        uses: actions/setup-python@v5
 | 
			
		||||
        with:
 | 
			
		||||
          python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
 | 
			
		||||
          cache: "pipenv"
 | 
			
		||||
          cache-dependency-path: 'Pipfile.lock'
 | 
			
		||||
      -
 | 
			
		||||
        name: Install pipenv
 | 
			
		||||
        run: |
 | 
			
		||||
          pip install --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }}
 | 
			
		||||
        name: Install uv
 | 
			
		||||
        uses: astral-sh/setup-uv@v5
 | 
			
		||||
        with:
 | 
			
		||||
          version: ${{ env.DEFAULT_UV_VERSION }}
 | 
			
		||||
          enable-cache: true
 | 
			
		||||
          python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
 | 
			
		||||
      -
 | 
			
		||||
        name: Install dependencies
 | 
			
		||||
        name: Install Python dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
          pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev
 | 
			
		||||
      -
 | 
			
		||||
        name: List installed Python dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
          pipenv --python ${{ steps.setup-python.outputs.python-version }} run pip list
 | 
			
		||||
          uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
 | 
			
		||||
      -
 | 
			
		||||
        name: Make documentation
 | 
			
		||||
        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
 | 
			
		||||
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
 | 
			
		||||
@@ -84,7 +83,11 @@ jobs:
 | 
			
		||||
          echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME"
 | 
			
		||||
          git config --global user.name "${{ github.actor }}"
 | 
			
		||||
          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
 | 
			
		||||
        uses: actions/upload-artifact@v4
 | 
			
		||||
@@ -117,12 +120,13 @@ jobs:
 | 
			
		||||
        uses: actions/setup-python@v5
 | 
			
		||||
        with:
 | 
			
		||||
          python-version: "${{ matrix.python-version }}"
 | 
			
		||||
          cache: "pipenv"
 | 
			
		||||
          cache-dependency-path: 'Pipfile.lock'
 | 
			
		||||
      -
 | 
			
		||||
        name: Install pipenv
 | 
			
		||||
        run: |
 | 
			
		||||
          pip install --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }}
 | 
			
		||||
        name: Install uv
 | 
			
		||||
        uses: astral-sh/setup-uv@v5
 | 
			
		||||
        with:
 | 
			
		||||
          version: ${{ env.DEFAULT_UV_VERSION }}
 | 
			
		||||
          enable-cache: true
 | 
			
		||||
          python-version: ${{ steps.setup-python.outputs.python-version }}
 | 
			
		||||
      -
 | 
			
		||||
        name: Install system dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -135,12 +139,14 @@ jobs:
 | 
			
		||||
      -
 | 
			
		||||
        name: Install Python dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
          pipenv --python ${{ steps.setup-python.outputs.python-version }} run python --version
 | 
			
		||||
          pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev
 | 
			
		||||
          uv sync \
 | 
			
		||||
            --python ${{ steps.setup-python.outputs.python-version }} \
 | 
			
		||||
            --group testing \
 | 
			
		||||
            --frozen
 | 
			
		||||
      -
 | 
			
		||||
        name: List installed Python dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
          pipenv --python ${{ steps.setup-python.outputs.python-version }} run pip list
 | 
			
		||||
          uv pip list
 | 
			
		||||
      -
 | 
			
		||||
        name: Tests
 | 
			
		||||
        env:
 | 
			
		||||
@@ -150,17 +156,26 @@ jobs:
 | 
			
		||||
          PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }}
 | 
			
		||||
          PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }}
 | 
			
		||||
        run: |
 | 
			
		||||
          cd src/
 | 
			
		||||
          pipenv --python ${{ steps.setup-python.outputs.python-version }} run pytest -ra
 | 
			
		||||
          uv run \
 | 
			
		||||
            --python ${{ steps.setup-python.outputs.python-version }} \
 | 
			
		||||
            --dev \
 | 
			
		||||
            --frozen \
 | 
			
		||||
            pytest
 | 
			
		||||
      -
 | 
			
		||||
        name: Upload coverage
 | 
			
		||||
        if: ${{ matrix.python-version == env.DEFAULT_PYTHON_VERSION }}
 | 
			
		||||
        uses: actions/upload-artifact@v4
 | 
			
		||||
        name: Upload backend test results to Codecov
 | 
			
		||||
        if: always()
 | 
			
		||||
        uses: codecov/test-results-action@v1
 | 
			
		||||
        with:
 | 
			
		||||
          name: backend-coverage-report
 | 
			
		||||
          path: src/coverage.xml
 | 
			
		||||
          retention-days: 7
 | 
			
		||||
          if-no-files-found: warn
 | 
			
		||||
          token: ${{ secrets.CODECOV_TOKEN }}
 | 
			
		||||
          flags: backend-python-${{ matrix.python-version }}
 | 
			
		||||
          files: junit.xml
 | 
			
		||||
      -
 | 
			
		||||
        name: Upload backend coverage to Codecov
 | 
			
		||||
        uses: codecov/codecov-action@v5
 | 
			
		||||
        with:
 | 
			
		||||
          token: ${{ secrets.CODECOV_TOKEN }}
 | 
			
		||||
          flags: backend-python-${{ matrix.python-version }}
 | 
			
		||||
          files: coverage.xml
 | 
			
		||||
      -
 | 
			
		||||
        name: Stop containers
 | 
			
		||||
        if: always()
 | 
			
		||||
@@ -168,42 +183,46 @@ jobs:
 | 
			
		||||
          docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml logs
 | 
			
		||||
          docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml down
 | 
			
		||||
 | 
			
		||||
  install-frontend-depedendencies:
 | 
			
		||||
  install-frontend-dependencies:
 | 
			
		||||
    name: "Install Frontend Dependencies"
 | 
			
		||||
    runs-on: ubuntu-24.04
 | 
			
		||||
    needs:
 | 
			
		||||
      - pre-commit
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v4
 | 
			
		||||
      - name: Install pnpm
 | 
			
		||||
        uses: pnpm/action-setup@v4
 | 
			
		||||
        with:
 | 
			
		||||
          version: 10
 | 
			
		||||
      -
 | 
			
		||||
        name: Use Node.js 20
 | 
			
		||||
        uses: actions/setup-node@v4
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 20.x
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
          cache-dependency-path: 'src-ui/package-lock.json'
 | 
			
		||||
          cache: 'pnpm'
 | 
			
		||||
          cache-dependency-path: 'src-ui/pnpm-lock.yaml'
 | 
			
		||||
      - name: Cache frontend dependencies
 | 
			
		||||
        id: cache-frontend-deps
 | 
			
		||||
        uses: actions/cache@v4
 | 
			
		||||
        with:
 | 
			
		||||
          path: |
 | 
			
		||||
            ~/.npm
 | 
			
		||||
            ~/.pnpm-store
 | 
			
		||||
            ~/.cache
 | 
			
		||||
          key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }}
 | 
			
		||||
          key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
 | 
			
		||||
      -
 | 
			
		||||
        name: Install dependencies
 | 
			
		||||
        if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
 | 
			
		||||
        run: cd src-ui && npm ci
 | 
			
		||||
        run: cd src-ui && pnpm install
 | 
			
		||||
      -
 | 
			
		||||
        name: Install Playwright
 | 
			
		||||
        if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
 | 
			
		||||
        run: cd src-ui && npx playwright install --with-deps
 | 
			
		||||
        run: cd src-ui && pnpm playwright install --with-deps
 | 
			
		||||
 | 
			
		||||
  tests-frontend:
 | 
			
		||||
    name: "Frontend Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})"
 | 
			
		||||
    runs-on: ubuntu-24.04
 | 
			
		||||
    needs:
 | 
			
		||||
      - install-frontend-depedendencies
 | 
			
		||||
      - install-frontend-dependencies
 | 
			
		||||
    strategy:
 | 
			
		||||
      fail-fast: false
 | 
			
		||||
      matrix:
 | 
			
		||||
@@ -212,124 +231,88 @@ jobs:
 | 
			
		||||
        shard-count: [4]
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v4
 | 
			
		||||
      - name: Install pnpm
 | 
			
		||||
        uses: pnpm/action-setup@v4
 | 
			
		||||
        with:
 | 
			
		||||
          version: 10
 | 
			
		||||
      -
 | 
			
		||||
        name: Use Node.js 20
 | 
			
		||||
        uses: actions/setup-node@v4
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 20.x
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
          cache-dependency-path: 'src-ui/package-lock.json'
 | 
			
		||||
          cache: 'pnpm'
 | 
			
		||||
          cache-dependency-path: 'src-ui/pnpm-lock.yaml'
 | 
			
		||||
      - name: Cache frontend dependencies
 | 
			
		||||
        id: cache-frontend-deps
 | 
			
		||||
        uses: actions/cache@v4
 | 
			
		||||
        with:
 | 
			
		||||
          path: |
 | 
			
		||||
            ~/.npm
 | 
			
		||||
            ~/.pnpm-store
 | 
			
		||||
            ~/.cache
 | 
			
		||||
          key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }}
 | 
			
		||||
          key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
 | 
			
		||||
      - name: Re-link Angular cli
 | 
			
		||||
        run: cd src-ui && npm link @angular/cli
 | 
			
		||||
        run: cd src-ui && pnpm link @angular/cli
 | 
			
		||||
      -
 | 
			
		||||
        name: Linting checks
 | 
			
		||||
        run: cd src-ui && npm run lint
 | 
			
		||||
        run: cd src-ui && pnpm run lint
 | 
			
		||||
      -
 | 
			
		||||
        name: Run Jest unit tests
 | 
			
		||||
        run: cd src-ui && npm run test -- --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
 | 
			
		||||
      -
 | 
			
		||||
        name: Upload Jest coverage
 | 
			
		||||
        if: always()
 | 
			
		||||
        uses: actions/upload-artifact@v4
 | 
			
		||||
        with:
 | 
			
		||||
          name: jest-coverage-report-${{ matrix.shard-index }}
 | 
			
		||||
          path: |
 | 
			
		||||
            src-ui/coverage/coverage-final.json
 | 
			
		||||
            src-ui/coverage/lcov.info
 | 
			
		||||
            src-ui/coverage/clover.xml
 | 
			
		||||
          retention-days: 7
 | 
			
		||||
          if-no-files-found: warn
 | 
			
		||||
        run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
 | 
			
		||||
      -
 | 
			
		||||
        name: Run Playwright e2e tests
 | 
			
		||||
        run: cd src-ui && npx playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
 | 
			
		||||
        run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
 | 
			
		||||
      -
 | 
			
		||||
        name: Upload Playwright test results
 | 
			
		||||
        name: Upload frontend test results to Codecov
 | 
			
		||||
        uses: codecov/test-results-action@v1
 | 
			
		||||
        if: always()
 | 
			
		||||
        uses: actions/upload-artifact@v4
 | 
			
		||||
        with:
 | 
			
		||||
          name: playwright-report-${{ matrix.shard-index }}
 | 
			
		||||
          path: src-ui/playwright-report
 | 
			
		||||
          retention-days: 7
 | 
			
		||||
 | 
			
		||||
  tests-coverage-upload:
 | 
			
		||||
    name: "Upload to Codecov"
 | 
			
		||||
    runs-on: ubuntu-24.04
 | 
			
		||||
    needs:
 | 
			
		||||
      - tests-backend
 | 
			
		||||
      - tests-frontend
 | 
			
		||||
    steps:
 | 
			
		||||
      -
 | 
			
		||||
        uses: actions/checkout@v4
 | 
			
		||||
      -
 | 
			
		||||
        name: Download frontend jest coverage
 | 
			
		||||
        uses: actions/download-artifact@v4
 | 
			
		||||
        with:
 | 
			
		||||
          path: src-ui/coverage/
 | 
			
		||||
          pattern: jest-coverage-report-*
 | 
			
		||||
      -
 | 
			
		||||
        name: Download frontend playwright coverage
 | 
			
		||||
        uses: actions/download-artifact@v4
 | 
			
		||||
        with:
 | 
			
		||||
          path: src-ui/coverage/
 | 
			
		||||
          pattern: playwright-report-*
 | 
			
		||||
          merge-multiple: true
 | 
			
		||||
          token: ${{ secrets.CODECOV_TOKEN }}
 | 
			
		||||
          flags: frontend-node-${{ matrix.node-version }}
 | 
			
		||||
          directory: src-ui/
 | 
			
		||||
      -
 | 
			
		||||
        name: Upload frontend coverage to Codecov
 | 
			
		||||
        uses: codecov/codecov-action@v5
 | 
			
		||||
        with:
 | 
			
		||||
          # not required for public repos, but intermittently fails otherwise
 | 
			
		||||
          token: ${{ secrets.CODECOV_TOKEN }}
 | 
			
		||||
          flags: frontend
 | 
			
		||||
          flags: frontend-node-${{ matrix.node-version }}
 | 
			
		||||
          directory: src-ui/coverage/
 | 
			
		||||
          # dont include backend coverage files here
 | 
			
		||||
          files: '!coverage.xml'
 | 
			
		||||
 | 
			
		||||
  frontend-bundle-analysis:
 | 
			
		||||
    name: "Frontend Bundle Analysis"
 | 
			
		||||
    runs-on: ubuntu-24.04
 | 
			
		||||
    needs:
 | 
			
		||||
      - tests-frontend
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v4
 | 
			
		||||
      -
 | 
			
		||||
        name: Download backend coverage
 | 
			
		||||
        uses: actions/download-artifact@v4
 | 
			
		||||
        name: Install pnpm
 | 
			
		||||
        uses: pnpm/action-setup@v4
 | 
			
		||||
        with:
 | 
			
		||||
          name: backend-coverage-report
 | 
			
		||||
          path: src/
 | 
			
		||||
      -
 | 
			
		||||
        name: Upload coverage to Codecov
 | 
			
		||||
        uses: codecov/codecov-action@v5
 | 
			
		||||
        with:
 | 
			
		||||
          # not required for public repos, but intermittently fails otherwise
 | 
			
		||||
          token: ${{ secrets.CODECOV_TOKEN }}
 | 
			
		||||
          # future expansion
 | 
			
		||||
          flags: backend
 | 
			
		||||
          directory: src/
 | 
			
		||||
          version: 10
 | 
			
		||||
      -
 | 
			
		||||
        name: Use Node.js 20
 | 
			
		||||
        uses: actions/setup-node@v4
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 20.x
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
          cache-dependency-path: 'src-ui/package-lock.json'
 | 
			
		||||
          cache: 'pnpm'
 | 
			
		||||
          cache-dependency-path: 'src-ui/pnpm-lock.yaml'
 | 
			
		||||
      -
 | 
			
		||||
        name: Cache frontend dependencies
 | 
			
		||||
        id: cache-frontend-deps
 | 
			
		||||
        uses: actions/cache@v4
 | 
			
		||||
        with:
 | 
			
		||||
          path: |
 | 
			
		||||
            ~/.npm
 | 
			
		||||
            ~/.pnpm-store
 | 
			
		||||
            ~/.cache
 | 
			
		||||
          key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }}
 | 
			
		||||
      -
 | 
			
		||||
        name: Re-link Angular cli
 | 
			
		||||
        run: cd src-ui && npm link @angular/cli
 | 
			
		||||
        run: cd src-ui && pnpm link @angular/cli
 | 
			
		||||
      -
 | 
			
		||||
        name: Build frontend and upload analysis
 | 
			
		||||
        env:
 | 
			
		||||
          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
 | 
			
		||||
        run: cd src-ui && ng build --configuration=production
 | 
			
		||||
        run: cd src-ui && pnpm run build --configuration=production
 | 
			
		||||
 | 
			
		||||
  build-docker-image:
 | 
			
		||||
    name: Build Docker image for ${{ github.ref_name }}
 | 
			
		||||
@@ -472,16 +455,17 @@ jobs:
 | 
			
		||||
        uses: actions/setup-python@v5
 | 
			
		||||
        with:
 | 
			
		||||
          python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
 | 
			
		||||
          cache: "pipenv"
 | 
			
		||||
          cache-dependency-path: 'Pipfile.lock'
 | 
			
		||||
      -
 | 
			
		||||
        name: Install pipenv + tools
 | 
			
		||||
        run: |
 | 
			
		||||
          pip install --upgrade --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} setuptools wheel
 | 
			
		||||
        name: Install uv
 | 
			
		||||
        uses: astral-sh/setup-uv@v5
 | 
			
		||||
        with:
 | 
			
		||||
          version: ${{ env.DEFAULT_UV_VERSION }}
 | 
			
		||||
          enable-cache: true
 | 
			
		||||
          python-version: ${{ steps.setup-python.outputs.python-version }}
 | 
			
		||||
      -
 | 
			
		||||
        name: Install Python dependencies
 | 
			
		||||
        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
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -502,17 +486,21 @@ jobs:
 | 
			
		||||
      -
 | 
			
		||||
        name: Generate requirements file
 | 
			
		||||
        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
 | 
			
		||||
        run: |
 | 
			
		||||
          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
 | 
			
		||||
        run: |
 | 
			
		||||
          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
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -528,13 +516,12 @@ jobs:
 | 
			
		||||
          for file_name in .dockerignore \
 | 
			
		||||
                          .env \
 | 
			
		||||
                          Dockerfile \
 | 
			
		||||
                          Pipfile \
 | 
			
		||||
                          Pipfile.lock \
 | 
			
		||||
                          pyproject.toml \
 | 
			
		||||
                          uv.lock \
 | 
			
		||||
                          requirements.txt \
 | 
			
		||||
                          LICENSE \
 | 
			
		||||
                          README.md \
 | 
			
		||||
                          paperless.conf.example \
 | 
			
		||||
                          gunicorn.conf.py
 | 
			
		||||
                          paperless.conf.example
 | 
			
		||||
          do
 | 
			
		||||
            cp --verbose ${file_name} dist/paperless-ngx/
 | 
			
		||||
          done
 | 
			
		||||
@@ -631,15 +618,17 @@ jobs:
 | 
			
		||||
          ref: main
 | 
			
		||||
      -
 | 
			
		||||
        name: Set up Python
 | 
			
		||||
        id: setup-python
 | 
			
		||||
        uses: actions/setup-python@v5
 | 
			
		||||
        with:
 | 
			
		||||
          python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
 | 
			
		||||
          cache: "pipenv"
 | 
			
		||||
          cache-dependency-path: 'Pipfile.lock'
 | 
			
		||||
      -
 | 
			
		||||
        name: Install pipenv + tools
 | 
			
		||||
        run: |
 | 
			
		||||
          pip install --upgrade --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} setuptools wheel
 | 
			
		||||
        name: Install uv
 | 
			
		||||
        uses: astral-sh/setup-uv@v5
 | 
			
		||||
        with:
 | 
			
		||||
          version: ${{ env.DEFAULT_UV_VERSION }}
 | 
			
		||||
          enable-cache: true
 | 
			
		||||
          python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
 | 
			
		||||
      -
 | 
			
		||||
        name: Append Changelog to docs
 | 
			
		||||
        id: append-Changelog
 | 
			
		||||
@@ -655,7 +644,10 @@ jobs:
 | 
			
		||||
          CURRENT_CHANGELOG=`tail --lines +2 changelog.md`
 | 
			
		||||
          echo -e "$CURRENT_CHANGELOG" >> changelog-new.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.email "41898282+github-actions[bot]@users.noreply.github.com"
 | 
			
		||||
          git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										4
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
								
							@@ -33,7 +33,7 @@ jobs:
 | 
			
		||||
      -
 | 
			
		||||
        name: Clean temporary images
 | 
			
		||||
        if: "${{ env.TOKEN != '' }}"
 | 
			
		||||
        uses: stumpylog/image-cleaner-action/ephemeral@v0.9.0
 | 
			
		||||
        uses: stumpylog/image-cleaner-action/ephemeral@v0.10.0
 | 
			
		||||
        with:
 | 
			
		||||
          token: "${{ env.TOKEN }}"
 | 
			
		||||
          owner: "${{ github.repository_owner }}"
 | 
			
		||||
@@ -61,7 +61,7 @@ jobs:
 | 
			
		||||
      -
 | 
			
		||||
        name: Clean untagged images
 | 
			
		||||
        if: "${{ env.TOKEN != '' }}"
 | 
			
		||||
        uses: stumpylog/image-cleaner-action/untagged@v0.9.0
 | 
			
		||||
        uses: stumpylog/image-cleaner-action/untagged@v0.10.0
 | 
			
		||||
        with:
 | 
			
		||||
          token: "${{ env.TOKEN }}"
 | 
			
		||||
          owner: "${{ github.repository_owner }}"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -44,6 +44,7 @@ nosetests.xml
 | 
			
		||||
coverage.xml
 | 
			
		||||
*,cover
 | 
			
		||||
.pytest_cache
 | 
			
		||||
junit.xml
 | 
			
		||||
 | 
			
		||||
# Translations
 | 
			
		||||
*.mo
 | 
			
		||||
 
 | 
			
		||||
@@ -32,7 +32,7 @@ repos:
 | 
			
		||||
    rev: v2.4.0
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: codespell
 | 
			
		||||
        exclude: "(^src-ui/src/locale/)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)"
 | 
			
		||||
        exclude: "(^src-ui/src/locale/)|(^src-ui/pnpm-lock.yaml)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)"
 | 
			
		||||
        exclude_types:
 | 
			
		||||
          - pofile
 | 
			
		||||
          - json
 | 
			
		||||
@@ -45,16 +45,19 @@ repos:
 | 
			
		||||
          - javascript
 | 
			
		||||
          - ts
 | 
			
		||||
          - markdown
 | 
			
		||||
        exclude: "(^Pipfile\\.lock$)"
 | 
			
		||||
        additional_dependencies:
 | 
			
		||||
          - prettier@3.3.3
 | 
			
		||||
          - 'prettier-plugin-organize-imports@4.1.0'
 | 
			
		||||
  # Python hooks
 | 
			
		||||
  - repo: https://github.com/astral-sh/ruff-pre-commit
 | 
			
		||||
    rev: v0.9.6
 | 
			
		||||
    rev: v0.9.9
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: ruff
 | 
			
		||||
      - id: ruff-format
 | 
			
		||||
  - repo: https://github.com/tox-dev/pyproject-fmt
 | 
			
		||||
    rev: "v2.5.1"
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: pyproject-fmt
 | 
			
		||||
  # Dockerfile hooks
 | 
			
		||||
  - repo: https://github.com/AleksaC/hadolint-py
 | 
			
		||||
    rev: v2.12.0.3
 | 
			
		||||
 
 | 
			
		||||
@@ -1 +0,0 @@
 | 
			
		||||
3.10.15
 | 
			
		||||
							
								
								
									
										87
									
								
								.ruff.toml
									
									
									
									
									
								
							
							
						
						
									
										87
									
								
								.ruff.toml
									
									
									
									
									
								
							@@ -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
 | 
			
		||||
  "TCH",   # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tch
 | 
			
		||||
  "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
 | 
			
		||||
@@ -5,5 +5,6 @@
 | 
			
		||||
/src-ui/ @paperless-ngx/frontend
 | 
			
		||||
 | 
			
		||||
/src/ @paperless-ngx/backend
 | 
			
		||||
Pipfile* @paperless-ngx/backend
 | 
			
		||||
pyproject.toml @paperless-ngx/backend
 | 
			
		||||
uv.lock @paperless-ngx/backend
 | 
			
		||||
*.py @paperless-ngx/backend
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										59
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										59
									
								
								Dockerfile
									
									
									
									
									
								
							@@ -4,15 +4,17 @@
 | 
			
		||||
# Stage: compile-frontend
 | 
			
		||||
# Purpose: Compiles the frontend
 | 
			
		||||
# Notes:
 | 
			
		||||
#  - Does NPM stuff with Typescript and such
 | 
			
		||||
#  - Does PNPM stuff with Typescript and such
 | 
			
		||||
FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-slim AS compile-frontend
 | 
			
		||||
 | 
			
		||||
COPY ./src-ui /src/src-ui
 | 
			
		||||
 | 
			
		||||
WORKDIR /src/src-ui
 | 
			
		||||
RUN set -eux \
 | 
			
		||||
  && npm update npm -g \
 | 
			
		||||
  && npm ci
 | 
			
		||||
  && npm update -g pnpm \
 | 
			
		||||
  && npm install -g corepack@latest \
 | 
			
		||||
  && corepack enable \
 | 
			
		||||
  && pnpm install
 | 
			
		||||
 | 
			
		||||
ARG PNGX_TAG_VERSION=
 | 
			
		||||
# Add the tag to the environment file if its a tagged dev build
 | 
			
		||||
@@ -26,28 +28,11 @@ esac
 | 
			
		||||
RUN set -eux \
 | 
			
		||||
  && ./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
 | 
			
		||||
# Purpose: Installs s6-overlay and rootfs
 | 
			
		||||
# Comments:
 | 
			
		||||
#  - 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.5-python3.12-bookworm-slim AS s6-overlay-base
 | 
			
		||||
 | 
			
		||||
WORKDIR /usr/src/s6
 | 
			
		||||
 | 
			
		||||
@@ -123,9 +108,12 @@ ARG GS_VERSION=10.03.1
 | 
			
		||||
# Set Python environment variables
 | 
			
		||||
ENV PYTHONDONTWRITEBYTECODE=1 \
 | 
			
		||||
    PYTHONUNBUFFERED=1 \
 | 
			
		||||
    # Ignore warning from Whitenoise
 | 
			
		||||
    # Ignore warning from Whitenoise about async iterators
 | 
			
		||||
    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
 | 
			
		||||
@@ -204,46 +192,29 @@ RUN set -eux \
 | 
			
		||||
        && rm --force --verbose *.deb \
 | 
			
		||||
    && rm --recursive --force --verbose /var/lib/apt/lists/*
 | 
			
		||||
 | 
			
		||||
# Copy gunicorn config
 | 
			
		||||
# Changes very infrequently
 | 
			
		||||
WORKDIR /usr/src/paperless/
 | 
			
		||||
 | 
			
		||||
COPY --chown=1000:1000 gunicorn.conf.py /usr/src/paperless/gunicorn.conf.py
 | 
			
		||||
 | 
			
		||||
WORKDIR /usr/src/paperless/src/
 | 
			
		||||
 | 
			
		||||
# Python dependencies
 | 
			
		||||
# 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
 | 
			
		||||
# dependencies
 | 
			
		||||
ARG BUILD_PACKAGES="\
 | 
			
		||||
  build-essential \
 | 
			
		||||
  git \
 | 
			
		||||
  # https://www.psycopg.org/docs/install.html#prerequisites
 | 
			
		||||
  libpq-dev \
 | 
			
		||||
  # https://github.com/PyMySQL/mysqlclient#linux
 | 
			
		||||
  default-libmysqlclient-dev \
 | 
			
		||||
  pkg-config"
 | 
			
		||||
 | 
			
		||||
ARG ZXING_VERSION=2.3.0
 | 
			
		||||
ARG PSYCOPG_VERSION=3.2.4
 | 
			
		||||
 | 
			
		||||
# 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 \
 | 
			
		||||
  && echo "Installing build system packages" \
 | 
			
		||||
    && apt-get update \
 | 
			
		||||
    && apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \
 | 
			
		||||
    && python3 -m pip install --upgrade wheel \
 | 
			
		||||
  && echo "Installing Python requirements" \
 | 
			
		||||
    && curl --fail --silent --no-progress-meter --show-error --location --remote-name-all --parallel --parallel-max 4 \
 | 
			
		||||
      https://github.com/paperless-ngx/builder/releases/download/psycopg-${PSYCOPG_VERSION}/psycopg_c-${PSYCOPG_VERSION}-cp312-cp312-linux_x86_64.whl \
 | 
			
		||||
      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 \
 | 
			
		||||
    && uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt \
 | 
			
		||||
    && uv pip install --system --no-python-downloads --python-preference system --requirements requirements.txt \
 | 
			
		||||
  && 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" stopwords \
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										102
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										102
									
								
								Pipfile
									
									
									
									
									
								
							@@ -1,102 +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 = "*"
 | 
			
		||||
gunicorn = "*"
 | 
			
		||||
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 = "*"
 | 
			
		||||
# See https://github.com/paperless-ngx/paperless-ngx/issues/5494
 | 
			
		||||
uvicorn = {extras = ["standard"], version = "==0.25.0"}
 | 
			
		||||
watchdog = "~=6.0"
 | 
			
		||||
whitenoise = "~=6.9"
 | 
			
		||||
whoosh = "~=2.7"
 | 
			
		||||
zxing-cpp = "*"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
[dev-packages]
 | 
			
		||||
# Linting
 | 
			
		||||
pre-commit = "*"
 | 
			
		||||
ruff = "*"
 | 
			
		||||
factory-boy = "*"
 | 
			
		||||
# Testing
 | 
			
		||||
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 = "*"
 | 
			
		||||
							
								
								
									
										4978
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4978
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -5,7 +5,7 @@
 | 
			
		||||
 | 
			
		||||
services:
 | 
			
		||||
  gotenberg:
 | 
			
		||||
    image: docker.io/gotenberg/gotenberg:8.7
 | 
			
		||||
    image: docker.io/gotenberg/gotenberg:8.17
 | 
			
		||||
    hostname: gotenberg
 | 
			
		||||
    container_name: gotenberg
 | 
			
		||||
    network_mode: host
 | 
			
		||||
 
 | 
			
		||||
@@ -77,7 +77,7 @@ services:
 | 
			
		||||
      PAPERLESS_TIKA_ENDPOINT: http://tika:9998
 | 
			
		||||
 | 
			
		||||
  gotenberg:
 | 
			
		||||
    image: docker.io/gotenberg/gotenberg:8.7
 | 
			
		||||
    image: docker.io/gotenberg/gotenberg:8.17
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    # The gotenberg chromium route is used to convert .eml files. We do not
 | 
			
		||||
    # want to allow external content like tracking pixels or even javascript.
 | 
			
		||||
 
 | 
			
		||||
@@ -38,7 +38,7 @@ services:
 | 
			
		||||
      - redisdata:/data
 | 
			
		||||
 | 
			
		||||
  db:
 | 
			
		||||
    image: docker.io/library/postgres:16
 | 
			
		||||
    image: docker.io/library/postgres:17
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    volumes:
 | 
			
		||||
      - pgdata:/var/lib/postgresql/data
 | 
			
		||||
 
 | 
			
		||||
@@ -38,7 +38,7 @@ services:
 | 
			
		||||
      - redisdata:/data
 | 
			
		||||
 | 
			
		||||
  db:
 | 
			
		||||
    image: docker.io/library/postgres:16
 | 
			
		||||
    image: docker.io/library/postgres:17
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    volumes:
 | 
			
		||||
      - pgdata:/var/lib/postgresql/data
 | 
			
		||||
@@ -71,7 +71,7 @@ services:
 | 
			
		||||
      PAPERLESS_TIKA_ENDPOINT: http://tika:9998
 | 
			
		||||
 | 
			
		||||
  gotenberg:
 | 
			
		||||
    image: docker.io/gotenberg/gotenberg:8.7
 | 
			
		||||
    image: docker.io/gotenberg/gotenberg:8.17
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
 | 
			
		||||
    # The gotenberg chromium route is used to convert .eml files. We do not
 | 
			
		||||
 
 | 
			
		||||
@@ -34,7 +34,7 @@ services:
 | 
			
		||||
      - redisdata:/data
 | 
			
		||||
 | 
			
		||||
  db:
 | 
			
		||||
    image: docker.io/library/postgres:16
 | 
			
		||||
    image: docker.io/library/postgres:17
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    volumes:
 | 
			
		||||
      - pgdata:/var/lib/postgresql/data
 | 
			
		||||
 
 | 
			
		||||
@@ -59,7 +59,7 @@ services:
 | 
			
		||||
      PAPERLESS_TIKA_ENDPOINT: http://tika:9998
 | 
			
		||||
 | 
			
		||||
  gotenberg:
 | 
			
		||||
    image: docker.io/gotenberg/gotenberg:8.7
 | 
			
		||||
    image: docker.io/gotenberg/gotenberg:8.17
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
 | 
			
		||||
    # The gotenberg chromium route is used to convert .eml files. We do not
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,18 @@
 | 
			
		||||
#!/command/with-contenv /usr/bin/bash
 | 
			
		||||
# shellcheck shell=bash
 | 
			
		||||
 | 
			
		||||
cd ${PAPERLESS_SRC_DIR}
 | 
			
		||||
 | 
			
		||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
 | 
			
		||||
	exec python3 manage.py document_consumer
 | 
			
		||||
if [[ -n "${PAPERLESS_CONSUMER_DISABLE}" ]]; then
 | 
			
		||||
	echo "[svc-consumer] Consumer is disabled, exiting"
 | 
			
		||||
	# https://skarnet.org/software/s6/s6-svc.html
 | 
			
		||||
	s6-svc -Od .
 | 
			
		||||
 | 
			
		||||
else
 | 
			
		||||
	cd ${PAPERLESS_SRC_DIR}
 | 
			
		||||
 | 
			
		||||
	if [[ -n "${USER_IS_NON_ROOT}" ]]; then
 | 
			
		||||
		exec python3 manage.py document_consumer
 | 
			
		||||
	else
 | 
			
		||||
		exec s6-setuidgid paperless python3 manage.py document_consumer
 | 
			
		||||
	fi
 | 
			
		||||
fi
 | 
			
		||||
 
 | 
			
		||||
@@ -3,8 +3,18 @@
 | 
			
		||||
 | 
			
		||||
cd ${PAPERLESS_SRC_DIR}
 | 
			
		||||
 | 
			
		||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
 | 
			
		||||
	exec /usr/local/bin/gunicorn -c /usr/src/paperless/gunicorn.conf.py paperless.asgi:application
 | 
			
		||||
else
 | 
			
		||||
	exec s6-setuidgid paperless /usr/local/bin/gunicorn -c /usr/src/paperless/gunicorn.conf.py paperless.asgi:application
 | 
			
		||||
# Translate between things, preferring GRANIAN_
 | 
			
		||||
export GRANIAN_HOST=${GRANIAN_HOST:-${PAPERLESS_BIND_ADDR:-"::"}}
 | 
			
		||||
export GRANIAN_PORT=${GRANIAN_PORT:-${PAPERLESS_PORT:-8000}}
 | 
			
		||||
export GRANIAN_WORKERS=${GRANIAN_WORKERS:-${PAPERLESS_WEBSERVER_WORKERS:-1}}
 | 
			
		||||
 | 
			
		||||
# Only set GRANIAN_URL_PATH_PREFIX if PAPERLESS_FORCE_SCRIPT_NAME is set
 | 
			
		||||
if [[ -n "${PAPERLESS_FORCE_SCRIPT_NAME}" ]]; then
 | 
			
		||||
  export GRANIAN_URL_PATH_PREFIX=${PAPERLESS_FORCE_SCRIPT_NAME}
 | 
			
		||||
fi
 | 
			
		||||
 | 
			
		||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
 | 
			
		||||
  exec granian --interface asginl --ws "paperless.asgi:application"
 | 
			
		||||
else
 | 
			
		||||
  exec s6-setuidgid paperless granian --interface asginl --ws "paperless.asgi:application"
 | 
			
		||||
fi
 | 
			
		||||
 
 | 
			
		||||
@@ -509,6 +509,12 @@ Invoice_{{ custom_fields|get_cf_value("Select Field") }}_{{ custom_fields|get_cf
 | 
			
		||||
 | 
			
		||||
This will create a path like `invoices/2022/01/01/Invoice_OptionTwo_20220101.pdf` if the custom field "Date Field" is set to January 1, 2022 and "Select Field" is set to `OptionTwo`.
 | 
			
		||||
 | 
			
		||||
You can also use a custom `slugify` filter to slufigy text:
 | 
			
		||||
 | 
			
		||||
```jinja
 | 
			
		||||
{{ title | slugify }}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Automatic recovery of invalid PDFs {#pdf-recovery}
 | 
			
		||||
 | 
			
		||||
Paperless will attempt to "clean" certain invalid PDFs with `qpdf` before processing if, for example, the mime_type
 | 
			
		||||
 
 | 
			
		||||
@@ -557,6 +557,20 @@ This is for use with self-signed certificates against local IMAP servers.
 | 
			
		||||
    Settings this value has security implications for the security of your email.
 | 
			
		||||
    Understand what it does and be sure you need to before setting.
 | 
			
		||||
 | 
			
		||||
### Authentication & SSO {#authentication}
 | 
			
		||||
 | 
			
		||||
#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS}
 | 
			
		||||
 | 
			
		||||
: Allow users to signup for a new Paperless-ngx account.
 | 
			
		||||
 | 
			
		||||
    Defaults to False
 | 
			
		||||
 | 
			
		||||
#### [`PAPERLESS_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_ACCOUNT_DEFAULT_GROUPS}
 | 
			
		||||
 | 
			
		||||
: A list of group names that users will be added to when they sign up for a new account. Groups listed here must already exist.
 | 
			
		||||
 | 
			
		||||
    Defaults to None
 | 
			
		||||
 | 
			
		||||
#### [`PAPERLESS_SOCIALACCOUNT_PROVIDERS=<json>`](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) {#PAPERLESS_SOCIALACCOUNT_PROVIDERS}
 | 
			
		||||
 | 
			
		||||
: This variable is used to setup login and signup via social account providers which are compatible with django-allauth.
 | 
			
		||||
@@ -580,12 +594,25 @@ system. See the corresponding
 | 
			
		||||
 | 
			
		||||
    Defaults to True
 | 
			
		||||
 | 
			
		||||
#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS}
 | 
			
		||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS=<bool>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS}
 | 
			
		||||
 | 
			
		||||
: Allow users to signup for a new Paperless-ngx account.
 | 
			
		||||
: Sync groups from the third party authentication system (e.g. OIDC) to Paperless-ngx. When enabled, users will be added or removed from groups based on their group membership in the third party authentication system. Groups must already exist in Paperless-ngx and have the same name as in the third party authentication system. Groups are updated upon logging in via the third party authentication system, see the corresponding [django-allauth documentation](https://docs.allauth.org/en/dev/socialaccount/signals.html).
 | 
			
		||||
 | 
			
		||||
: In order to pass groups from the authentication system you will need to update your [PAPERLESS_SOCIALACCOUNT_PROVIDERS](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) setting by adding a top-level "SCOPES" setting which includes "groups", e.g.:
 | 
			
		||||
 | 
			
		||||
    ```json
 | 
			
		||||
    {"openid_connect":{"SCOPE": ["openid","profile","email","groups"]...
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
    Defaults to False
 | 
			
		||||
 | 
			
		||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
 | 
			
		||||
 | 
			
		||||
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
 | 
			
		||||
If both the [PAPERLESS_ACCOUNT_DEFAULT_GROUPS](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS) setting and this setting are used, the user will be added to both sets of groups.
 | 
			
		||||
 | 
			
		||||
    Defaults to None
 | 
			
		||||
 | 
			
		||||
#### [`PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL=<string>`](#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL) {#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL}
 | 
			
		||||
 | 
			
		||||
: The protocol used when generating URLs, e.g. login callback URLs. See the corresponding
 | 
			
		||||
@@ -1030,6 +1057,11 @@ be used with caution!
 | 
			
		||||
 | 
			
		||||
## Document Consumption {#consume_config}
 | 
			
		||||
 | 
			
		||||
#### [`PAPERLESS_CONSUMER_DISABLE=<bool>`](#PAPERLESS_CONSUMER_DISABLE) {#PAPERLESS_CONSUMER_DISABLE}
 | 
			
		||||
 | 
			
		||||
: Completely disable the directory-based consumer in docker. If you don't plan to consume documents
 | 
			
		||||
via the consumption directory, you can disable the consumer to save resources.
 | 
			
		||||
 | 
			
		||||
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}
 | 
			
		||||
 | 
			
		||||
: When the consumer detects a duplicate document, it will not touch
 | 
			
		||||
@@ -1506,13 +1538,23 @@ increase RAM usage.
 | 
			
		||||
 | 
			
		||||
    Defaults to 1.
 | 
			
		||||
 | 
			
		||||
    !!! note
 | 
			
		||||
 | 
			
		||||
         This option may also be set with `GRANIAN_WORKERS` and
 | 
			
		||||
         this option may be removed in the future
 | 
			
		||||
 | 
			
		||||
#### [`PAPERLESS_BIND_ADDR=<ip address>`](#PAPERLESS_BIND_ADDR) {#PAPERLESS_BIND_ADDR}
 | 
			
		||||
 | 
			
		||||
: The IP address the webserver will listen on inside the container.
 | 
			
		||||
There are special setups where you may need to configure this value
 | 
			
		||||
to restrict the Ip address or interface the webserver listens on.
 | 
			
		||||
 | 
			
		||||
    Defaults to `[::]`, meaning all interfaces, including IPv6.
 | 
			
		||||
    Defaults to `::`, meaning all interfaces, including IPv6.
 | 
			
		||||
 | 
			
		||||
    !!! note
 | 
			
		||||
 | 
			
		||||
         This option may also be set with `GRANIAN_HOST` and
 | 
			
		||||
         this option may be removed in the future
 | 
			
		||||
 | 
			
		||||
#### [`PAPERLESS_PORT=<port>`](#PAPERLESS_PORT) {#PAPERLESS_PORT}
 | 
			
		||||
 | 
			
		||||
@@ -1527,6 +1569,11 @@ one pod).
 | 
			
		||||
 | 
			
		||||
    Defaults to 8000.
 | 
			
		||||
 | 
			
		||||
    !!! note
 | 
			
		||||
 | 
			
		||||
         This option may also be set with `GRANIAN_PORT` and
 | 
			
		||||
         this option may be removed in the future
 | 
			
		||||
 | 
			
		||||
#### [`USERMAP_UID=<uid>`](#USERMAP_UID) {#USERMAP_UID}
 | 
			
		||||
 | 
			
		||||
: The ID of the paperless user in the container. Set this to your
 | 
			
		||||
 
 | 
			
		||||
@@ -60,7 +60,7 @@ first-time setup.
 | 
			
		||||
 | 
			
		||||
      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).
 | 
			
		||||
 | 
			
		||||
2.  Copy `paperless.conf.example` to `paperless.conf` and enable debug
 | 
			
		||||
@@ -75,17 +75,13 @@ first-time setup.
 | 
			
		||||
4.  Install the Python dependencies:
 | 
			
		||||
 | 
			
		||||
    ```bash
 | 
			
		||||
    pipenv install --dev
 | 
			
		||||
    $ uv sync --group dev
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
    !!! note
 | 
			
		||||
 | 
			
		||||
        Using a virtual environment is highly recommended. You can spawn one via `pipenv shell`.
 | 
			
		||||
 | 
			
		||||
5.  Install pre-commit hooks:
 | 
			
		||||
 | 
			
		||||
    ```bash
 | 
			
		||||
    pre-commit install
 | 
			
		||||
    $ uv run pre-commit install
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
6.  Apply migrations and create a superuser for your development instance:
 | 
			
		||||
@@ -93,8 +89,8 @@ first-time setup.
 | 
			
		||||
    ```bash
 | 
			
		||||
    # src/
 | 
			
		||||
 | 
			
		||||
    python3 manage.py migrate
 | 
			
		||||
    python3 manage.py createsuperuser
 | 
			
		||||
    $ uv run manage.py migrate
 | 
			
		||||
    $ uv run manage.py createsuperuser
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
7.  You can now either ...
 | 
			
		||||
@@ -144,7 +140,7 @@ To build the front end once use this command:
 | 
			
		||||
```bash
 | 
			
		||||
# src-ui/
 | 
			
		||||
 | 
			
		||||
$ npm install
 | 
			
		||||
$ pnpm install
 | 
			
		||||
$ ng build --configuration production
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
@@ -164,10 +160,23 @@ $ ng build --configuration production
 | 
			
		||||
      complicated IF cases. Append `# noqa: E501` to disable this check
 | 
			
		||||
      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
 | 
			
		||||
 | 
			
		||||
The front end is built using AngularJS. In order to get started, you need Node.js (version 14.15+) and
 | 
			
		||||
`npm`.
 | 
			
		||||
`pnpm`.
 | 
			
		||||
 | 
			
		||||
!!! note
 | 
			
		||||
 | 
			
		||||
@@ -176,7 +185,7 @@ The front end is built using AngularJS. In order to get started, you need Node.j
 | 
			
		||||
1.  Install the Angular CLI. You might need sudo privileges to perform this command:
 | 
			
		||||
 | 
			
		||||
    ```bash
 | 
			
		||||
    npm install -g @angular/cli
 | 
			
		||||
    pnpm install -g @angular/cli
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
2.  Make sure that it's on your path.
 | 
			
		||||
@@ -184,7 +193,7 @@ The front end is built using AngularJS. In order to get started, you need Node.j
 | 
			
		||||
3.  Install all necessary modules:
 | 
			
		||||
 | 
			
		||||
    ```bash
 | 
			
		||||
    npm install
 | 
			
		||||
    pnpm install
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
4.  You can launch a development server by running:
 | 
			
		||||
@@ -198,7 +207,7 @@ The front end is built using AngularJS. In order to get started, you need Node.j
 | 
			
		||||
    restart it.
 | 
			
		||||
 | 
			
		||||
    By default, the development server is available on `http://localhost:4200/` and is configured to access the API at
 | 
			
		||||
    `http://localhost:8000/api/`, which is the default of the backend. If you enabled `DEBUG` on the back end, several security overrides for allowed hosts, CORS and X-Frame-Options are in place so that the front end behaves exactly as in production.
 | 
			
		||||
    `http://localhost:8000/api/`, which is the default of the backend. If you enabled `DEBUG` on the back end, several security overrides for allowed hosts and CORS are in place so that the front end behaves exactly as in production.
 | 
			
		||||
 | 
			
		||||
### Testing and code style
 | 
			
		||||
 | 
			
		||||
@@ -332,27 +341,21 @@ LANGUAGES = [
 | 
			
		||||
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:
 | 
			
		||||
 | 
			
		||||
1.  Have an active pipenv shell (`pipenv shell`) and install Python dependencies:
 | 
			
		||||
1.  Build the documentation
 | 
			
		||||
 | 
			
		||||
    ```bash
 | 
			
		||||
    pipenv install --dev
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
2.  Build the documentation
 | 
			
		||||
 | 
			
		||||
    ```bash
 | 
			
		||||
    mkdocs build --config-file mkdocs.yml
 | 
			
		||||
    $ uv run mkdocs build --config-file mkdocs.yml
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
    _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
 | 
			
		||||
    that will automatically refresh every time you change
 | 
			
		||||
    something.
 | 
			
		||||
 | 
			
		||||
    ```bash
 | 
			
		||||
    mkdocs serve
 | 
			
		||||
    $ uv run mkdocs serve
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
## Building the Docker image
 | 
			
		||||
 
 | 
			
		||||
@@ -133,6 +133,9 @@ Multiple options for ASGI servers exist:
 | 
			
		||||
    implementation for ASGI.
 | 
			
		||||
-   `uvicorn` as a standalone server
 | 
			
		||||
 | 
			
		||||
You may also find the [Django documentation](https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/) on ASGI
 | 
			
		||||
useful to review.
 | 
			
		||||
 | 
			
		||||
## _What about the Redis licensing change and using one of the open source forks_?
 | 
			
		||||
 | 
			
		||||
Currently (October 2024), forks of Redis such as Valkey or Redirect are not officially supported by our upstream
 | 
			
		||||
 
 | 
			
		||||
@@ -380,6 +380,12 @@ are released, dependency support is confirmed, etc.
 | 
			
		||||
        dependencies.  This is an alternative to the above and may require adjusting
 | 
			
		||||
        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:
 | 
			
		||||
 | 
			
		||||
    ```bash
 | 
			
		||||
@@ -426,31 +432,20 @@ are released, dependency support is confirmed, etc.
 | 
			
		||||
 | 
			
		||||
    !!! note
 | 
			
		||||
 | 
			
		||||
        The `socket` script enables `gunicorn` to run on port 80 without
 | 
			
		||||
        The `socket` script enables `granian` to run on port 80 without
 | 
			
		||||
        root privileges. For this you need to uncomment the
 | 
			
		||||
        `Require=paperless-webserver.socket` in the `webserver` script
 | 
			
		||||
        and configure `gunicorn` to listen on port 80 (see
 | 
			
		||||
        `paperless/gunicorn.conf.py`).
 | 
			
		||||
 | 
			
		||||
    You may need to adjust the path to the `gunicorn` executable. This
 | 
			
		||||
    will be installed as part of the python dependencies, and is either
 | 
			
		||||
    located in the `bin` folder of your virtual environment, or in
 | 
			
		||||
    `~/.local/bin/` if no virtual environment is used.
 | 
			
		||||
        and configure `granian` to listen on port 80 (set `GRANIAN_PORT`).
 | 
			
		||||
 | 
			
		||||
    These services rely on redis and optionally the database server, but
 | 
			
		||||
    don't need to be started in any particular order. The example files
 | 
			
		||||
    depend on redis being started. If you use a database server, you
 | 
			
		||||
    should add additional dependencies.
 | 
			
		||||
 | 
			
		||||
    !!! warning
 | 
			
		||||
    !!! note
 | 
			
		||||
 | 
			
		||||
        The included scripts run a `gunicorn` standalone server, which is
 | 
			
		||||
        fine for running paperless. It does support SSL, however, the
 | 
			
		||||
        documentation of GUnicorn states that you should use a proxy server
 | 
			
		||||
        in front of gunicorn instead.
 | 
			
		||||
 | 
			
		||||
        For instructions on how to use nginx for that,
 | 
			
		||||
        [see the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#nginx).
 | 
			
		||||
        For instructions on using a reverse proxy,
 | 
			
		||||
        [see the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#).
 | 
			
		||||
 | 
			
		||||
    !!! warning
 | 
			
		||||
 | 
			
		||||
@@ -714,6 +709,8 @@ the Pi and configuring some options in paperless can help improve
 | 
			
		||||
performance immensely:
 | 
			
		||||
 | 
			
		||||
-   Stick with SQLite to save some resources.
 | 
			
		||||
-   If you do not need the filesystem-based consumer, consider disabling it
 | 
			
		||||
    entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`.
 | 
			
		||||
-   Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that paperless will
 | 
			
		||||
    only OCR the first page of your documents. In most cases, this page
 | 
			
		||||
    contains enough information to be able to find it.
 | 
			
		||||
 
 | 
			
		||||
@@ -195,34 +195,6 @@ This might have multiple reasons.
 | 
			
		||||
    is not, you need to compile the front end yourself or download the
 | 
			
		||||
    release archive instead of cloning the repository.
 | 
			
		||||
 | 
			
		||||
2.  Check the output of the web server. You might see errors like this:
 | 
			
		||||
 | 
			
		||||
    ```
 | 
			
		||||
    [2021-01-25 10:08:04 +0000] [40] [ERROR] Socket error processing request.
 | 
			
		||||
    Traceback (most recent call last):
 | 
			
		||||
    File "/usr/local/lib/python3.7/site-packages/gunicorn/workers/sync.py", line 134, in handle
 | 
			
		||||
        self.handle_request(listener, req, client, addr)
 | 
			
		||||
    File "/usr/local/lib/python3.7/site-packages/gunicorn/workers/sync.py", line 190, in handle_request
 | 
			
		||||
        util.reraise(*sys.exc_info())
 | 
			
		||||
    File "/usr/local/lib/python3.7/site-packages/gunicorn/util.py", line 625, in reraise
 | 
			
		||||
        raise value
 | 
			
		||||
    File "/usr/local/lib/python3.7/site-packages/gunicorn/workers/sync.py", line 178, in handle_request
 | 
			
		||||
        resp.write_file(respiter)
 | 
			
		||||
    File "/usr/local/lib/python3.7/site-packages/gunicorn/http/wsgi.py", line 396, in write_file
 | 
			
		||||
        if not self.sendfile(respiter):
 | 
			
		||||
    File "/usr/local/lib/python3.7/site-packages/gunicorn/http/wsgi.py", line 386, in sendfile
 | 
			
		||||
        sent += os.sendfile(sockno, fileno, offset + sent, count)
 | 
			
		||||
    OSError: [Errno 22] Invalid argument
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
    To fix this issue, add
 | 
			
		||||
 | 
			
		||||
    ```
 | 
			
		||||
    SENDFILE=0
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
    to your `docker-compose.env` file.
 | 
			
		||||
 | 
			
		||||
## Error while reading metadata
 | 
			
		||||
 | 
			
		||||
You might find messages like these in your log files:
 | 
			
		||||
@@ -322,12 +294,12 @@ many documents at once often. Otherwise, try tweaking the
 | 
			
		||||
[`PAPERLESS_DB_TIMEOUT`](configuration.md#PAPERLESS_DB_TIMEOUT) setting to allow more time for the database to
 | 
			
		||||
unlock. This may have minor performance implications.
 | 
			
		||||
 | 
			
		||||
## gunicorn fails to start with "is not a valid port number"
 | 
			
		||||
## granian fails to start with "is not a valid port number"
 | 
			
		||||
 | 
			
		||||
You are likely running using Kubernetes, which automatically creates an
 | 
			
		||||
environment variable named `${serviceName}_PORT`. This is
 | 
			
		||||
the same environment variable which is used by Paperless to optionally
 | 
			
		||||
change the port gunicorn listens on.
 | 
			
		||||
change the port granian listens on.
 | 
			
		||||
 | 
			
		||||
To fix this, set [`PAPERLESS_PORT`](configuration.md#PAPERLESS_PORT) again to your desired port, or the
 | 
			
		||||
default of 8000.
 | 
			
		||||
 
 | 
			
		||||
@@ -837,7 +837,7 @@ Paperless-ngx consists of the following components:
 | 
			
		||||
 | 
			
		||||
    ```shell-session
 | 
			
		||||
    cd /path/to/paperless/src/
 | 
			
		||||
    gunicorn -c ../gunicorn.conf.py paperless.wsgi
 | 
			
		||||
    granian --interface asginl --ws "paperless.asgi:application"
 | 
			
		||||
    ```
 | 
			
		||||
 | 
			
		||||
    or by any other means such as Apache `mod_wsgi`.
 | 
			
		||||
 
 | 
			
		||||
@@ -1,49 +0,0 @@
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
# See https://docs.gunicorn.org/en/stable/settings.html for
 | 
			
		||||
# explanations of settings
 | 
			
		||||
 | 
			
		||||
bind = f"{os.getenv('PAPERLESS_BIND_ADDR', '[::]')}:{os.getenv('PAPERLESS_PORT', 8000)}"
 | 
			
		||||
 | 
			
		||||
workers = int(os.getenv("PAPERLESS_WEBSERVER_WORKERS", 1))
 | 
			
		||||
worker_class = "paperless.workers.ConfigurableWorker"
 | 
			
		||||
timeout = 120
 | 
			
		||||
preload_app = True
 | 
			
		||||
 | 
			
		||||
# https://docs.gunicorn.org/en/stable/faq.html#blocking-os-fchmod
 | 
			
		||||
worker_tmp_dir = "/dev/shm"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def pre_fork(server, worker):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def pre_exec(server):
 | 
			
		||||
    server.log.info("Forked child, re-executing.")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def when_ready(server):
 | 
			
		||||
    server.log.info("Server is ready. Spawning workers")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def worker_int(worker):
 | 
			
		||||
    worker.log.info("worker received INT or QUIT signal")
 | 
			
		||||
 | 
			
		||||
    ## get traceback info
 | 
			
		||||
    import sys
 | 
			
		||||
    import threading
 | 
			
		||||
    import traceback
 | 
			
		||||
 | 
			
		||||
    id2name = {th.ident: th.name for th in threading.enumerate()}
 | 
			
		||||
    code = []
 | 
			
		||||
    for threadId, stack in sys._current_frames().items():
 | 
			
		||||
        code.append(f"\n# Thread: {id2name.get(threadId, '')}({threadId})")
 | 
			
		||||
        for filename, lineno, name, line in traceback.extract_stack(stack):
 | 
			
		||||
            code.append(f'File: "{filename}", line {lineno}, in {name}')
 | 
			
		||||
            if line:
 | 
			
		||||
                code.append(f"  {line.strip()}")
 | 
			
		||||
    worker.log.debug("\n".join(code))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def worker_abort(worker):
 | 
			
		||||
    worker.log.info("worker received SIGABRT signal")
 | 
			
		||||
							
								
								
									
										355
									
								
								pyproject.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										355
									
								
								pyproject.toml
									
									
									
									
									
										Normal 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.3.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.10.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.5",
 | 
			
		||||
  # Direct dependency for proper resolution of the pre-built wheels
 | 
			
		||||
  "psycopg-c==3.2.5",
 | 
			
		||||
]
 | 
			
		||||
optional-dependencies.webserver = [
 | 
			
		||||
  "granian~=2.0.1",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[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.5/psycopg_c-3.2.5-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.5/psycopg_c-3.2.5-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"
 | 
			
		||||
@@ -9,7 +9,21 @@ Requires=redis.service
 | 
			
		||||
User=paperless
 | 
			
		||||
Group=paperless
 | 
			
		||||
WorkingDirectory=/opt/paperless/src
 | 
			
		||||
ExecStart=/opt/paperless/.local/bin/gunicorn -c /opt/paperless/gunicorn.conf.py paperless.asgi:application
 | 
			
		||||
 | 
			
		||||
Environment=GRANIAN_HOST=::
 | 
			
		||||
Environment=GRANIAN_PORT=8000
 | 
			
		||||
Environment=GRANIAN_WORKERS=1
 | 
			
		||||
 | 
			
		||||
ExecStart=/bin/sh -c '\
 | 
			
		||||
  # Host: GRANIAN_HOST -> PAPERLESS_BIND_ADDR -> default \
 | 
			
		||||
  [ -n "$PAPERLESS_BIND_ADDR" ] && export GRANIAN_HOST=$PAPERLESS_BIND_ADDR; \
 | 
			
		||||
  # Port: GRANIAN_PORT -> PAPERLESS_PORT -> default \
 | 
			
		||||
  [ -n "$PAPERLESS_PORT" ] && export GRANIAN_PORT=$PAPERLESS_PORT; \
 | 
			
		||||
  # Workers: GRANIAN_WORKERS -> PAPERLESS_WEBSERVER_WORKERS -> default \
 | 
			
		||||
  [ -n "$PAPERLESS_WEBSERVER_WORKERS" ] && export GRANIAN_WORKERS=$PAPERLESS_WEBSERVER_WORKERS; \
 | 
			
		||||
  # URL path prefix: only set if PAPERLESS_FORCE_SCRIPT_NAME exists \
 | 
			
		||||
  [ -n "$PAPERLESS_FORCE_SCRIPT_NAME" ] && export GRANIAN_URL_PATH_PREFIX=$PAPERLESS_FORCE_SCRIPT_NAME; \
 | 
			
		||||
  exec granian --interface asginl --ws "paperless.asgi:application"'
 | 
			
		||||
 | 
			
		||||
[Install]
 | 
			
		||||
WantedBy=multi-user.target
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								src-ui/.npmrc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src-ui/.npmrc
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
shamefully-hoist=true
 | 
			
		||||
@@ -178,7 +178,8 @@
 | 
			
		||||
    "schematicCollections": [
 | 
			
		||||
      "@angular-eslint/schematics"
 | 
			
		||||
    ],
 | 
			
		||||
    "analytics": false
 | 
			
		||||
    "analytics": false,
 | 
			
		||||
    "packageManager": "pnpm"
 | 
			
		||||
  },
 | 
			
		||||
  "schematics": {
 | 
			
		||||
    "@angular-eslint/schematics:application": {
 | 
			
		||||
 
 | 
			
		||||
@@ -83,10 +83,17 @@ test('date filtering', async ({ page }) => {
 | 
			
		||||
  await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
 | 
			
		||||
  await page.goto('/documents')
 | 
			
		||||
  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 page.getByRole('menuitem', { name: 'Within 3 months' }).first().click()
 | 
			
		||||
  await page.getByLabel('Datesselected').getByRole('button').first().click()
 | 
			
		||||
  await page
 | 
			
		||||
    .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 year' }).selectOption('2022')
 | 
			
		||||
  await page.getByText('11', { exact: true }).click()
 | 
			
		||||
 
 | 
			
		||||
@@ -7,9 +7,20 @@ module.exports = {
 | 
			
		||||
    'abstract-name-filter-service',
 | 
			
		||||
    'abstract-paperless-service',
 | 
			
		||||
  ],
 | 
			
		||||
  transformIgnorePatterns: [`<rootDir>/node_modules/(?!.*\\.mjs$|lodash-es)`],
 | 
			
		||||
  transformIgnorePatterns: [
 | 
			
		||||
    `<rootDir>/node_modules/.pnpm/(?!.*\\.mjs$|lodash-es)`,
 | 
			
		||||
  ],
 | 
			
		||||
  moduleNameMapper: {
 | 
			
		||||
    '^src/(.*)': '<rootDir>/src/$1',
 | 
			
		||||
  },
 | 
			
		||||
  workerIdleMemoryLimit: '512MB',
 | 
			
		||||
  reporters: [
 | 
			
		||||
    'default',
 | 
			
		||||
    [
 | 
			
		||||
      'jest-junit',
 | 
			
		||||
      {
 | 
			
		||||
        classNameTemplate: '{filepath}/{classname}: {title}',
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
  ],
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1078
									
								
								src-ui/messages.xlf
									
									
									
									
									
								
							
							
						
						
									
										1078
									
								
								src-ui/messages.xlf
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										19090
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										19090
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -2,6 +2,7 @@
 | 
			
		||||
  "name": "paperless-ui",
 | 
			
		||||
  "version": "0.0.0",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "preinstall": "npx only-allow pnpm",
 | 
			
		||||
    "ng": "ng",
 | 
			
		||||
    "start": "ng serve",
 | 
			
		||||
    "build": "ng build",
 | 
			
		||||
@@ -11,17 +12,17 @@
 | 
			
		||||
  },
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@angular/cdk": "^19.1.2",
 | 
			
		||||
    "@angular/common": "~19.1.4",
 | 
			
		||||
    "@angular/compiler": "~19.1.4",
 | 
			
		||||
    "@angular/core": "~19.1.4",
 | 
			
		||||
    "@angular/forms": "~19.1.4",
 | 
			
		||||
    "@angular/localize": "~19.1.4",
 | 
			
		||||
    "@angular/platform-browser": "~19.1.4",
 | 
			
		||||
    "@angular/platform-browser-dynamic": "~19.1.4",
 | 
			
		||||
    "@angular/router": "~19.1.4",
 | 
			
		||||
    "@angular/cdk": "^19.2.2",
 | 
			
		||||
    "@angular/common": "~19.2.1",
 | 
			
		||||
    "@angular/compiler": "~19.2.1",
 | 
			
		||||
    "@angular/core": "~19.2.1",
 | 
			
		||||
    "@angular/forms": "~19.2.1",
 | 
			
		||||
    "@angular/localize": "~19.2.1",
 | 
			
		||||
    "@angular/platform-browser": "~19.2.1",
 | 
			
		||||
    "@angular/platform-browser-dynamic": "~19.2.1",
 | 
			
		||||
    "@angular/router": "~19.2.1",
 | 
			
		||||
    "@ng-bootstrap/ng-bootstrap": "^18.0.0",
 | 
			
		||||
    "@ng-select/ng-select": "^14.2.0",
 | 
			
		||||
    "@ng-select/ng-select": "^14.2.3",
 | 
			
		||||
    "@ngneat/dirty-check-forms": "^3.0.3",
 | 
			
		||||
    "@popperjs/core": "^2.11.8",
 | 
			
		||||
    "bootstrap": "^5.3.3",
 | 
			
		||||
@@ -29,46 +30,56 @@
 | 
			
		||||
    "mime-names": "^1.0.0",
 | 
			
		||||
    "ng2-pdf-viewer": "^10.4.0",
 | 
			
		||||
    "ngx-bootstrap-icons": "^1.9.3",
 | 
			
		||||
    "ngx-color": "^9.0.0",
 | 
			
		||||
    "ngx-cookie-service": "^19.1.0",
 | 
			
		||||
    "ngx-color": "^10.0.0",
 | 
			
		||||
    "ngx-cookie-service": "^19.1.2",
 | 
			
		||||
    "ngx-device-detector": "^9.0.0",
 | 
			
		||||
    "ngx-file-drop": "^16.0.0",
 | 
			
		||||
    "ngx-ui-tour-ng-bootstrap": "^16.0.0",
 | 
			
		||||
    "rxjs": "^7.8.1",
 | 
			
		||||
    "rxjs": "^7.8.2",
 | 
			
		||||
    "tslib": "^2.8.1",
 | 
			
		||||
    "utif": "^3.1.0",
 | 
			
		||||
    "uuid": "^11.0.5",
 | 
			
		||||
    "uuid": "^11.1.0",
 | 
			
		||||
    "zone.js": "^0.15.0"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@angular-builders/custom-webpack": "^19.0.0",
 | 
			
		||||
    "@angular-builders/jest": "^19.0.0",
 | 
			
		||||
    "@angular-devkit/build-angular": "^19.0.4",
 | 
			
		||||
    "@angular-devkit/core": "^19.1.5",
 | 
			
		||||
    "@angular-devkit/schematics": "^19.1.5",
 | 
			
		||||
    "@angular-eslint/builder": "19.0.2",
 | 
			
		||||
    "@angular-eslint/eslint-plugin": "19.0.2",
 | 
			
		||||
    "@angular-eslint/eslint-plugin-template": "19.0.2",
 | 
			
		||||
    "@angular-eslint/schematics": "19.0.2",
 | 
			
		||||
    "@angular-eslint/template-parser": "19.0.2",
 | 
			
		||||
    "@angular/cli": "~19.1.5",
 | 
			
		||||
    "@angular/compiler-cli": "~19.1.4",
 | 
			
		||||
    "@codecov/webpack-plugin": "^1.8.0",
 | 
			
		||||
    "@angular-devkit/build-angular": "^19.2.1",
 | 
			
		||||
    "@angular-devkit/core": "^19.2.1",
 | 
			
		||||
    "@angular-devkit/schematics": "^19.2.1",
 | 
			
		||||
    "@angular-eslint/builder": "19.2.1",
 | 
			
		||||
    "@angular-eslint/eslint-plugin": "19.2.1",
 | 
			
		||||
    "@angular-eslint/eslint-plugin-template": "19.2.1",
 | 
			
		||||
    "@angular-eslint/schematics": "19.2.1",
 | 
			
		||||
    "@angular-eslint/template-parser": "19.2.1",
 | 
			
		||||
    "@angular/cli": "~19.2.1",
 | 
			
		||||
    "@angular/compiler-cli": "~19.2.1",
 | 
			
		||||
    "@codecov/webpack-plugin": "^1.9.0",
 | 
			
		||||
    "@playwright/test": "^1.50.1",
 | 
			
		||||
    "@types/jest": "^29.5.14",
 | 
			
		||||
    "@types/node": "^22.13.0",
 | 
			
		||||
    "@typescript-eslint/eslint-plugin": "^8.22.0",
 | 
			
		||||
    "@typescript-eslint/parser": "^8.22.0",
 | 
			
		||||
    "@typescript-eslint/utils": "^8.0.0",
 | 
			
		||||
    "eslint": "^9.19.0",
 | 
			
		||||
    "@types/node": "^22.13.9",
 | 
			
		||||
    "@typescript-eslint/eslint-plugin": "^8.26.1",
 | 
			
		||||
    "@typescript-eslint/parser": "^8.26.1",
 | 
			
		||||
    "@typescript-eslint/utils": "^8.26.1",
 | 
			
		||||
    "eslint": "^9.22.0",
 | 
			
		||||
    "jest": "29.7.0",
 | 
			
		||||
    "jest-environment-jsdom": "^29.7.0",
 | 
			
		||||
    "jest-preset-angular": "^14.4.2",
 | 
			
		||||
    "jest-junit": "^16.0.0",
 | 
			
		||||
    "jest-preset-angular": "^14.5.3",
 | 
			
		||||
    "jest-websocket-mock": "^2.5.0",
 | 
			
		||||
    "patch-package": "^8.0.0",
 | 
			
		||||
    "prettier-plugin-organize-imports": "^4.1.0",
 | 
			
		||||
    "ts-node": "~10.9.1",
 | 
			
		||||
    "typescript": "^5.5.4"
 | 
			
		||||
  },
 | 
			
		||||
  "pnpm": {
 | 
			
		||||
    "onlyBuiltDependencies": [
 | 
			
		||||
      "@parcel/watcher",
 | 
			
		||||
      "canvas",
 | 
			
		||||
      "esbuild",
 | 
			
		||||
      "lmdb",
 | 
			
		||||
      "msgpackr-extract"
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  "typings": "./src/typings.d.ts"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -21,7 +21,7 @@ export default defineConfig({
 | 
			
		||||
  /* Run your local dev server before starting the tests */
 | 
			
		||||
  webServer: {
 | 
			
		||||
    port,
 | 
			
		||||
    command: 'npm run start',
 | 
			
		||||
    command: 'pnpm run start',
 | 
			
		||||
    reuseExistingServer: !process.env.CI,
 | 
			
		||||
    timeout: 2 * 60 * 1000,
 | 
			
		||||
  },
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12447
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										12447
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -36,7 +36,13 @@ export const routes: Routes = [
 | 
			
		||||
    component: AppFrameComponent,
 | 
			
		||||
    canDeactivate: [DirtyDocGuard],
 | 
			
		||||
    children: [
 | 
			
		||||
      { path: 'dashboard', component: DashboardComponent },
 | 
			
		||||
      {
 | 
			
		||||
        path: 'dashboard',
 | 
			
		||||
        component: DashboardComponent,
 | 
			
		||||
        data: {
 | 
			
		||||
          componentName: 'AppFrameComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        path: 'documents',
 | 
			
		||||
        component: DocumentListComponent,
 | 
			
		||||
@@ -47,6 +53,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.View,
 | 
			
		||||
            type: PermissionType.Document,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'DocumentListComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -59,6 +66,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.View,
 | 
			
		||||
            type: PermissionType.SavedView,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'DocumentListComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -70,6 +78,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.View,
 | 
			
		||||
            type: PermissionType.Document,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'DocumentDetailComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -81,6 +90,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.View,
 | 
			
		||||
            type: PermissionType.Document,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'DocumentDetailComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -92,6 +102,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.View,
 | 
			
		||||
            type: PermissionType.Document,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'DocumentAsnComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -103,6 +114,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.View,
 | 
			
		||||
            type: PermissionType.Tag,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'TagListComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -114,6 +126,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.View,
 | 
			
		||||
            type: PermissionType.DocumentType,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'DocumentTypeListComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -125,6 +138,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.View,
 | 
			
		||||
            type: PermissionType.Correspondent,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'CorrespondentListComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -136,6 +150,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.View,
 | 
			
		||||
            type: PermissionType.StoragePath,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'StoragePathListComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -144,6 +159,7 @@ export const routes: Routes = [
 | 
			
		||||
        canActivate: [PermissionsGuard],
 | 
			
		||||
        data: {
 | 
			
		||||
          requireAdmin: true,
 | 
			
		||||
          componentName: 'LogsComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -155,6 +171,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.Delete,
 | 
			
		||||
            type: PermissionType.Document,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'TrashComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      // redirect old paths
 | 
			
		||||
@@ -180,6 +197,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.Change,
 | 
			
		||||
            type: PermissionType.UISettings,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'SettingsComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -192,6 +210,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.View,
 | 
			
		||||
            type: PermissionType.UISettings,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'SettingsComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -203,6 +222,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.Change,
 | 
			
		||||
            type: PermissionType.AppConfig,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'ConfigComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -214,6 +234,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.View,
 | 
			
		||||
            type: PermissionType.PaperlessTask,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'TasksComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -225,6 +246,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.View,
 | 
			
		||||
            type: PermissionType.CustomField,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'CustomFieldsComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -236,6 +258,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.View,
 | 
			
		||||
            type: PermissionType.Workflow,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'WorkflowsComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -247,6 +270,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.View,
 | 
			
		||||
            type: PermissionType.MailAccount,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'MailComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -258,6 +282,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.View,
 | 
			
		||||
            type: PermissionType.User,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'UsersAndGroupsComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
@@ -269,6 +294,7 @@ export const routes: Routes = [
 | 
			
		||||
            action: PermissionAction.View,
 | 
			
		||||
            type: PermissionType.SavedView,
 | 
			
		||||
          },
 | 
			
		||||
          componentName: 'SavedViewsComponent',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
 
 | 
			
		||||
@@ -118,7 +118,7 @@
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="row mb-3">
 | 
			
		||||
            <div class="row">
 | 
			
		||||
              <div class="col-md-3 col-form-label pt-0">
 | 
			
		||||
                <span i18n>Sidebar</span>
 | 
			
		||||
              </div>
 | 
			
		||||
@@ -129,7 +129,7 @@
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="row mb-3">
 | 
			
		||||
            <div class="row">
 | 
			
		||||
              <div class="col-md-3 col-form-label pt-0">
 | 
			
		||||
                <span i18n>Dark mode</span>
 | 
			
		||||
              </div>
 | 
			
		||||
@@ -165,7 +165,7 @@
 | 
			
		||||
                  <p i18n>
 | 
			
		||||
                    Update checking works by pinging the public GitHub API for the latest release to determine whether a new version is available. Actual updating of the app must still be performed manually.
 | 
			
		||||
                  </p>
 | 
			
		||||
                  <p>
 | 
			
		||||
                  <p class="mb-0">
 | 
			
		||||
                    <em i18n>No tracking data is collected by the app in any way.</em>
 | 
			
		||||
                  </p>
 | 
			
		||||
                </ng-template>
 | 
			
		||||
@@ -173,7 +173,7 @@
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <h5 class="mt-3" i18n>Saved Views</h5>
 | 
			
		||||
            <div class="row mb-3">
 | 
			
		||||
            <div class="row">
 | 
			
		||||
              <div class="col">
 | 
			
		||||
                <pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check>
 | 
			
		||||
              </div>
 | 
			
		||||
@@ -183,15 +183,15 @@
 | 
			
		||||
          <div class="col-xl-6 ps-xl-5">
 | 
			
		||||
            <h5 class="mt-3 mt-md-0" i18n>Document editing</h5>
 | 
			
		||||
 | 
			
		||||
            <div class="row mb-3">
 | 
			
		||||
            <div class="row">
 | 
			
		||||
              <div class="col">
 | 
			
		||||
                <pngx-input-check i18n-title title="Use PDF viewer provided by the browser" i18n-hint hint="This is usually faster for displaying large PDF documents, but it might not work on some browsers." formControlName="useNativePdfViewer"></pngx-input-check>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="row mb-3">
 | 
			
		||||
              <div class="col-2">
 | 
			
		||||
                <span i18n>Default zoom:</span>
 | 
			
		||||
            <div class="row">
 | 
			
		||||
              <div class="col-md-3 col-form-label pt-0">
 | 
			
		||||
                <span i18n>Default zoom</span>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div class="col">
 | 
			
		||||
                <select class="form-select" formControlName="pdfViewerDefaultZoom">
 | 
			
		||||
@@ -202,7 +202,7 @@
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="row mb-3">
 | 
			
		||||
            <div class="row">
 | 
			
		||||
              <div class="col">
 | 
			
		||||
                <pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check>
 | 
			
		||||
              </div>
 | 
			
		||||
@@ -214,10 +214,22 @@
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <h5 class="mt-3" i18n>Notes</h5>
 | 
			
		||||
            <div class="row mb-3">
 | 
			
		||||
            <h5 class="mt-3" i18n>Global search</h5>
 | 
			
		||||
            <div class="row">
 | 
			
		||||
              <div class="col">
 | 
			
		||||
                <pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check>
 | 
			
		||||
                <pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="row mb-3">
 | 
			
		||||
              <div class="col-md-3 col-form-label pt-0">
 | 
			
		||||
                <span i18n>Full search links to</span>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div class="col mb-3">
 | 
			
		||||
                <select class="form-select" formControlName="searchLink">
 | 
			
		||||
                  <option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
 | 
			
		||||
                  <option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
 | 
			
		||||
                </select>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
@@ -229,26 +241,10 @@
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <h5 class="mt-3" i18n>Global search</h5>
 | 
			
		||||
            <h5 class="mt-3" i18n>Notes</h5>
 | 
			
		||||
            <div class="row mb-3">
 | 
			
		||||
              <div class="col">
 | 
			
		||||
                <pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="row mb-3">
 | 
			
		||||
              <div class="col">
 | 
			
		||||
                <div class="row">
 | 
			
		||||
                  <div class="col-md-3 col-form-label pt-0">
 | 
			
		||||
                    <span i18n>Full search links to</span>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <div class="col">
 | 
			
		||||
                    <select class="form-select" formControlName="searchLink">
 | 
			
		||||
                      <option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
 | 
			
		||||
                      <option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
 | 
			
		||||
                    </select>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
@@ -267,7 +263,7 @@
 | 
			
		||||
        <div class="row mb-3">
 | 
			
		||||
          <div class="col">
 | 
			
		||||
            <p i18n>
 | 
			
		||||
            Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI
 | 
			
		||||
              Settings apply to this user account for objects (Tags, Mail Rules, etc. but not documents) created via the web UI.
 | 
			
		||||
            </p>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
@@ -307,7 +303,7 @@
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="row mb-3">
 | 
			
		||||
        <div class="row">
 | 
			
		||||
          <div class="col-md-3 col-form-label pt-0">
 | 
			
		||||
            <span i18n>Default Edit Permissions</span>
 | 
			
		||||
          </div>
 | 
			
		||||
@@ -346,7 +342,7 @@
 | 
			
		||||
 | 
			
		||||
        <h5 i18n>Document processing</h5>
 | 
			
		||||
 | 
			
		||||
        <div class="row mb-3">
 | 
			
		||||
        <div class="row">
 | 
			
		||||
          <div class="col">
 | 
			
		||||
            <pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check>
 | 
			
		||||
            <pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check>
 | 
			
		||||
 
 | 
			
		||||
@@ -325,6 +325,8 @@ describe('SettingsComponent', () => {
 | 
			
		||||
    component['systemStatus'].database.status = SystemStatusItemStatus.OK
 | 
			
		||||
    component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK
 | 
			
		||||
    component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK
 | 
			
		||||
    component['systemStatus'].tasks.sanity_check_status =
 | 
			
		||||
      SystemStatusItemStatus.OK
 | 
			
		||||
    expect(component.systemStatusHasErrors).toBeFalsy()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -164,7 +164,10 @@ export class SettingsComponent
 | 
			
		||||
      this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR ||
 | 
			
		||||
      this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR ||
 | 
			
		||||
      this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR ||
 | 
			
		||||
      this.systemStatus.tasks.classifier_status === SystemStatusItemStatus.ERROR
 | 
			
		||||
      this.systemStatus.tasks.classifier_status ===
 | 
			
		||||
        SystemStatusItemStatus.ERROR ||
 | 
			
		||||
      this.systemStatus.tasks.sanity_check_status ===
 | 
			
		||||
        SystemStatusItemStatus.ERROR
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -15,7 +15,7 @@
 | 
			
		||||
    </svg>
 | 
			
		||||
    <div class="ms-2 ms-md-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled">
 | 
			
		||||
      @if (customAppTitle?.length) {
 | 
			
		||||
        <div class="d-flex flex-column align-items-start">
 | 
			
		||||
        <div class="d-flex flex-column align-items-start custom-title">
 | 
			
		||||
          <span class="title">{{customAppTitle}}</span>
 | 
			
		||||
          <span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span>
 | 
			
		||||
        </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -244,7 +244,7 @@ main {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media screen and (max-width: 768px) {
 | 
			
		||||
@media screen and (min-width: 366px) and (max-width: 768px) {
 | 
			
		||||
  .navbar-toggler {
 | 
			
		||||
    // compensate for 2 buttons on the right
 | 
			
		||||
    margin-right: 45px;
 | 
			
		||||
@@ -257,6 +257,13 @@ main {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media screen and (max-width: 345px) {
 | 
			
		||||
  .custom-title {
 | 
			
		||||
    max-width: 110px;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:host ::ng-deep .dropdown.show .dropdown-toggle,
 | 
			
		||||
:host ::ng-deep .dropdown-toggle:hover {
 | 
			
		||||
  opacity: 0.7;
 | 
			
		||||
 
 | 
			
		||||
@@ -21,7 +21,7 @@
 | 
			
		||||
    }
 | 
			
		||||
    <div class="scroll-list">
 | 
			
		||||
      @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>
 | 
			
		||||
 
 | 
			
		||||
@@ -28,10 +28,16 @@
 | 
			
		||||
        </select>
 | 
			
		||||
    </div>
 | 
			
		||||
    <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">
 | 
			
		||||
      <label class="form-check-label" for="deleteOriginalsSwitch" i18n>Delete original documents after successful merge</label>
 | 
			
		||||
    </div>
 | 
			
		||||
    @if (!archiveFallback) {
 | 
			
		||||
      <p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
 | 
			
		||||
    }
 | 
			
		||||
</div>
 | 
			
		||||
<div class="modal-footer">
 | 
			
		||||
    <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
 | 
			
		||||
 
 | 
			
		||||
@@ -29,6 +29,7 @@ export class MergeConfirmDialogComponent
 | 
			
		||||
  implements OnInit
 | 
			
		||||
{
 | 
			
		||||
  public documentIDs: number[] = []
 | 
			
		||||
  public archiveFallback: boolean = false
 | 
			
		||||
  public deleteOriginals: boolean = false
 | 
			
		||||
  private _documents: Document[] = []
 | 
			
		||||
  get documents(): Document[] {
 | 
			
		||||
 
 | 
			
		||||
@@ -84,7 +84,7 @@ export class SplitConfirmDialogComponent
 | 
			
		||||
  addSplit() {
 | 
			
		||||
    if (this.page === this.totalPages) return
 | 
			
		||||
    this.pages.add(this.page)
 | 
			
		||||
    this.pages = new Set(Array.from(this.pages).sort())
 | 
			
		||||
    this.pages = new Set(Array.from(this.pages).sort((a, b) => a - b))
 | 
			
		||||
    this.confirmButtonEnabled = this.pages.size > 0
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -34,7 +34,7 @@ import {
 | 
			
		||||
  CustomFieldQueryElement,
 | 
			
		||||
  CustomFieldQueryExpression,
 | 
			
		||||
} 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 { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
 | 
			
		||||
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
 | 
			
		||||
@@ -183,7 +183,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
 | 
			
		||||
  public CustomFieldDataType = CustomFieldDataType
 | 
			
		||||
  public CUSTOM_FIELD_QUERY_MAX_DEPTH = CUSTOM_FIELD_QUERY_MAX_DEPTH
 | 
			
		||||
  public CUSTOM_FIELD_QUERY_MAX_ATOMS = CUSTOM_FIELD_QUERY_MAX_ATOMS
 | 
			
		||||
  public popperOptions = popperOptionsReenablePreventOverflow
 | 
			
		||||
  public popperOptions = pngxPopperOptions
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  title: string
 | 
			
		||||
 
 | 
			
		||||
@@ -1,35 +1,37 @@
 | 
			
		||||
<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">
 | 
			
		||||
    <i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs>
 | 
			
		||||
    <div class="d-none d-sm-inline"> {{title}}</div>
 | 
			
		||||
    <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
 | 
			
		||||
  </button>
 | 
			
		||||
  <div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
 | 
			
		||||
    <div class="row d-flex">
 | 
			
		||||
      <div class="col border-end">
 | 
			
		||||
        <div class="list-group list-group-flush">
 | 
			
		||||
    <h6 class="dropdown-header border-bottom" i18n>Created</h6>
 | 
			
		||||
          @for (rd of relativeDates; track rd) {
 | 
			
		||||
            <button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setCreatedRelativeDate(rd.id)">
 | 
			
		||||
    <div class="list-group list-group-flush">
 | 
			
		||||
      <div class="list-group-item d-flex p-2 select-item" role="menuitem">
 | 
			
		||||
        <div class="selected-icon">
 | 
			
		||||
                @if (createdRelativeDate === rd.id) {
 | 
			
		||||
                  <i-bs width="1em" height="1em" name="check"></i-bs>
 | 
			
		||||
          @if (createdRelativeDate) {
 | 
			
		||||
            <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()">
 | 
			
		||||
              <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="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' }} – <ng-container i18n>now</ng-container>
 | 
			
		||||
                  </span>
 | 
			
		||||
        <div class="input-group input-group-sm small ps-1 pe-2">
 | 
			
		||||
          <ng-select class="w-100" name="createdRelativeDate"
 | 
			
		||||
          [items]="relativeDates" [(ngModel)]="createdRelativeDate"
 | 
			
		||||
          bindValue="id"
 | 
			
		||||
          bindLabel="name"
 | 
			
		||||
          clearable="false"
 | 
			
		||||
          placeholder="Relative dates"
 | 
			
		||||
          i18n-placeholder
 | 
			
		||||
          (change)="onSetCreatedRelativeDate($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' }} – <ng-container i18n>now</ng-container></span></div>
 | 
			
		||||
          </ng-template>
 | 
			
		||||
          </ng-select>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
            </button>
 | 
			
		||||
          }
 | 
			
		||||
      <div class="list-group-item d-flex p-2" role="menuitem">
 | 
			
		||||
 | 
			
		||||
        <div class="selected-icon">
 | 
			
		||||
          @if (createdDateFrom) {
 | 
			
		||||
            <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()">
 | 
			
		||||
@@ -52,10 +54,8 @@
 | 
			
		||||
            </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()">
 | 
			
		||||
@@ -81,31 +81,33 @@
 | 
			
		||||
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="col">
 | 
			
		||||
    <h6 class="dropdown-header border-bottom" i18n>Added</h6>
 | 
			
		||||
    <div class="list-group list-group-flush">
 | 
			
		||||
          @for (rd of relativeDates; track rd) {
 | 
			
		||||
            <button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setAddedRelativeDate(rd.id)">
 | 
			
		||||
      <div class="list-group-item d-flex p-2 select-item" role="menuitem">
 | 
			
		||||
        <div class="selected-icon">
 | 
			
		||||
                @if (addedRelativeDate === rd.id) {
 | 
			
		||||
                  <i-bs width="1em" height="1em" name="check"></i-bs>
 | 
			
		||||
          @if (addedRelativeDate) {
 | 
			
		||||
            <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedRelativeDate()">
 | 
			
		||||
              <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="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' }} – <ng-container i18n>now</ng-container>
 | 
			
		||||
                  </span>
 | 
			
		||||
        <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' }} – <ng-container i18n>now</ng-container></span></div>
 | 
			
		||||
            </ng-template>
 | 
			
		||||
          </ng-select>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
            </button>
 | 
			
		||||
          }
 | 
			
		||||
      <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()">
 | 
			
		||||
@@ -128,10 +130,8 @@
 | 
			
		||||
            </div>
 | 
			
		||||
          </ng-template>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="list-group-item d-flex p-2" role="menuitem">
 | 
			
		||||
 | 
			
		||||
        <div class="selected-icon">
 | 
			
		||||
          @if (addedDateTo) {
 | 
			
		||||
            <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()">
 | 
			
		||||
@@ -154,9 +154,6 @@
 | 
			
		||||
            </div>
 | 
			
		||||
          </ng-template>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,7 @@
 | 
			
		||||
.date-dropdown {
 | 
			
		||||
  --bs-dropdown-min-width: 22rem;
 | 
			
		||||
  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 {
 | 
			
		||||
    line-height: 1;
 | 
			
		||||
  }
 | 
			
		||||
@@ -21,6 +12,10 @@
 | 
			
		||||
  min-height: 1em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.select-item .selected-icon {
 | 
			
		||||
  line-height: 2em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.input-group-sm {
 | 
			
		||||
  .form-control {
 | 
			
		||||
    font-size: 0.875rem;
 | 
			
		||||
 
 | 
			
		||||
@@ -82,10 +82,12 @@ describe('DatesDropdownComponent', () => {
 | 
			
		||||
  it('should support relative dates', fakeAsync(() => {
 | 
			
		||||
    let result: DateSelection
 | 
			
		||||
    component.datesSet.subscribe((date) => (result = date))
 | 
			
		||||
    component.setCreatedRelativeDate(null)
 | 
			
		||||
    component.setCreatedRelativeDate(RelativeDate.WITHIN_1_WEEK)
 | 
			
		||||
    component.setAddedRelativeDate(null)
 | 
			
		||||
    component.setAddedRelativeDate(RelativeDate.WITHIN_1_WEEK)
 | 
			
		||||
    component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
 | 
			
		||||
    component.onSetCreatedRelativeDate({
 | 
			
		||||
      id: 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)
 | 
			
		||||
    expect(result).toEqual({
 | 
			
		||||
      createdFrom: null,
 | 
			
		||||
@@ -147,8 +149,19 @@ describe('DatesDropdownComponent', () => {
 | 
			
		||||
    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', () => {
 | 
			
		||||
    const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
 | 
			
		||||
    const input: HTMLInputElement =
 | 
			
		||||
      fixture.nativeElement.querySelector('input.form-control')
 | 
			
		||||
    let event: KeyboardEvent = new KeyboardEvent('keypress', {
 | 
			
		||||
      key: '9',
 | 
			
		||||
    })
 | 
			
		||||
@@ -163,4 +176,19 @@ describe('DatesDropdownComponent', () => {
 | 
			
		||||
    input.dispatchEvent(event)
 | 
			
		||||
    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,
 | 
			
		||||
    })
 | 
			
		||||
  }))
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -13,13 +13,14 @@ import {
 | 
			
		||||
  NgbDatepickerModule,
 | 
			
		||||
  NgbDropdownModule,
 | 
			
		||||
} from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { NgSelectModule } from '@ng-select/ng-select'
 | 
			
		||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 | 
			
		||||
import { Subject, Subscription } from 'rxjs'
 | 
			
		||||
import { debounceTime } from 'rxjs/operators'
 | 
			
		||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
 | 
			
		||||
import { SettingsService } from 'src/app/services/settings.service'
 | 
			
		||||
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'
 | 
			
		||||
 | 
			
		||||
export interface DateSelection {
 | 
			
		||||
@@ -32,10 +33,14 @@ export interface DateSelection {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum RelativeDate {
 | 
			
		||||
  WITHIN_1_WEEK = 0,
 | 
			
		||||
  WITHIN_1_MONTH = 1,
 | 
			
		||||
  WITHIN_3_MONTHS = 2,
 | 
			
		||||
  WITHIN_1_YEAR = 3,
 | 
			
		||||
  WITHIN_1_WEEK = 1,
 | 
			
		||||
  WITHIN_1_MONTH = 2,
 | 
			
		||||
  WITHIN_3_MONTHS = 3,
 | 
			
		||||
  WITHIN_1_YEAR = 4,
 | 
			
		||||
  THIS_YEAR = 5,
 | 
			
		||||
  THIS_MONTH = 6,
 | 
			
		||||
  TODAY = 7,
 | 
			
		||||
  YESTERDAY = 8,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
@@ -49,13 +54,14 @@ export enum RelativeDate {
 | 
			
		||||
    NgxBootstrapIconsModule,
 | 
			
		||||
    NgbDatepickerModule,
 | 
			
		||||
    NgbDropdownModule,
 | 
			
		||||
    NgSelectModule,
 | 
			
		||||
    FormsModule,
 | 
			
		||||
    ReactiveFormsModule,
 | 
			
		||||
    NgClass,
 | 
			
		||||
  ],
 | 
			
		||||
})
 | 
			
		||||
export class DatesDropdownComponent implements OnInit, OnDestroy {
 | 
			
		||||
  public popperOptions = popperOptionsReenablePreventOverflow
 | 
			
		||||
  public popperOptions = pngxPopperOptions
 | 
			
		||||
 | 
			
		||||
  constructor(settings: SettingsService) {
 | 
			
		||||
    this.datePlaceHolder = settings.getLocalizedDateInputFormat()
 | 
			
		||||
@@ -82,44 +88,64 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
 | 
			
		||||
      name: $localize`Within 1 year`,
 | 
			
		||||
      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
 | 
			
		||||
 | 
			
		||||
  // created
 | 
			
		||||
  @Input()
 | 
			
		||||
  createdDateTo: string
 | 
			
		||||
  createdDateTo: string = null
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  createdDateToChange = new EventEmitter<string>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  createdDateFrom: string
 | 
			
		||||
  createdDateFrom: string = null
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  createdDateFromChange = new EventEmitter<string>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  createdRelativeDate: RelativeDate
 | 
			
		||||
  createdRelativeDate: RelativeDate = null
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  createdRelativeDateChange = new EventEmitter<number>()
 | 
			
		||||
 | 
			
		||||
  // added
 | 
			
		||||
  @Input()
 | 
			
		||||
  addedDateTo: string
 | 
			
		||||
  addedDateTo: string = null
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  addedDateToChange = new EventEmitter<string>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  addedDateFrom: string
 | 
			
		||||
  addedDateFrom: string = null
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  addedDateFromChange = new EventEmitter<string>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  addedRelativeDate: RelativeDate
 | 
			
		||||
  addedRelativeDate: RelativeDate = null
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  addedRelativeDateChange = new EventEmitter<number>()
 | 
			
		||||
@@ -133,6 +159,9 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
 | 
			
		||||
  @Input()
 | 
			
		||||
  disabled: boolean = false
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  placement: string = 'bottom-start'
 | 
			
		||||
 | 
			
		||||
  public readonly today: string = new Date().toISOString().split('T')[0]
 | 
			
		||||
 | 
			
		||||
  get isActive(): boolean {
 | 
			
		||||
@@ -172,17 +201,17 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setCreatedRelativeDate(rd: RelativeDate) {
 | 
			
		||||
  onSetCreatedRelativeDate(rd: { id: number; name: string; date: number }) {
 | 
			
		||||
    // createdRelativeDate is set by ngModel
 | 
			
		||||
    this.createdDateTo = null
 | 
			
		||||
    this.createdDateFrom = null
 | 
			
		||||
    this.createdRelativeDate = this.createdRelativeDate == rd ? null : rd
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setAddedRelativeDate(rd: RelativeDate) {
 | 
			
		||||
  onSetAddedRelativeDate(rd: { id: number; name: string; date: number }) {
 | 
			
		||||
    // addedRelativeDate is set by ngModel
 | 
			
		||||
    this.addedDateTo = null
 | 
			
		||||
    this.addedDateFrom = null
 | 
			
		||||
    this.addedRelativeDate = this.addedRelativeDate == rd ? null : rd
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -224,6 +253,11 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clearCreatedRelativeDate() {
 | 
			
		||||
    this.createdRelativeDate = null
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clearAddedTo() {
 | 
			
		||||
    this.addedDateTo = null
 | 
			
		||||
    this.onChange()
 | 
			
		||||
@@ -234,6 +268,11 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clearAddedRelativeDate() {
 | 
			
		||||
    this.addedRelativeDate = null
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // prevent chars other than numbers and separators
 | 
			
		||||
  onKeyPress(event: KeyboardEvent) {
 | 
			
		||||
    if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) {
 | 
			
		||||
 
 | 
			
		||||
@@ -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 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-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 class="col">
 | 
			
		||||
            <pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select>
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,12 @@ import { CdkDragDrop } from '@angular/cdk/drag-drop'
 | 
			
		||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
 | 
			
		||||
import { provideHttpClientTesting } from '@angular/common/http/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 { NgSelectModule } from '@ng-select/ng-select'
 | 
			
		||||
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].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([])
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -47,6 +47,7 @@ import { WorkflowService } from 'src/app/services/rest/workflow.service'
 | 
			
		||||
import { SettingsService } from 'src/app/services/settings.service'
 | 
			
		||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.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 { NumberComponent } from '../../input/number/number.component'
 | 
			
		||||
import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component'
 | 
			
		||||
@@ -151,6 +152,7 @@ const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
 | 
			
		||||
    SelectComponent,
 | 
			
		||||
    TextAreaComponent,
 | 
			
		||||
    TagsComponent,
 | 
			
		||||
    CustomFieldsValuesComponent,
 | 
			
		||||
    PermissionsGroupComponent,
 | 
			
		||||
    PermissionsUserComponent,
 | 
			
		||||
    ConfirmButtonComponent,
 | 
			
		||||
@@ -439,6 +441,9 @@ export class WorkflowEditDialogComponent
 | 
			
		||||
        assign_change_users: new FormControl(action.assign_change_users),
 | 
			
		||||
        assign_change_groups: new FormControl(action.assign_change_groups),
 | 
			
		||||
        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_all_tags: new FormControl(action.remove_all_tags),
 | 
			
		||||
        remove_document_types: new FormControl(action.remove_document_types),
 | 
			
		||||
@@ -565,6 +570,7 @@ export class WorkflowEditDialogComponent
 | 
			
		||||
      assign_change_users: [],
 | 
			
		||||
      assign_change_groups: [],
 | 
			
		||||
      assign_custom_fields: [],
 | 
			
		||||
      assign_custom_fields_values: {},
 | 
			
		||||
      remove_tags: [],
 | 
			
		||||
      remove_all_tags: false,
 | 
			
		||||
      remove_document_types: [],
 | 
			
		||||
@@ -643,4 +649,12 @@ export class WorkflowEditDialogComponent
 | 
			
		||||
      })
 | 
			
		||||
    super.save()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public removeSelectedCustomField(fieldId: number, group: FormGroup) {
 | 
			
		||||
    group
 | 
			
		||||
      .get('assign_custom_fields')
 | 
			
		||||
      .setValue(
 | 
			
		||||
        group.get('assign_custom_fields').value.filter((id) => id !== fieldId)
 | 
			
		||||
      )
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,32 @@
 | 
			
		||||
<div class="modal-header">
 | 
			
		||||
    <h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
 | 
			
		||||
    <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
 | 
			
		||||
</div>
 | 
			
		||||
<div class="modal-body">
 | 
			
		||||
    <div class="mb-1">
 | 
			
		||||
        <label for="email" class="form-label" i18n>Email address(es)</label>
 | 
			
		||||
        <input type="email" class="form-control" id="email" [(ngModel)]="emailAddress">
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="mb-1">
 | 
			
		||||
        <label for="email" class="form-label" i18n>Subject</label>
 | 
			
		||||
        <input type="email" class="form-control" id="subject" [(ngModel)]="emailSubject">
 | 
			
		||||
    </div>
 | 
			
		||||
    <div>
 | 
			
		||||
        <label for="message" class="form-label" i18n>Message</label>
 | 
			
		||||
        <textarea class="form-control" id="message" rows="3" [(ngModel)]="emailMessage"></textarea>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
<div class="modal-footer">
 | 
			
		||||
    <div class="input-group">
 | 
			
		||||
        <div class="input-group-text flex-grow-1">
 | 
			
		||||
            <input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
 | 
			
		||||
            <label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label>
 | 
			
		||||
        </div>
 | 
			
		||||
        <button type="submit" class="btn btn-outline-primary" (click)="emailDocument()" [disabled]="loading || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0">
 | 
			
		||||
            @if (loading) {
 | 
			
		||||
                <div class="spinner-border spinner-border-sm me-2" role="status"></div>
 | 
			
		||||
            }
 | 
			
		||||
            <ng-container i18n>Send email</ng-container>
 | 
			
		||||
        </button>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,72 @@
 | 
			
		||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
 | 
			
		||||
 | 
			
		||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
 | 
			
		||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
 | 
			
		||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
 | 
			
		||||
import { of, throwError } from 'rxjs'
 | 
			
		||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
 | 
			
		||||
import { PermissionsService } from 'src/app/services/permissions.service'
 | 
			
		||||
import { DocumentService } from 'src/app/services/rest/document.service'
 | 
			
		||||
import { ToastService } from 'src/app/services/toast.service'
 | 
			
		||||
import { EmailDocumentDialogComponent } from './email-document-dialog.component'
 | 
			
		||||
 | 
			
		||||
describe('EmailDocumentDialogComponent', () => {
 | 
			
		||||
  let component: EmailDocumentDialogComponent
 | 
			
		||||
  let fixture: ComponentFixture<EmailDocumentDialogComponent>
 | 
			
		||||
  let documentService: DocumentService
 | 
			
		||||
  let permissionsService: PermissionsService
 | 
			
		||||
  let toastService: ToastService
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await TestBed.configureTestingModule({
 | 
			
		||||
      imports: [
 | 
			
		||||
        EmailDocumentDialogComponent,
 | 
			
		||||
        IfPermissionsDirective,
 | 
			
		||||
        NgxBootstrapIconsModule.pick(allIcons),
 | 
			
		||||
      ],
 | 
			
		||||
      providers: [
 | 
			
		||||
        provideHttpClient(withInterceptorsFromDi()),
 | 
			
		||||
        provideHttpClientTesting(),
 | 
			
		||||
        NgbActiveModal,
 | 
			
		||||
      ],
 | 
			
		||||
    }).compileComponents()
 | 
			
		||||
 | 
			
		||||
    fixture = TestBed.createComponent(EmailDocumentDialogComponent)
 | 
			
		||||
    documentService = TestBed.inject(DocumentService)
 | 
			
		||||
    toastService = TestBed.inject(ToastService)
 | 
			
		||||
    component = fixture.componentInstance
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should set hasArchiveVersion and useArchiveVersion', () => {
 | 
			
		||||
    expect(component.hasArchiveVersion).toBeTruthy()
 | 
			
		||||
    component.hasArchiveVersion = false
 | 
			
		||||
    expect(component.hasArchiveVersion).toBeFalsy()
 | 
			
		||||
    expect(component.useArchiveVersion).toBeFalsy()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support sending document via email, showing error if needed', () => {
 | 
			
		||||
    const toastErrorSpy = jest.spyOn(toastService, 'showError')
 | 
			
		||||
    const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
 | 
			
		||||
    component.emailAddress = 'hello@paperless-ngx.com'
 | 
			
		||||
    component.emailSubject = 'Hello'
 | 
			
		||||
    component.emailMessage = 'World'
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(documentService, 'emailDocument')
 | 
			
		||||
      .mockReturnValue(throwError(() => new Error('Unable to email document')))
 | 
			
		||||
    component.emailDocument()
 | 
			
		||||
    expect(toastErrorSpy).toHaveBeenCalled()
 | 
			
		||||
 | 
			
		||||
    jest.spyOn(documentService, 'emailDocument').mockReturnValue(of(true))
 | 
			
		||||
    component.emailDocument()
 | 
			
		||||
    expect(toastSuccessSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should close the dialog', () => {
 | 
			
		||||
    const activeModal = TestBed.inject(NgbActiveModal)
 | 
			
		||||
    const closeSpy = jest.spyOn(activeModal, 'close')
 | 
			
		||||
    component.close()
 | 
			
		||||
    expect(closeSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
@@ -0,0 +1,78 @@
 | 
			
		||||
import { Component, Input } from '@angular/core'
 | 
			
		||||
import { FormsModule } from '@angular/forms'
 | 
			
		||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 | 
			
		||||
import { DocumentService } from 'src/app/services/rest/document.service'
 | 
			
		||||
import { ToastService } from 'src/app/services/toast.service'
 | 
			
		||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'pngx-email-document-dialog',
 | 
			
		||||
  templateUrl: './email-document-dialog.component.html',
 | 
			
		||||
  styleUrl: './email-document-dialog.component.scss',
 | 
			
		||||
  imports: [FormsModule, NgxBootstrapIconsModule],
 | 
			
		||||
})
 | 
			
		||||
export class EmailDocumentDialogComponent extends LoadingComponentWithPermissions {
 | 
			
		||||
  @Input()
 | 
			
		||||
  title = $localize`Email Document`
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  documentId: number
 | 
			
		||||
 | 
			
		||||
  private _hasArchiveVersion: boolean = true
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  set hasArchiveVersion(value: boolean) {
 | 
			
		||||
    this._hasArchiveVersion = value
 | 
			
		||||
    this.useArchiveVersion = value
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get hasArchiveVersion(): boolean {
 | 
			
		||||
    return this._hasArchiveVersion
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public useArchiveVersion: boolean = true
 | 
			
		||||
 | 
			
		||||
  public emailAddress: string = ''
 | 
			
		||||
  public emailSubject: string = ''
 | 
			
		||||
  public emailMessage: string = ''
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    private activeModal: NgbActiveModal,
 | 
			
		||||
    private documentService: DocumentService,
 | 
			
		||||
    private toastService: ToastService
 | 
			
		||||
  ) {
 | 
			
		||||
    super()
 | 
			
		||||
    this.loading = false
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public emailDocument() {
 | 
			
		||||
    this.loading = true
 | 
			
		||||
    this.documentService
 | 
			
		||||
      .emailDocument(
 | 
			
		||||
        this.documentId,
 | 
			
		||||
        this.emailAddress,
 | 
			
		||||
        this.emailSubject,
 | 
			
		||||
        this.emailMessage,
 | 
			
		||||
        this.useArchiveVersion
 | 
			
		||||
      )
 | 
			
		||||
      .subscribe({
 | 
			
		||||
        next: () => {
 | 
			
		||||
          this.loading = false
 | 
			
		||||
          this.emailAddress = ''
 | 
			
		||||
          this.emailSubject = ''
 | 
			
		||||
          this.emailMessage = ''
 | 
			
		||||
          this.close()
 | 
			
		||||
          this.toastService.showInfo($localize`Email sent`)
 | 
			
		||||
        },
 | 
			
		||||
        error: (e) => {
 | 
			
		||||
          this.loading = false
 | 
			
		||||
          this.toastService.showError($localize`Error emailing document`, e)
 | 
			
		||||
        },
 | 
			
		||||
      })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public close() {
 | 
			
		||||
    this.activeModal.close()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -7,6 +7,7 @@ import {
 | 
			
		||||
  tick,
 | 
			
		||||
} from '@angular/core/testing'
 | 
			
		||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
 | 
			
		||||
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
 | 
			
		||||
import {
 | 
			
		||||
  DEFAULT_MATCHING_ALGORITHM,
 | 
			
		||||
  MATCH_ALL,
 | 
			
		||||
@@ -44,6 +45,11 @@ const nullItem = {
 | 
			
		||||
  name: 'Not assigned',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const negativeNullItem = {
 | 
			
		||||
  id: NEGATIVE_NULL_FILTER_VALUE,
 | 
			
		||||
  name: 'Not assigned',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let selectionModel: FilterableDropdownSelectionModel
 | 
			
		||||
 | 
			
		||||
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
 | 
			
		||||
@@ -64,6 +70,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
    hotkeyService = TestBed.inject(HotKeyService)
 | 
			
		||||
    fixture = TestBed.createComponent(FilterableDropdownComponent)
 | 
			
		||||
    component = fixture.componentInstance
 | 
			
		||||
    component.selectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
    selectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
@@ -74,7 +81,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support reset', () => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    selectionModel.set(items[0].id, ToggleableItemState.Selected)
 | 
			
		||||
    expect(selectionModel.getSelectedItems()).toHaveLength(1)
 | 
			
		||||
@@ -96,7 +103,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should emit change when items selected', () => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    let newModel: FilterableDropdownSelectionModel
 | 
			
		||||
    component.selectionModelChange.subscribe((model) => (newModel = model))
 | 
			
		||||
@@ -110,11 +117,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
    selectionModel.set(items[0].id, ToggleableItemState.NotSelected)
 | 
			
		||||
    expect(newModel.getSelectedItems()).toEqual([])
 | 
			
		||||
 | 
			
		||||
    expect(component.items).toEqual([nullItem, ...items])
 | 
			
		||||
    expect(component.selectionModel.items).toEqual([nullItem, ...items])
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should emit change when items excluded', () => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    let newModel: FilterableDropdownSelectionModel
 | 
			
		||||
    component.selectionModelChange.subscribe((model) => (newModel = model))
 | 
			
		||||
@@ -124,7 +131,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should emit change when items excluded', () => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    let newModel: FilterableDropdownSelectionModel
 | 
			
		||||
    component.selectionModelChange.subscribe((model) => (newModel = model))
 | 
			
		||||
@@ -139,8 +146,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should exclude items when excluded and not editing', () => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.manyToOne = true
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.selectionModel.manyToOne = true
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    selectionModel.set(items[0].id, ToggleableItemState.Selected)
 | 
			
		||||
    component.excludeClicked(items[0].id)
 | 
			
		||||
@@ -149,8 +156,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should toggle when items excluded and editing', () => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.manyToOne = true
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.selectionModel.manyToOne = true
 | 
			
		||||
    component.editing = true
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    selectionModel.set(items[0].id, ToggleableItemState.NotSelected)
 | 
			
		||||
@@ -160,8 +167,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should hide count for item if adding will increase size of set', () => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.manyToOne = true
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.selectionModel.manyToOne = true
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    expect(component.hideCount(items[0])).toBeFalsy()
 | 
			
		||||
    selectionModel.logicalOperator = LogicalOperator.Or
 | 
			
		||||
@@ -170,7 +177,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
 | 
			
		||||
  it('should enforce single select when editing', () => {
 | 
			
		||||
    component.editing = true
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    let newModel: FilterableDropdownSelectionModel
 | 
			
		||||
    component.selectionModelChange.subscribe((model) => (newModel = model))
 | 
			
		||||
@@ -182,11 +189,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support manyToOne selecting', () => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    selectionModel.manyToOne = false
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    component.manyToOne = true
 | 
			
		||||
    expect(component.manyToOne).toBeTruthy()
 | 
			
		||||
    component.selectionModel.manyToOne = true
 | 
			
		||||
    expect(component.selectionModel.manyToOne).toBeTruthy()
 | 
			
		||||
    let newModel: FilterableDropdownSelectionModel
 | 
			
		||||
    component.selectionModelChange.subscribe((model) => (newModel = model))
 | 
			
		||||
 | 
			
		||||
@@ -197,12 +204,10 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should dynamically enable / disable modifier toggle', () => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    expect(component.modifierToggleEnabled).toBeTruthy()
 | 
			
		||||
    selectionModel.toggle(null)
 | 
			
		||||
    expect(component.modifierToggleEnabled).toBeFalsy()
 | 
			
		||||
    component.manyToOne = true
 | 
			
		||||
    component.selectionModel.manyToOne = true
 | 
			
		||||
    expect(component.modifierToggleEnabled).toBeFalsy()
 | 
			
		||||
    selectionModel.toggle(items[0].id)
 | 
			
		||||
    selectionModel.toggle(items[1].id)
 | 
			
		||||
@@ -210,7 +215,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should apply changes and close when apply button clicked', () => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.icon = 'tag-fill'
 | 
			
		||||
    component.editing = true
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
@@ -232,7 +237,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should apply on close if enabled', () => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.icon = 'tag-fill'
 | 
			
		||||
    component.editing = true
 | 
			
		||||
    component.applyOnClose = true
 | 
			
		||||
@@ -250,7 +255,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should focus text filter on open, support filtering, clear on close', fakeAsync(() => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.icon = 'tag-fill'
 | 
			
		||||
    fixture.nativeElement
 | 
			
		||||
      .querySelector('button')
 | 
			
		||||
@@ -277,7 +282,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should toggle & close on enter inside filter field if 1 item remains', fakeAsync(() => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.icon = 'tag-fill'
 | 
			
		||||
    expect(component.selectionModel.getSelectedItems()).toEqual([])
 | 
			
		||||
    fixture.nativeElement
 | 
			
		||||
@@ -297,7 +302,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should apply & close on enter inside filter field if 1 item remains if editing', fakeAsync(() => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.icon = 'tag-fill'
 | 
			
		||||
    component.editing = true
 | 
			
		||||
    let applyResult: ChangedItems
 | 
			
		||||
@@ -319,7 +324,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should support arrow keyboard navigation', fakeAsync(() => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.icon = 'tag-fill'
 | 
			
		||||
    fixture.nativeElement
 | 
			
		||||
      .querySelector('button')
 | 
			
		||||
@@ -364,7 +369,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.icon = 'tag-fill'
 | 
			
		||||
    fixture.nativeElement
 | 
			
		||||
      .querySelector('button')
 | 
			
		||||
@@ -400,7 +405,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should support arrow keyboard navigation after click', fakeAsync(() => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.icon = 'tag-fill'
 | 
			
		||||
    fixture.nativeElement
 | 
			
		||||
      .querySelector('button')
 | 
			
		||||
@@ -425,9 +430,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should toggle logical operator', fakeAsync(() => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.icon = 'tag-fill'
 | 
			
		||||
    component.manyToOne = true
 | 
			
		||||
    component.selectionModel.manyToOne = true
 | 
			
		||||
    selectionModel.set(items[0].id, ToggleableItemState.Selected)
 | 
			
		||||
    selectionModel.set(items[1].id, ToggleableItemState.Selected)
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
@@ -454,7 +459,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should toggle intersection include / exclude', fakeAsync(() => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.icon = 'tag-fill'
 | 
			
		||||
    selectionModel.set(items[0].id, ToggleableItemState.Selected)
 | 
			
		||||
    selectionModel.set(items[1].id, ToggleableItemState.Selected)
 | 
			
		||||
@@ -483,22 +488,53 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
    expect(changedResult.getExcludedItems()).toEqual(items)
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('selection model should sort items by state', () => {
 | 
			
		||||
    component.items = items.concat([{ id: null, name: 'Null B' }])
 | 
			
		||||
  it('should update null item selection on toggleIntersection', () => {
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    component.selectionModel.intersection = Intersection.Include
 | 
			
		||||
    component.selectionModel.set(null, ToggleableItemState.Selected)
 | 
			
		||||
    component.selectionModel.intersection = Intersection.Exclude
 | 
			
		||||
    component.selectionModel.toggleIntersection()
 | 
			
		||||
    expect(component.selectionModel.getExcludedItems()).toEqual([
 | 
			
		||||
      negativeNullItem,
 | 
			
		||||
    ])
 | 
			
		||||
 | 
			
		||||
    component.selectionModel.intersection = Intersection.Include
 | 
			
		||||
    component.selectionModel.toggleIntersection()
 | 
			
		||||
    expect(component.selectionModel.getSelectedItems()).toEqual([nullItem])
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('selection model should sort items by state', () => {
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    component.selectionModel.items = items.concat([{ id: 3, name: 'Item3' }])
 | 
			
		||||
    selectionModel.toggle(items[1].id)
 | 
			
		||||
    selectionModel.apply()
 | 
			
		||||
    expect(selectionModel.items.length).toEqual(4)
 | 
			
		||||
    expect(selectionModel.items).toEqual([
 | 
			
		||||
      nullItem,
 | 
			
		||||
      { id: null, name: 'Null B' },
 | 
			
		||||
      items[1],
 | 
			
		||||
      { id: 3, name: 'Item3' },
 | 
			
		||||
      items[0],
 | 
			
		||||
    ])
 | 
			
		||||
 | 
			
		||||
    selectionModel.intersection = Intersection.Exclude
 | 
			
		||||
    selectionModel.toggleIntersection()
 | 
			
		||||
    selectionModel.apply()
 | 
			
		||||
    expect(selectionModel.items).toEqual([
 | 
			
		||||
      negativeNullItem,
 | 
			
		||||
      items[1],
 | 
			
		||||
      { id: 3, name: 'Item3' },
 | 
			
		||||
      items[0],
 | 
			
		||||
    ])
 | 
			
		||||
 | 
			
		||||
    // coverage
 | 
			
		||||
    selectionModel.items = selectionModel.items.reverse()
 | 
			
		||||
    selectionModel.apply()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('selection model should sort items by state and document counts = 0, if set', () => {
 | 
			
		||||
    const tagA = { id: 4, name: 'Tag A' }
 | 
			
		||||
    component.items = items.concat([tagA])
 | 
			
		||||
    component.selectionModel.items = items.concat([tagA])
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    component.documentCounts = [
 | 
			
		||||
      { id: 1, document_count: 0 }, // Tag1
 | 
			
		||||
@@ -529,7 +565,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should set support create, keep open model and call createRef method', fakeAsync(() => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.icon = 'tag-fill'
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    fixture.nativeElement
 | 
			
		||||
@@ -549,7 +585,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should call create on enter inside filter field if 0 items remain while editing', fakeAsync(() => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.icon = 'tag-fill'
 | 
			
		||||
    component.editing = true
 | 
			
		||||
    component.createRef = jest.fn()
 | 
			
		||||
@@ -569,7 +605,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
    const id = 1
 | 
			
		||||
    const state = ToggleableItemState.Selected
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    component.manyToOne = true
 | 
			
		||||
    component.selectionModel.manyToOne = true
 | 
			
		||||
    component.selectionModel.singleSelect = true
 | 
			
		||||
    component.selectionModel.intersection = Intersection.Include
 | 
			
		||||
    component.selectionModel['temporarySelectionStates'].set(id, state)
 | 
			
		||||
@@ -596,7 +632,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support shortcut keys', () => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.icon = 'tag-fill'
 | 
			
		||||
    component.shortcutKey = 't'
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
@@ -606,7 +642,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support an extra button and not apply changes when clicked', () => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
    component.selectionModel.items = items
 | 
			
		||||
    component.icon = 'tag-fill'
 | 
			
		||||
    component.extraButtonTitle = 'Extra'
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
 
 | 
			
		||||
@@ -12,12 +12,13 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
 | 
			
		||||
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 | 
			
		||||
import { Subject, filter, takeUntil } from 'rxjs'
 | 
			
		||||
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
 | 
			
		||||
import { MatchingModel } from 'src/app/data/matching-model'
 | 
			
		||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
 | 
			
		||||
import { FilterPipe } from 'src/app/pipes/filter.pipe'
 | 
			
		||||
import { HotKeyService } from 'src/app/services/hot-key.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 { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
 | 
			
		||||
import {
 | 
			
		||||
@@ -61,15 +62,56 @@ export class FilterableDropdownSelectionModel {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set items(items: MatchingModel[]) {
 | 
			
		||||
    this._items = items
 | 
			
		||||
    if (items) {
 | 
			
		||||
      this._items = Array.from(items)
 | 
			
		||||
      this.sortItems()
 | 
			
		||||
      this.setNullItem()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private setNullItem() {
 | 
			
		||||
    if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
 | 
			
		||||
      if (this._items[0]?.id === null) {
 | 
			
		||||
        this._items.shift()
 | 
			
		||||
      }
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const item = {
 | 
			
		||||
      name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
 | 
			
		||||
      id:
 | 
			
		||||
        this.manyToOne || this.intersection === Intersection.Include
 | 
			
		||||
          ? null
 | 
			
		||||
          : NEGATIVE_NULL_FILTER_VALUE,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      this._items[0]?.id === null ||
 | 
			
		||||
      this._items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
 | 
			
		||||
    ) {
 | 
			
		||||
      this._items[0] = item
 | 
			
		||||
    } else if (this._items) {
 | 
			
		||||
      this._items.unshift(item)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  constructor(manyToOne: boolean = false) {
 | 
			
		||||
    this.manyToOne = manyToOne
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private sortItems() {
 | 
			
		||||
    this._items.sort((a, b) => {
 | 
			
		||||
      if (a.id == null && b.id != null) {
 | 
			
		||||
      if (
 | 
			
		||||
        (a.id == null && b.id != null) ||
 | 
			
		||||
        (a.id == NEGATIVE_NULL_FILTER_VALUE &&
 | 
			
		||||
          b.id != NEGATIVE_NULL_FILTER_VALUE)
 | 
			
		||||
      ) {
 | 
			
		||||
        return -1
 | 
			
		||||
      } else if (a.id != null && b.id == null) {
 | 
			
		||||
      } else if (
 | 
			
		||||
        (a.id != null && b.id == null) ||
 | 
			
		||||
        (a.id != NEGATIVE_NULL_FILTER_VALUE &&
 | 
			
		||||
          b.id == NEGATIVE_NULL_FILTER_VALUE)
 | 
			
		||||
      ) {
 | 
			
		||||
        return 1
 | 
			
		||||
      } else if (
 | 
			
		||||
        this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
 | 
			
		||||
@@ -230,6 +272,7 @@ export class FilterableDropdownSelectionModel {
 | 
			
		||||
 | 
			
		||||
  set logicalOperator(operator: LogicalOperator) {
 | 
			
		||||
    this.temporaryLogicalOperator = operator
 | 
			
		||||
    this.setNullItem()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  toggleOperator() {
 | 
			
		||||
@@ -242,6 +285,7 @@ export class FilterableDropdownSelectionModel {
 | 
			
		||||
 | 
			
		||||
  set intersection(intersection: Intersection) {
 | 
			
		||||
    this.temporaryIntersection = intersection
 | 
			
		||||
    this.setNullItem()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  toggleIntersection() {
 | 
			
		||||
@@ -250,9 +294,20 @@ export class FilterableDropdownSelectionModel {
 | 
			
		||||
      this.intersection == Intersection.Include
 | 
			
		||||
        ? ToggleableItemState.Selected
 | 
			
		||||
        : ToggleableItemState.Excluded
 | 
			
		||||
 | 
			
		||||
    this.temporarySelectionStates.forEach((state, key) => {
 | 
			
		||||
      if (key === null && this.intersection === Intersection.Exclude) {
 | 
			
		||||
        this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState)
 | 
			
		||||
      } else if (
 | 
			
		||||
        key === NEGATIVE_NULL_FILTER_VALUE &&
 | 
			
		||||
        this.intersection === Intersection.Include
 | 
			
		||||
      ) {
 | 
			
		||||
        this.temporarySelectionStates.set(null, newState)
 | 
			
		||||
      } else {
 | 
			
		||||
        this.temporarySelectionStates.set(key, newState)
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    this.changed.next(this)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -274,6 +329,7 @@ export class FilterableDropdownSelectionModel {
 | 
			
		||||
    this.temporarySelectionStates.clear()
 | 
			
		||||
    this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And
 | 
			
		||||
    this.temporaryIntersection = this._intersection = Intersection.Include
 | 
			
		||||
    this.setNullItem()
 | 
			
		||||
    if (fireEvent) {
 | 
			
		||||
      this.changed.next(this)
 | 
			
		||||
    }
 | 
			
		||||
@@ -305,8 +361,10 @@ export class FilterableDropdownSelectionModel {
 | 
			
		||||
 | 
			
		||||
  isNoneSelected() {
 | 
			
		||||
    return (
 | 
			
		||||
      this.selectionSize() == 1 &&
 | 
			
		||||
      this.get(null) == ToggleableItemState.Selected
 | 
			
		||||
      (this.selectionSize() == 1 &&
 | 
			
		||||
        this.get(null) == ToggleableItemState.Selected) ||
 | 
			
		||||
      (this.intersection == Intersection.Exclude &&
 | 
			
		||||
        this.get(NEGATIVE_NULL_FILTER_VALUE) == ToggleableItemState.Excluded)
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -380,29 +438,17 @@ export class FilterableDropdownComponent
 | 
			
		||||
  @ViewChild('dropdown') dropdown: NgbDropdown
 | 
			
		||||
  @ViewChild('buttonItems') buttonItems: ElementRef
 | 
			
		||||
 | 
			
		||||
  public popperOptions = popperOptionsReenablePreventOverflow
 | 
			
		||||
  public popperOptions = pngxPopperOptions
 | 
			
		||||
 | 
			
		||||
  filterText: string
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  set items(items: MatchingModel[]) {
 | 
			
		||||
    if (items) {
 | 
			
		||||
      this._selectionModel.items = Array.from(items)
 | 
			
		||||
      this._selectionModel.items.unshift({
 | 
			
		||||
        name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
 | 
			
		||||
        id: null,
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  _selectionModel: FilterableDropdownSelectionModel
 | 
			
		||||
 | 
			
		||||
  get items(): MatchingModel[] {
 | 
			
		||||
    return this._selectionModel.items
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  _selectionModel: FilterableDropdownSelectionModel =
 | 
			
		||||
    new FilterableDropdownSelectionModel()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  @Input({ required: true })
 | 
			
		||||
  set selectionModel(model: FilterableDropdownSelectionModel) {
 | 
			
		||||
    if (this.selectionModel) {
 | 
			
		||||
      this.selectionModel.changed.complete()
 | 
			
		||||
@@ -423,11 +469,6 @@ export class FilterableDropdownComponent
 | 
			
		||||
  @Output()
 | 
			
		||||
  selectionModelChange = new EventEmitter<FilterableDropdownSelectionModel>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  set manyToOne(manyToOne: boolean) {
 | 
			
		||||
    this.selectionModel.manyToOne = manyToOne
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get manyToOne() {
 | 
			
		||||
    return this.selectionModel.manyToOne
 | 
			
		||||
  }
 | 
			
		||||
@@ -484,7 +525,7 @@ export class FilterableDropdownComponent
 | 
			
		||||
    return this.manyToOne
 | 
			
		||||
      ? this.selectionModel.selectionSize() > 1 &&
 | 
			
		||||
          this.selectionModel.getExcludedItems().length == 0
 | 
			
		||||
      : !this.selectionModel.isNoneSelected()
 | 
			
		||||
      : true
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get name(): string {
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -0,0 +1,3 @@
 | 
			
		||||
:host ::ng-deep .list-group-item .mb-3 {
 | 
			
		||||
  margin-bottom: 0 !important;
 | 
			
		||||
}
 | 
			
		||||
@@ -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)
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
@@ -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)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,14 +0,0 @@
 | 
			
		||||
<div class="modal-header">
 | 
			
		||||
  <h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
 | 
			
		||||
  <button type="button" class="btn-close" aria-label="Close" (click)="cancelClicked()">
 | 
			
		||||
  </button>
 | 
			
		||||
</div>
 | 
			
		||||
<div class="modal-body">
 | 
			
		||||
 | 
			
		||||
  <pngx-input-select [items]="objects" [title]="message" [(ngModel)]="selected"></pngx-input-select>
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
<div class="modal-footer">
 | 
			
		||||
  <button type="button" class="btn btn-outline-dark" (click)="cancelClicked()" i18n>Cancel</button>
 | 
			
		||||
  <button type="button" class="btn btn-primary" (click)="selectClicked.emit(selected)" i18n>Select</button>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -1,36 +0,0 @@
 | 
			
		||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
 | 
			
		||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
 | 
			
		||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { NgSelectModule } from '@ng-select/ng-select'
 | 
			
		||||
import { SelectComponent } from '../input/select/select.component'
 | 
			
		||||
import { SelectDialogComponent } from './select-dialog.component'
 | 
			
		||||
 | 
			
		||||
describe('SelectDialogComponent', () => {
 | 
			
		||||
  let component: SelectDialogComponent
 | 
			
		||||
  let fixture: ComponentFixture<SelectDialogComponent>
 | 
			
		||||
  let modal: NgbActiveModal
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    TestBed.configureTestingModule({
 | 
			
		||||
      providers: [NgbActiveModal],
 | 
			
		||||
      imports: [
 | 
			
		||||
        NgSelectModule,
 | 
			
		||||
        FormsModule,
 | 
			
		||||
        ReactiveFormsModule,
 | 
			
		||||
        SelectDialogComponent,
 | 
			
		||||
        SelectComponent,
 | 
			
		||||
      ],
 | 
			
		||||
    }).compileComponents()
 | 
			
		||||
 | 
			
		||||
    modal = TestBed.inject(NgbActiveModal)
 | 
			
		||||
    fixture = TestBed.createComponent(SelectDialogComponent)
 | 
			
		||||
    component = fixture.componentInstance
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should close modal on cancel', () => {
 | 
			
		||||
    const closeSpy = jest.spyOn(modal, 'close')
 | 
			
		||||
    component.cancelClicked()
 | 
			
		||||
    expect(closeSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
@@ -1,33 +0,0 @@
 | 
			
		||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
 | 
			
		||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
 | 
			
		||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { ObjectWithId } from 'src/app/data/object-with-id'
 | 
			
		||||
import { SelectComponent } from '../input/select/select.component'
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'pngx-select-dialog',
 | 
			
		||||
  templateUrl: './select-dialog.component.html',
 | 
			
		||||
  styleUrls: ['./select-dialog.component.scss'],
 | 
			
		||||
  imports: [SelectComponent, FormsModule, ReactiveFormsModule],
 | 
			
		||||
})
 | 
			
		||||
export class SelectDialogComponent {
 | 
			
		||||
  constructor(public activeModal: NgbActiveModal) {}
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  public selectClicked = new EventEmitter()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  title = $localize`Select`
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  message = $localize`Please select an object`
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  objects: ObjectWithId[] = []
 | 
			
		||||
 | 
			
		||||
  selected: number
 | 
			
		||||
 | 
			
		||||
  cancelClicked() {
 | 
			
		||||
    this.activeModal.close()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,68 @@
 | 
			
		||||
<div class="modal-header">
 | 
			
		||||
  <h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
 | 
			
		||||
  <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
 | 
			
		||||
</div>
 | 
			
		||||
<div class="modal-body p-0">
 | 
			
		||||
  <ul class="list-group list-group-flush">
 | 
			
		||||
    @if (!shareLinks || shareLinks.length === 0) {
 | 
			
		||||
      <li class="list-group-item fst-italic small text-center text-secondary" i18n>
 | 
			
		||||
        No existing links
 | 
			
		||||
      </li>
 | 
			
		||||
    }
 | 
			
		||||
    @for (link of shareLinks; track link) {
 | 
			
		||||
      <li class="list-group-item">
 | 
			
		||||
        <div class="input-group w-100">
 | 
			
		||||
          <input type="text" class="form-control" aria-label="Share link" [value]="getShareUrl(link)" readonly>
 | 
			
		||||
          @if (link.expiration) {
 | 
			
		||||
            <span class="input-group-text">
 | 
			
		||||
              {{ getDaysRemaining(link) }}
 | 
			
		||||
            </span>
 | 
			
		||||
          }
 | 
			
		||||
          <button type="button" class="btn btn-outline-primary" (click)="copy(link)">
 | 
			
		||||
              @if (copied !== link.id) {
 | 
			
		||||
                <i-bs width="1.2em" height="1.2em" name="clipboard-fill"></i-bs>
 | 
			
		||||
              }
 | 
			
		||||
              @if (copied === link.id) {
 | 
			
		||||
                <i-bs width="1.2em" height="1.2em" name="clipboard-check-fill"></i-bs>
 | 
			
		||||
              }
 | 
			
		||||
              <span class="visually-hidden" i18n>Copy</span>
 | 
			
		||||
          </button>
 | 
			
		||||
          @if (canShare(link)) {
 | 
			
		||||
            <button type="button" class="btn btn-outline-primary" (click)="share(link)">
 | 
			
		||||
              <i-bs width="1.2em" height="1.2em" name="box-arrow-up"></i-bs><span class="visually-hidden" i18n>Share</span>
 | 
			
		||||
            </button>
 | 
			
		||||
          }
 | 
			
		||||
          <button type="button" class="btn btn-outline-danger" (click)="delete(link)">
 | 
			
		||||
            <i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="visually-hidden" i18n>Delete</span>
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied === link.id" i18n>Copied!</span>
 | 
			
		||||
      </li>
 | 
			
		||||
    }
 | 
			
		||||
  </ul>
 | 
			
		||||
</div>
 | 
			
		||||
<div class="modal-footer">
 | 
			
		||||
  <div class="input-group w-100">
 | 
			
		||||
    <div class="form-check form-switch ms-auto">
 | 
			
		||||
      <input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
 | 
			
		||||
      <label class="form-check-label" for="versionSwitch" i18n>Share archive version</label>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="input-group w-100 mt-2">
 | 
			
		||||
    <label class="input-group-text" for="addLink"><ng-container i18n>Expires</ng-container>:</label>
 | 
			
		||||
    <select class="form-select fs-6" [(ngModel)]="expirationDays">
 | 
			
		||||
      @for (option of EXPIRATION_OPTIONS; track option) {
 | 
			
		||||
        <option [ngValue]="option.value">{{ option.label }}</option>
 | 
			
		||||
      }
 | 
			
		||||
    </select>
 | 
			
		||||
    <button class="btn btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading">
 | 
			
		||||
      @if (loading) {
 | 
			
		||||
        <div class="spinner-border spinner-border-sm me-2" role="status"></div>
 | 
			
		||||
      }
 | 
			
		||||
      @if (!loading) {
 | 
			
		||||
        <i-bs name="plus"></i-bs>
 | 
			
		||||
      }
 | 
			
		||||
      <ng-container i18n>Create</ng-container>
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,3 @@
 | 
			
		||||
.copied-badge {
 | 
			
		||||
    right: 15em;
 | 
			
		||||
}
 | 
			
		||||
@@ -11,17 +11,18 @@ import {
 | 
			
		||||
  tick,
 | 
			
		||||
} from '@angular/core/testing'
 | 
			
		||||
import { By } from '@angular/platform-browser'
 | 
			
		||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
 | 
			
		||||
import { of, throwError } from 'rxjs'
 | 
			
		||||
import { FileVersion, ShareLink } from 'src/app/data/share-link'
 | 
			
		||||
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
 | 
			
		||||
import { ToastService } from 'src/app/services/toast.service'
 | 
			
		||||
import { environment } from 'src/environments/environment'
 | 
			
		||||
import { ShareLinksDropdownComponent } from './share-links-dropdown.component'
 | 
			
		||||
import { ShareLinksDialogComponent } from './share-links-dialog.component'
 | 
			
		||||
 | 
			
		||||
describe('ShareLinksDropdownComponent', () => {
 | 
			
		||||
  let component: ShareLinksDropdownComponent
 | 
			
		||||
  let fixture: ComponentFixture<ShareLinksDropdownComponent>
 | 
			
		||||
describe('ShareLinksDialogComponent', () => {
 | 
			
		||||
  let component: ShareLinksDialogComponent
 | 
			
		||||
  let fixture: ComponentFixture<ShareLinksDialogComponent>
 | 
			
		||||
  let shareLinkService: ShareLinkService
 | 
			
		||||
  let toastService: ToastService
 | 
			
		||||
  let httpController: HttpTestingController
 | 
			
		||||
@@ -30,16 +31,17 @@ describe('ShareLinksDropdownComponent', () => {
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    TestBed.configureTestingModule({
 | 
			
		||||
      imports: [
 | 
			
		||||
        ShareLinksDropdownComponent,
 | 
			
		||||
        ShareLinksDialogComponent,
 | 
			
		||||
        NgxBootstrapIconsModule.pick(allIcons),
 | 
			
		||||
      ],
 | 
			
		||||
      providers: [
 | 
			
		||||
        provideHttpClient(withInterceptorsFromDi()),
 | 
			
		||||
        provideHttpClientTesting(),
 | 
			
		||||
        NgbActiveModal,
 | 
			
		||||
      ],
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    fixture = TestBed.createComponent(ShareLinksDropdownComponent)
 | 
			
		||||
    fixture = TestBed.createComponent(ShareLinksDialogComponent)
 | 
			
		||||
    shareLinkService = TestBed.inject(ShareLinkService)
 | 
			
		||||
    toastService = TestBed.inject(ToastService)
 | 
			
		||||
    httpController = TestBed.inject(HttpTestingController)
 | 
			
		||||
@@ -232,4 +234,11 @@ describe('ShareLinksDropdownComponent', () => {
 | 
			
		||||
      ]
 | 
			
		||||
    ).toBeTruthy()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support close', () => {
 | 
			
		||||
    const activeModal = TestBed.inject(NgbActiveModal)
 | 
			
		||||
    const closeSpy = jest.spyOn(activeModal, 'close')
 | 
			
		||||
    component.close()
 | 
			
		||||
    expect(closeSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import { Clipboard } from '@angular/cdk/clipboard'
 | 
			
		||||
import { Component, Input, OnInit } from '@angular/core'
 | 
			
		||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
 | 
			
		||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 | 
			
		||||
import { first } from 'rxjs'
 | 
			
		||||
import { FileVersion, ShareLink } from 'src/app/data/share-link'
 | 
			
		||||
@@ -10,17 +10,12 @@ import { ToastService } from 'src/app/services/toast.service'
 | 
			
		||||
import { environment } from 'src/environments/environment'
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'pngx-share-links-dropdown',
 | 
			
		||||
  templateUrl: './share-links-dropdown.component.html',
 | 
			
		||||
  styleUrls: ['./share-links-dropdown.component.scss'],
 | 
			
		||||
  imports: [
 | 
			
		||||
    FormsModule,
 | 
			
		||||
    ReactiveFormsModule,
 | 
			
		||||
    NgbDropdownModule,
 | 
			
		||||
    NgxBootstrapIconsModule,
 | 
			
		||||
  ],
 | 
			
		||||
  selector: 'pngx-share-links-dialog',
 | 
			
		||||
  templateUrl: './share-links-dialog.component.html',
 | 
			
		||||
  styleUrls: ['./share-links-dialog.component.scss'],
 | 
			
		||||
  imports: [FormsModule, ReactiveFormsModule, NgxBootstrapIconsModule],
 | 
			
		||||
})
 | 
			
		||||
export class ShareLinksDropdownComponent implements OnInit {
 | 
			
		||||
export class ShareLinksDialogComponent implements OnInit {
 | 
			
		||||
  EXPIRATION_OPTIONS = [
 | 
			
		||||
    { label: $localize`1 day`, value: 1 },
 | 
			
		||||
    { label: $localize`7 days`, value: 7 },
 | 
			
		||||
@@ -41,9 +36,6 @@ export class ShareLinksDropdownComponent implements OnInit {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  disabled: boolean = false
 | 
			
		||||
 | 
			
		||||
  private _hasArchiveVersion: boolean = true
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
@@ -67,6 +59,7 @@ export class ShareLinksDropdownComponent implements OnInit {
 | 
			
		||||
  useArchiveVersion: boolean = true
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    private activeModal: NgbActiveModal,
 | 
			
		||||
    private shareLinkService: ShareLinkService,
 | 
			
		||||
    private toastService: ToastService,
 | 
			
		||||
    private clipboard: Clipboard
 | 
			
		||||
@@ -169,4 +162,8 @@ export class ShareLinksDropdownComponent implements OnInit {
 | 
			
		||||
        },
 | 
			
		||||
      })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  close() {
 | 
			
		||||
    this.activeModal.close()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,70 +0,0 @@
 | 
			
		||||
<div ngbDropdown>
 | 
			
		||||
  <button class="btn btn-sm btn-outline-primary" id="shareLinksDropdown" [disabled]="disabled" ngbDropdownToggle>
 | 
			
		||||
    <i-bs name="link"></i-bs>
 | 
			
		||||
    <div class="d-none d-sm-inline"> <ng-container i18n>Share Links</ng-container></div>
 | 
			
		||||
  </button>
 | 
			
		||||
  <div ngbDropdownMenu aria-labelledby="shareLinksDropdown" class="shadow share-links-dropdown">
 | 
			
		||||
    <ul class="list-group list-group-flush">
 | 
			
		||||
      @if (!shareLinks || shareLinks.length === 0) {
 | 
			
		||||
        <li class="list-group-item fst-italic small text-center text-secondary" i18n>
 | 
			
		||||
          No existing links
 | 
			
		||||
        </li>
 | 
			
		||||
      }
 | 
			
		||||
      @for (link of shareLinks; track link) {
 | 
			
		||||
        <li class="list-group-item">
 | 
			
		||||
          <div class="input-group input-group-sm w-100">
 | 
			
		||||
            <input type="text" class="form-control" aria-label="Share link" [value]="getShareUrl(link)" readonly>
 | 
			
		||||
            @if (link.expiration) {
 | 
			
		||||
              <span class="input-group-text">
 | 
			
		||||
                {{ getDaysRemaining(link) }}
 | 
			
		||||
              </span>
 | 
			
		||||
            }
 | 
			
		||||
            <button type="button" class="btn btn-sm btn-outline-primary" (click)="copy(link)">
 | 
			
		||||
                @if (copied !== link.id) {
 | 
			
		||||
                  <i-bs width="1.2em" height="1.2em" name="clipboard-fill"></i-bs>
 | 
			
		||||
                }
 | 
			
		||||
                @if (copied === link.id) {
 | 
			
		||||
                  <i-bs width="1.2em" height="1.2em" name="clipboard-check-fill"></i-bs>
 | 
			
		||||
                }
 | 
			
		||||
                <span class="visually-hidden" i18n>Copy</span>
 | 
			
		||||
              </button>
 | 
			
		||||
              @if (canShare(link)) {
 | 
			
		||||
                <button type="button" class="btn btn-sm btn-outline-primary" (click)="share(link)">
 | 
			
		||||
                  <i-bs width="1.2em" height="1.2em" name="box-arrow-up"></i-bs><span class="visually-hidden" i18n>Share</span>
 | 
			
		||||
                  </button>
 | 
			
		||||
                }
 | 
			
		||||
                <button type="button" class="btn btn-sm btn-outline-danger" (click)="delete(link)">
 | 
			
		||||
                  <i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="visually-hidden" i18n>Delete</span>
 | 
			
		||||
                  </button>
 | 
			
		||||
                </div>
 | 
			
		||||
                <span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied === link.id" i18n>Copied!</span>
 | 
			
		||||
              </li>
 | 
			
		||||
            }
 | 
			
		||||
            <li class="list-group-item pt-3 pb-2">
 | 
			
		||||
              <div class="input-group input-group-sm w-100">
 | 
			
		||||
                <div class="form-check form-switch ms-auto small">
 | 
			
		||||
                  <input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
 | 
			
		||||
                  <label class="form-check-label" for="versionSwitch" i18n>Share archive version</label>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div class="input-group input-group-sm w-100 mt-2">
 | 
			
		||||
                <label class="input-group-text" for="addLink"><ng-container i18n>Expires</ng-container>:</label>
 | 
			
		||||
                <select class="form-select form-select-sm" [(ngModel)]="expirationDays">
 | 
			
		||||
                  @for (option of EXPIRATION_OPTIONS; track option) {
 | 
			
		||||
                    <option [ngValue]="option.value">{{ option.label }}</option>
 | 
			
		||||
                  }
 | 
			
		||||
                </select>
 | 
			
		||||
                <button class="btn btn-sm btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading">
 | 
			
		||||
                  @if (loading) {
 | 
			
		||||
                    <div class="spinner-border spinner-border-sm me-2" role="status"></div>
 | 
			
		||||
                  }
 | 
			
		||||
                  @if (!loading) {
 | 
			
		||||
                    <i-bs name="plus"></i-bs>
 | 
			
		||||
                  }
 | 
			
		||||
                  <ng-container i18n>Create</ng-container>
 | 
			
		||||
                </button>
 | 
			
		||||
              </div>
 | 
			
		||||
            </li>
 | 
			
		||||
          </ul>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
@@ -1,14 +0,0 @@
 | 
			
		||||
.share-links-dropdown {
 | 
			
		||||
    min-width: 350px;
 | 
			
		||||
 | 
			
		||||
    // correct position on mobile
 | 
			
		||||
    @media (max-width: 575.98px) {
 | 
			
		||||
        &.show {
 | 
			
		||||
            margin-left: -175px !important;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.copied-badge {
 | 
			
		||||
    right: 7.5em;
 | 
			
		||||
}
 | 
			
		||||
@@ -12,7 +12,7 @@
 | 
			
		||||
    </div>
 | 
			
		||||
  } @else {
 | 
			
		||||
    <div class="row row-cols-1 row-cols-md-4 g-3">
 | 
			
		||||
      <div class="col-4">
 | 
			
		||||
      <div class="col">
 | 
			
		||||
        <div class="card bg-light h-100">
 | 
			
		||||
          <div class="card-header">
 | 
			
		||||
            <h6 class="card-title mb-0" i18n>Environment</h6>
 | 
			
		||||
@@ -46,14 +46,14 @@
 | 
			
		||||
              <dd>{{status.database.type}}</dd>
 | 
			
		||||
              <dt i18n>Status</dt>
 | 
			
		||||
              <dd>
 | 
			
		||||
                <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="databaseStatus" triggers="mouseenter:mouseleave">
 | 
			
		||||
                <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="databaseStatus" triggers="click mouseenter:mouseleave">
 | 
			
		||||
                  {{status.database.status}}
 | 
			
		||||
                  @if (status.database.status === 'OK') {
 | 
			
		||||
                    <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
 | 
			
		||||
                  } @else {
 | 
			
		||||
                    <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
 | 
			
		||||
                  }
 | 
			
		||||
                </div>
 | 
			
		||||
                </button>
 | 
			
		||||
                <ng-template #databaseStatus>
 | 
			
		||||
                  @if (status.database.status === 'OK') {
 | 
			
		||||
                    {{status.database.url}}
 | 
			
		||||
@@ -64,7 +64,7 @@
 | 
			
		||||
              </dd>
 | 
			
		||||
              <dt i18n>Migration Status</dt>
 | 
			
		||||
              <dd>
 | 
			
		||||
                <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave">
 | 
			
		||||
                <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="migrationStatus" triggers="click mouseenter:mouseleave">
 | 
			
		||||
                  @if (status.database.migration_status.unapplied_migrations.length === 0) {
 | 
			
		||||
                    <ng-container i18n>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
 | 
			
		||||
                  } @else {
 | 
			
		||||
@@ -81,7 +81,7 @@
 | 
			
		||||
                      </ul>
 | 
			
		||||
                    }
 | 
			
		||||
                  </ng-template>
 | 
			
		||||
                </div>
 | 
			
		||||
                </button>
 | 
			
		||||
              </dd>
 | 
			
		||||
            </dl>
 | 
			
		||||
          </div>
 | 
			
		||||
@@ -97,14 +97,14 @@
 | 
			
		||||
            <dl class="card-text">
 | 
			
		||||
              <dt i18n>Redis Status</dt>
 | 
			
		||||
              <dd>
 | 
			
		||||
                <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="redisStatus" triggers="mouseenter:mouseleave">
 | 
			
		||||
                <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="redisStatus" triggers="click mouseenter:mouseleave">
 | 
			
		||||
                  {{status.tasks.redis_status}}
 | 
			
		||||
                  @if (status.tasks.redis_status === 'OK') {
 | 
			
		||||
                    <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
 | 
			
		||||
                  } @else {
 | 
			
		||||
                    <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
 | 
			
		||||
                  }
 | 
			
		||||
                </div>
 | 
			
		||||
                </button>
 | 
			
		||||
                <ng-template #redisStatus>
 | 
			
		||||
                  @if (status.tasks.redis_status === 'OK') {
 | 
			
		||||
                    {{status.tasks.redis_url}}
 | 
			
		||||
@@ -115,14 +115,14 @@
 | 
			
		||||
              </dd>
 | 
			
		||||
              <dt i18n>Celery Status</dt>
 | 
			
		||||
              <dd>
 | 
			
		||||
                <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="celeryStatus" triggers="mouseenter:mouseleave">
 | 
			
		||||
                <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="celeryStatus" triggers="click mouseenter:mouseleave">
 | 
			
		||||
                  {{status.tasks.celery_status}}
 | 
			
		||||
                  @if (status.tasks.celery_status === 'OK') {
 | 
			
		||||
                    <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
 | 
			
		||||
                  } @else {
 | 
			
		||||
                    <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
 | 
			
		||||
                  }
 | 
			
		||||
                </div>
 | 
			
		||||
                </button>
 | 
			
		||||
                <ng-template #celeryStatus>
 | 
			
		||||
                  @if (status.tasks.celery_status === 'OK') {
 | 
			
		||||
                    {{status.tasks.celery_url}}
 | 
			
		||||
@@ -144,8 +144,8 @@
 | 
			
		||||
          <div class="card-body">
 | 
			
		||||
            <dl class="card-text">
 | 
			
		||||
              <dt i18n>Search Index</dt>
 | 
			
		||||
              <dd>
 | 
			
		||||
                <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave">
 | 
			
		||||
              <dd class="d-flex align-items-center">
 | 
			
		||||
                <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="indexStatus" triggers="click mouseenter:mouseleave">
 | 
			
		||||
                  {{status.tasks.index_status}}
 | 
			
		||||
                  @if (status.tasks.index_status === 'OK') {
 | 
			
		||||
                    @if (isStale(status.tasks.index_last_modified)) {
 | 
			
		||||
@@ -156,7 +156,17 @@
 | 
			
		||||
                  } @else {
 | 
			
		||||
                    <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
 | 
			
		||||
                  }
 | 
			
		||||
                </div>
 | 
			
		||||
                </button>
 | 
			
		||||
                @if (currentUserIsSuperUser) {
 | 
			
		||||
                  @if (isRunning(PaperlessTaskName.IndexOptimize)) {
 | 
			
		||||
                    <div class="spinner-border spinner-border-sm ms-2" role="status"></div>
 | 
			
		||||
                  } @else {
 | 
			
		||||
                    <button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.IndexOptimize)">
 | 
			
		||||
                      <i-bs name="play-fill"></i-bs> 
 | 
			
		||||
                      <ng-container i18n>Run Task</ng-container>
 | 
			
		||||
                    </button>
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              </dd>
 | 
			
		||||
              <ng-template #indexStatus>
 | 
			
		||||
                @if (status.tasks.index_status === 'OK') {
 | 
			
		||||
@@ -166,8 +176,8 @@
 | 
			
		||||
                }
 | 
			
		||||
              </ng-template>
 | 
			
		||||
              <dt i18n>Classifier</dt>
 | 
			
		||||
              <dd>
 | 
			
		||||
                <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave">
 | 
			
		||||
              <dd class="d-flex align-items-center">
 | 
			
		||||
                <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="classifierStatus" triggers="click mouseenter:mouseleave">
 | 
			
		||||
                  {{status.tasks.classifier_status}}
 | 
			
		||||
                  @if (status.tasks.classifier_status === 'OK') {
 | 
			
		||||
                    @if (isStale(status.tasks.classifier_last_trained)) {
 | 
			
		||||
@@ -180,7 +190,17 @@
 | 
			
		||||
                    [class.text-danger]="status.tasks.classifier_status === SystemStatusItemStatus.ERROR"
 | 
			
		||||
                    [class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING"></i-bs>
 | 
			
		||||
                  }
 | 
			
		||||
                </div>
 | 
			
		||||
                </button>
 | 
			
		||||
                @if (currentUserIsSuperUser) {
 | 
			
		||||
                  @if (isRunning(PaperlessTaskName.TrainClassifier)) {
 | 
			
		||||
                    <div class="spinner-border spinner-border-sm ms-2" role="status"></div>
 | 
			
		||||
                  } @else {
 | 
			
		||||
                    <button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.TrainClassifier)">
 | 
			
		||||
                      <i-bs name="play-fill"></i-bs> 
 | 
			
		||||
                      <ng-container i18n>Run Task</ng-container>
 | 
			
		||||
                    </button>
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              </dd>
 | 
			
		||||
              <ng-template #classifierStatus>
 | 
			
		||||
                @if (status.tasks.classifier_status === 'OK') {
 | 
			
		||||
@@ -190,8 +210,8 @@
 | 
			
		||||
                }
 | 
			
		||||
              </ng-template>
 | 
			
		||||
              <dt i18n>Sanity Checker</dt>
 | 
			
		||||
              <dd>
 | 
			
		||||
                <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="sanityCheckerStatus" triggers="mouseenter:mouseleave">
 | 
			
		||||
              <dd class="d-flex align-items-center">
 | 
			
		||||
                <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="sanityCheckerStatus" triggers="click mouseenter:mouseleave">
 | 
			
		||||
                  {{status.tasks.sanity_check_status}}
 | 
			
		||||
                  @if (status.tasks.sanity_check_status === 'OK') {
 | 
			
		||||
                    @if (isStale(status.tasks.sanity_check_last_run)) {
 | 
			
		||||
@@ -204,7 +224,17 @@
 | 
			
		||||
                    [class.text-danger]="status.tasks.sanity_check_status === SystemStatusItemStatus.ERROR"
 | 
			
		||||
                    [class.text-warning]="status.tasks.sanity_check_status === SystemStatusItemStatus.WARNING"></i-bs>
 | 
			
		||||
                  }
 | 
			
		||||
                </div>
 | 
			
		||||
                </button>
 | 
			
		||||
                @if (currentUserIsSuperUser) {
 | 
			
		||||
                  @if (isRunning(PaperlessTaskName.SanityCheck)) {
 | 
			
		||||
                    <div class="spinner-border spinner-border-sm ms-2" role="status"></div>
 | 
			
		||||
                  } @else {
 | 
			
		||||
                    <button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.SanityCheck)">
 | 
			
		||||
                      <i-bs name="play-fill"></i-bs> 
 | 
			
		||||
                      <ng-container i18n>Run Task</ng-container>
 | 
			
		||||
                    </button>
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              </dd>
 | 
			
		||||
              <ng-template #sanityCheckerStatus>
 | 
			
		||||
                @if (status.tasks.sanity_check_status === 'OK') {
 | 
			
		||||
@@ -221,7 +251,7 @@
 | 
			
		||||
  }
 | 
			
		||||
</div>
 | 
			
		||||
<div class="modal-footer">
 | 
			
		||||
  <button class="btn btn-sm btn-outline-secondary" (click)="copy()">
 | 
			
		||||
  <button class="btn btn-sm d-flex align-items-center btn-dark btn btn-sm d-flex align-items-center btn-dark btn-outline-secondary" (click)="copy()">
 | 
			
		||||
    @if (!copied) {
 | 
			
		||||
      <i-bs name="clipboard-fill"></i-bs> 
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,3 @@
 | 
			
		||||
.border-primary {
 | 
			
		||||
  --bs-border-color: var(--bs-primary);
 | 
			
		||||
.btn.small {
 | 
			
		||||
  font-size: 0.75rem;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -9,11 +9,16 @@ import {
 | 
			
		||||
} from '@angular/core/testing'
 | 
			
		||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
 | 
			
		||||
import { of, throwError } from 'rxjs'
 | 
			
		||||
import { PaperlessTaskName } from 'src/app/data/paperless-task'
 | 
			
		||||
import {
 | 
			
		||||
  InstallType,
 | 
			
		||||
  SystemStatus,
 | 
			
		||||
  SystemStatusItemStatus,
 | 
			
		||||
} from 'src/app/data/system-status'
 | 
			
		||||
import { SystemStatusService } from 'src/app/services/system-status.service'
 | 
			
		||||
import { TasksService } from 'src/app/services/tasks.service'
 | 
			
		||||
import { ToastService } from 'src/app/services/toast.service'
 | 
			
		||||
import { SystemStatusDialogComponent } from './system-status-dialog.component'
 | 
			
		||||
 | 
			
		||||
const status: SystemStatus = {
 | 
			
		||||
@@ -54,6 +59,9 @@ describe('SystemStatusDialogComponent', () => {
 | 
			
		||||
  let component: SystemStatusDialogComponent
 | 
			
		||||
  let fixture: ComponentFixture<SystemStatusDialogComponent>
 | 
			
		||||
  let clipboard: Clipboard
 | 
			
		||||
  let tasksService: TasksService
 | 
			
		||||
  let systemStatusService: SystemStatusService
 | 
			
		||||
  let toastService: ToastService
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await TestBed.configureTestingModule({
 | 
			
		||||
@@ -72,6 +80,9 @@ describe('SystemStatusDialogComponent', () => {
 | 
			
		||||
    component = fixture.componentInstance
 | 
			
		||||
    component.status = status
 | 
			
		||||
    clipboard = TestBed.inject(Clipboard)
 | 
			
		||||
    tasksService = TestBed.inject(TasksService)
 | 
			
		||||
    systemStatusService = TestBed.inject(SystemStatusService)
 | 
			
		||||
    toastService = TestBed.inject(ToastService)
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
@@ -98,4 +109,37 @@ describe('SystemStatusDialogComponent', () => {
 | 
			
		||||
    expect(component.isStale(date.toISOString())).toBeTruthy()
 | 
			
		||||
    expect(component.isStale(date.toISOString(), 26)).toBeFalsy()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should check if task is running', () => {
 | 
			
		||||
    component.runTask(PaperlessTaskName.IndexOptimize)
 | 
			
		||||
    expect(component.isRunning(PaperlessTaskName.IndexOptimize)).toBeTruthy()
 | 
			
		||||
    expect(component.isRunning(PaperlessTaskName.SanityCheck)).toBeFalsy()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support running tasks, refresh status and show toasts', () => {
 | 
			
		||||
    const toastSpy = jest.spyOn(toastService, 'showInfo')
 | 
			
		||||
    const toastErrorSpy = jest.spyOn(toastService, 'showError')
 | 
			
		||||
    const getStatusSpy = jest.spyOn(systemStatusService, 'get')
 | 
			
		||||
    const runSpy = jest.spyOn(tasksService, 'run')
 | 
			
		||||
 | 
			
		||||
    // fail first
 | 
			
		||||
    runSpy.mockReturnValue(throwError(() => new Error('error')))
 | 
			
		||||
    component.runTask(PaperlessTaskName.IndexOptimize)
 | 
			
		||||
    expect(runSpy).toHaveBeenCalledWith(PaperlessTaskName.IndexOptimize)
 | 
			
		||||
    expect(toastErrorSpy).toHaveBeenCalledWith(
 | 
			
		||||
      `Failed to start task ${PaperlessTaskName.IndexOptimize}, see the logs for more details`,
 | 
			
		||||
      expect.any(Error)
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    // succeed
 | 
			
		||||
    runSpy.mockReturnValue(of({}))
 | 
			
		||||
    getStatusSpy.mockReturnValue(of(status))
 | 
			
		||||
    component.runTask(PaperlessTaskName.IndexOptimize)
 | 
			
		||||
    expect(runSpy).toHaveBeenCalledWith(PaperlessTaskName.IndexOptimize)
 | 
			
		||||
 | 
			
		||||
    expect(getStatusSpy).toHaveBeenCalled()
 | 
			
		||||
    expect(toastSpy).toHaveBeenCalledWith(
 | 
			
		||||
      `Task ${PaperlessTaskName.IndexOptimize} started`
 | 
			
		||||
    )
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -7,12 +7,17 @@ import {
 | 
			
		||||
  NgbProgressbarModule,
 | 
			
		||||
} from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 | 
			
		||||
import { PaperlessTaskName } from 'src/app/data/paperless-task'
 | 
			
		||||
import {
 | 
			
		||||
  SystemStatus,
 | 
			
		||||
  SystemStatusItemStatus,
 | 
			
		||||
} from 'src/app/data/system-status'
 | 
			
		||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
 | 
			
		||||
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
 | 
			
		||||
import { PermissionsService } from 'src/app/services/permissions.service'
 | 
			
		||||
import { SystemStatusService } from 'src/app/services/system-status.service'
 | 
			
		||||
import { TasksService } from 'src/app/services/tasks.service'
 | 
			
		||||
import { ToastService } from 'src/app/services/toast.service'
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'pngx-system-status-dialog',
 | 
			
		||||
@@ -30,13 +35,24 @@ import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
 | 
			
		||||
})
 | 
			
		||||
export class SystemStatusDialogComponent {
 | 
			
		||||
  public SystemStatusItemStatus = SystemStatusItemStatus
 | 
			
		||||
  public PaperlessTaskName = PaperlessTaskName
 | 
			
		||||
  public status: SystemStatus
 | 
			
		||||
 | 
			
		||||
  public copied: boolean = false
 | 
			
		||||
 | 
			
		||||
  private runningTasks: Set<PaperlessTaskName> = new Set()
 | 
			
		||||
 | 
			
		||||
  get currentUserIsSuperUser(): boolean {
 | 
			
		||||
    return this.permissionsService.isSuperUser()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    public activeModal: NgbActiveModal,
 | 
			
		||||
    private clipboard: Clipboard
 | 
			
		||||
    private clipboard: Clipboard,
 | 
			
		||||
    private systemStatusService: SystemStatusService,
 | 
			
		||||
    private tasksService: TasksService,
 | 
			
		||||
    private toastService: ToastService,
 | 
			
		||||
    private permissionsService: PermissionsService
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  public close() {
 | 
			
		||||
@@ -56,4 +72,30 @@ export class SystemStatusDialogComponent {
 | 
			
		||||
    const now = new Date()
 | 
			
		||||
    return now.getTime() - date.getTime() > hours * 60 * 60 * 1000
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public isRunning(taskName: PaperlessTaskName): boolean {
 | 
			
		||||
    return this.runningTasks.has(taskName)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public runTask(taskName: PaperlessTaskName) {
 | 
			
		||||
    this.runningTasks.add(taskName)
 | 
			
		||||
    this.toastService.showInfo(`Task ${taskName} started`)
 | 
			
		||||
    this.tasksService.run(taskName).subscribe({
 | 
			
		||||
      next: () => {
 | 
			
		||||
        this.runningTasks.delete(taskName)
 | 
			
		||||
        this.systemStatusService.get().subscribe({
 | 
			
		||||
          next: (status) => {
 | 
			
		||||
            this.status = status
 | 
			
		||||
          },
 | 
			
		||||
        })
 | 
			
		||||
      },
 | 
			
		||||
      error: (err) => {
 | 
			
		||||
        this.runningTasks.delete(taskName)
 | 
			
		||||
        this.toastService.showError(
 | 
			
		||||
          `Failed to start task ${taskName}, see the logs for more details`,
 | 
			
		||||
          err
 | 
			
		||||
        )
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -33,7 +33,7 @@
 | 
			
		||||
                }
 | 
			
		||||
                <div class="row">
 | 
			
		||||
                    <div class="col offset-sm-3">
 | 
			
		||||
                    <button class="btn btn-sm btn-outline-dark" (click)="copyError(toast.error)">
 | 
			
		||||
                    <button class="btn btn-sm btn-outline-secondary" (click)="copyError(toast.error)">
 | 
			
		||||
                        @if (!copied) {
 | 
			
		||||
                        <i-bs name="clipboard"></i-bs> 
 | 
			
		||||
                        }
 | 
			
		||||
@@ -48,9 +48,9 @@
 | 
			
		||||
            </details>
 | 
			
		||||
            }
 | 
			
		||||
            @if (toast.action) {
 | 
			
		||||
            <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)="closed.emit(toast); toast.action()">{{toast.actionName}}</button></p>
 | 
			
		||||
            }
 | 
			
		||||
        </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>
 | 
			
		||||
</ngb-toast>
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,7 @@ export class ToastComponent {
 | 
			
		||||
 | 
			
		||||
  @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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,3 @@
 | 
			
		||||
@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>
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -34,6 +34,17 @@
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      <ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
 | 
			
		||||
        @if (!settingsService.offerTour() && savedViewService.allViews.length === 0) {
 | 
			
		||||
          <div class="col">
 | 
			
		||||
            <div class="card shadow-sm bg-light opacity-50">
 | 
			
		||||
              <div class="card-body">
 | 
			
		||||
                <div class="text-center">
 | 
			
		||||
                  <p class="mb-0 fst-italic"><i-bs name="info-circle" class="me-2"></i-bs><ng-container i18n>Hint: saved views can be created from the <a routerLink="/documents">documents list</a></ng-container></p>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        }
 | 
			
		||||
        @for (v of dashboardViews; track v.id) {
 | 
			
		||||
          <div class="col">
 | 
			
		||||
            <pngx-saved-view-widget
 | 
			
		||||
@@ -49,12 +60,8 @@
 | 
			
		||||
  </div>
 | 
			
		||||
  <div class="col-12 col-lg-4 col-xl-3 col-sidebar">
 | 
			
		||||
    <div class="row row-cols-1 g-4 mb-4 sticky-lg-top z-0">
 | 
			
		||||
      <div class="col">
 | 
			
		||||
        <pngx-statistics-widget *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }"></pngx-statistics-widget>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="col">
 | 
			
		||||
      <pngx-upload-file-widget></pngx-upload-file-widget>
 | 
			
		||||
      </div>
 | 
			
		||||
      <pngx-statistics-widget *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }"></pngx-statistics-widget>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -105,6 +105,7 @@ describe('DashboardComponent', () => {
 | 
			
		||||
                results: saved_views,
 | 
			
		||||
              }),
 | 
			
		||||
            dashboardViews: saved_views.filter((v) => v.show_on_dashboard),
 | 
			
		||||
            allViews: saved_views,
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        provideHttpClient(withInterceptorsFromDi()),
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,8 @@ import {
 | 
			
		||||
  moveItemInArray,
 | 
			
		||||
} from '@angular/cdk/drag-drop'
 | 
			
		||||
import { Component } from '@angular/core'
 | 
			
		||||
import { RouterModule } from '@angular/router'
 | 
			
		||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 | 
			
		||||
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
 | 
			
		||||
import { SavedView } from 'src/app/data/saved-view'
 | 
			
		||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
 | 
			
		||||
@@ -35,6 +37,8 @@ import { WelcomeWidgetComponent } from './widgets/welcome-widget/welcome-widget.
 | 
			
		||||
    IfPermissionsDirective,
 | 
			
		||||
    DragDropModule,
 | 
			
		||||
    TourNgBootstrapModule,
 | 
			
		||||
    NgxBootstrapIconsModule,
 | 
			
		||||
    RouterModule,
 | 
			
		||||
  ],
 | 
			
		||||
})
 | 
			
		||||
export class DashboardComponent extends ComponentWithPermissions {
 | 
			
		||||
 
 | 
			
		||||
@@ -55,7 +55,7 @@
 | 
			
		||||
                    }
 | 
			
		||||
                    @case (DisplayField.TAGS) {
 | 
			
		||||
                      @for (tagID of doc.tags; track tagID) {
 | 
			
		||||
                        <pngx-tag [tagID]="tagID" class="ms-1" (click)="clickTag(tagID, $event)" [clickable]="true" linkTitle="Filter by tag" i18n-title></pngx-tag>
 | 
			
		||||
                        <pngx-tag [tagID]="tagID" class="ms-1 fs-6" (click)="clickTag(tagID, $event)" [clickable]="true" linkTitle="Filter by tag" i18n-title></pngx-tag>
 | 
			
		||||
                      }
 | 
			
		||||
                    }
 | 
			
		||||
                    @case (DisplayField.DOCUMENT_TYPE) {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,15 @@
 | 
			
		||||
<pngx-widget-frame title="Upload new documents" i18n-title *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }">
 | 
			
		||||
<pngx-widget-frame *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }" [cardless]="true">
 | 
			
		||||
  <div content tourAnchor="tour.upload-widget">
 | 
			
		||||
    <form class="justify-content-center d-flex flex-column align-items-center py-3 px-2">
 | 
			
		||||
      <span class="text-muted" i18n>Drop documents anywhere or</span>
 | 
			
		||||
      <button type="button" class="btn btn-sm btn-outline-primary mt-3" (click)="fileUpload.click()" i18n>Browse files</button>
 | 
			
		||||
    <form class="justify-content-center d-flex flex-column align-items-center">
 | 
			
		||||
      <button type="button" class="btn btn-outline-dark bg-light shadow-sm w-100 h-100 pt-3 pb-3" (click)="fileUpload.click()">
 | 
			
		||||
        <i-bs class="text-primary" name="plus-circle"></i-bs> 
 | 
			
		||||
        <span class="text-primary" i18n>Upload documents</span>
 | 
			
		||||
        <div class="text-muted smaller fst-italic" i18n>or drop files anywhere</div>
 | 
			
		||||
      </button>
 | 
			
		||||
      <input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
 | 
			
		||||
    </form>
 | 
			
		||||
    @if (getStatus().length > 0) {
 | 
			
		||||
    <div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none max-vh100-40" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
 | 
			
		||||
    <div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
 | 
			
		||||
      <div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll">
 | 
			
		||||
        <div class="card shadow-sm consumer-status-card">
 | 
			
		||||
          <div class="card-body">
 | 
			
		||||
@@ -30,24 +33,6 @@
 | 
			
		||||
                    <ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
 | 
			
		||||
                  </div>
 | 
			
		||||
                }
 | 
			
		||||
                @if (getStatusHidden().length) {
 | 
			
		||||
                  <div class="alerts-hidden">
 | 
			
		||||
                    @if (!alertsExpanded) {
 | 
			
		||||
                      <p class="mt-3 mb-0 text-center">
 | 
			
		||||
                        <span i18n="This is shown as a summary line when there are more than 5 document in the processing pipeline.">{getStatusHidden().length, plural, =1 {One more document} other {{{getStatusHidden().length}} more documents}}</span>
 | 
			
		||||
                         • 
 | 
			
		||||
                        <a [routerLink]="[]" (click)="alertsExpanded = !alertsExpanded" aria-controls="hiddenAlerts" [attr.aria-expanded]="alertsExpanded" i18n>Show all</a>
 | 
			
		||||
                      </p>
 | 
			
		||||
                    }
 | 
			
		||||
                    <div #hiddenAlerts="ngbCollapse" [ngbCollapse]="!alertsExpanded" (ngbCollapseChange)="alertsExpanded = $event">
 | 
			
		||||
                      @for (status of getStatusHidden(); track status) {
 | 
			
		||||
                        <div>
 | 
			
		||||
                          <ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
 | 
			
		||||
                        </div>
 | 
			
		||||
                      }
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                }
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,13 @@
 | 
			
		||||
form {
 | 
			
		||||
  position: relative;
 | 
			
		||||
:host ::ng-deep i-bs svg {
 | 
			
		||||
  margin-top: -3px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-outline-dark {
 | 
			
		||||
  --bs-btn-border-color: var(--bs-border-color-translucent);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.smaller {
 | 
			
		||||
  font-size: 0.75rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.alert-heading {
 | 
			
		||||
@@ -40,6 +48,10 @@ form {
 | 
			
		||||
  background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.max-vh100-40 {
 | 
			
		||||
  max-height: calc(100vh - 40px);
 | 
			
		||||
.consumer-status-list {
 | 
			
		||||
  max-height: calc(100vh - 312px); // e.g. below the upload button, mobile
 | 
			
		||||
 | 
			
		||||
  @media screen and (min-width: 768px) {
 | 
			
		||||
    max-height: calc(100vh - 208px); // e.g. below the upload button
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,6 @@ import {
 | 
			
		||||
} from '@angular/core/testing'
 | 
			
		||||
import { By } from '@angular/platform-browser'
 | 
			
		||||
import { RouterTestingModule } from '@angular/router/testing'
 | 
			
		||||
import { NgbAlert, NgbCollapse } from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
 | 
			
		||||
import { routes } from 'src/app/app-routing.module'
 | 
			
		||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
 | 
			
		||||
@@ -116,20 +115,6 @@ describe('UploadFileWidgetComponent', () => {
 | 
			
		||||
    expect(component.getStatusColor(successStatus)).toEqual('success')
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should enforce a maximum number of alerts', () => {
 | 
			
		||||
    mockConsumerStatuses(websocketStatusService)
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    // 5 total, 1 hidden
 | 
			
		||||
    expect(fixture.debugElement.queryAll(By.directive(NgbAlert))).toHaveLength(
 | 
			
		||||
      6
 | 
			
		||||
    )
 | 
			
		||||
    expect(
 | 
			
		||||
      fixture.debugElement
 | 
			
		||||
        .query(By.directive(NgbCollapse))
 | 
			
		||||
        .queryAll(By.directive(NgbAlert))
 | 
			
		||||
    ).toHaveLength(1)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should allow dismissing an alert', () => {
 | 
			
		||||
    const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
 | 
			
		||||
    component.dismiss(new FileStatus())
 | 
			
		||||
@@ -138,7 +123,6 @@ describe('UploadFileWidgetComponent', () => {
 | 
			
		||||
 | 
			
		||||
  it('should allow dismissing completed alerts', fakeAsync(() => {
 | 
			
		||||
    mockConsumerStatuses(websocketStatusService)
 | 
			
		||||
    component.alertsExpanded = true
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(component, 'getStatusCompleted')
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,6 @@ import { RouterModule } from '@angular/router'
 | 
			
		||||
import {
 | 
			
		||||
  NgbAlert,
 | 
			
		||||
  NgbAlertModule,
 | 
			
		||||
  NgbCollapseModule,
 | 
			
		||||
  NgbProgressbarModule,
 | 
			
		||||
} from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 | 
			
		||||
@@ -21,8 +20,6 @@ import {
 | 
			
		||||
} from 'src/app/services/websocket-status.service'
 | 
			
		||||
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
 | 
			
		||||
 | 
			
		||||
const MAX_ALERTS = 5
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'pngx-upload-file-widget',
 | 
			
		||||
  templateUrl: './upload-file-widget.component.html',
 | 
			
		||||
@@ -34,15 +31,12 @@ const MAX_ALERTS = 5
 | 
			
		||||
    NgTemplateOutlet,
 | 
			
		||||
    RouterModule,
 | 
			
		||||
    NgbAlertModule,
 | 
			
		||||
    NgbCollapseModule,
 | 
			
		||||
    NgbProgressbarModule,
 | 
			
		||||
    NgxBootstrapIconsModule,
 | 
			
		||||
    TourNgBootstrapModule,
 | 
			
		||||
  ],
 | 
			
		||||
})
 | 
			
		||||
export class UploadFileWidgetComponent extends ComponentWithPermissions {
 | 
			
		||||
  alertsExpanded = false
 | 
			
		||||
 | 
			
		||||
  @ViewChildren(NgbAlert) alerts: QueryList<NgbAlert>
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
@@ -54,7 +48,7 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getStatus() {
 | 
			
		||||
    return this.websocketStatusService.getConsumerStatus().slice(0, MAX_ALERTS)
 | 
			
		||||
    return this.websocketStatusService.getConsumerStatus()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getStatusSummary() {
 | 
			
		||||
@@ -77,13 +71,6 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getStatusHidden() {
 | 
			
		||||
    if (this.websocketStatusService.getConsumerStatus().length < MAX_ALERTS)
 | 
			
		||||
      return []
 | 
			
		||||
    else
 | 
			
		||||
      return this.websocketStatusService.getConsumerStatus().slice(MAX_ALERTS)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getStatusUploading() {
 | 
			
		||||
    return this.websocketStatusService.getConsumerStatus(
 | 
			
		||||
      FileStatusPhase.UPLOADING
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
<div class="card shadow-sm bg-light fade" [class.show]="show" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent">
 | 
			
		||||
@if (!cardless) {
 | 
			
		||||
  <div class="card shadow-sm bg-light fade" [class.show]="show" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent">
 | 
			
		||||
    <div class="card-header">
 | 
			
		||||
      <div class="d-flex justify-content-between align-items-center">
 | 
			
		||||
        <div class="d-flex">
 | 
			
		||||
@@ -15,9 +16,17 @@
 | 
			
		||||
        }
 | 
			
		||||
        <ng-content select="[header-buttons]"></ng-content>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="card-body text-dark">
 | 
			
		||||
    <ng-content select="[content]"></ng-content>
 | 
			
		||||
      <ng-container [ngTemplateOutlet]="content"></ng-container>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
  </div>
 | 
			
		||||
} @else {
 | 
			
		||||
  <div class="fade" [class.show]="show">
 | 
			
		||||
    <ng-container [ngTemplateOutlet]="content"></ng-container>
 | 
			
		||||
  </div>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
<ng-template #content>
 | 
			
		||||
  <ng-content select="[content]"></ng-content>
 | 
			
		||||
</ng-template>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import { DragDropModule } from '@angular/cdk/drag-drop'
 | 
			
		||||
import { NgTemplateOutlet } from '@angular/common'
 | 
			
		||||
import { AfterViewInit, Component, Input } from '@angular/core'
 | 
			
		||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 | 
			
		||||
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
 | 
			
		||||
@@ -7,7 +8,7 @@ import { LoadingComponentWithPermissions } from 'src/app/components/loading-comp
 | 
			
		||||
  selector: 'pngx-widget-frame',
 | 
			
		||||
  templateUrl: './widget-frame.component.html',
 | 
			
		||||
  styleUrls: ['./widget-frame.component.scss'],
 | 
			
		||||
  imports: [DragDropModule, NgxBootstrapIconsModule],
 | 
			
		||||
  imports: [DragDropModule, NgxBootstrapIconsModule, NgTemplateOutlet],
 | 
			
		||||
})
 | 
			
		||||
export class WidgetFrameComponent
 | 
			
		||||
  extends LoadingComponentWithPermissions
 | 
			
		||||
@@ -26,6 +27,9 @@ export class WidgetFrameComponent
 | 
			
		||||
  @Input()
 | 
			
		||||
  draggable: any
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  cardless: boolean = false
 | 
			
		||||
 | 
			
		||||
  ngAfterViewInit(): void {
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this.show = true
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user