mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Compare commits
	
		
			24 Commits
		
	
	
		
			v2.15.0
			...
			feature-pa
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | a0a9e0c6c8 | ||
|   | 1c7c703e5f | ||
|   | 53e9e910d8 | ||
|   | 9fe611a24c | ||
|   | 31e71aab83 | ||
|   | 7e7ce97d10 | ||
|   | e06adc58c7 | ||
|   | 7170ac31b7 | ||
|   | a0aa78c788 | ||
|   | f3438914cc | ||
|   | e1b944ce6b | ||
|   | 0add5aab0e | ||
|   | c9adc74fa9 | ||
|   | 32abfbfc0a | ||
|   | 7f02f782f4 | ||
|   | 7c3f011e84 | ||
|   | 5c68177960 | ||
|   | 7a4666783e | ||
|   | 372825c271 | ||
|   | abfddd6931 | ||
|   | b3d49dbf12 | ||
|   | 673839265d | ||
|   | f31df22ab6 | ||
|   | f897447a65 | 
							
								
								
									
										16
									
								
								.codecov.yml
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								.codecov.yml
									
									
									
									
									
								
							| @@ -1,18 +1,18 @@ | |||||||
| codecov: | codecov: | ||||||
|   require_ci_to_pass: true |   require_ci_to_pass: true | ||||||
|   # https://docs.codecov.com/docs/components | # https://docs.codecov.com/docs/flags#recommended-automatic-flag-management | ||||||
| component_management: | # Require each flag to have 1 upload before notification | ||||||
|   individual_components: | flag_management: | ||||||
|     - component_id: backend |   individual_flags: | ||||||
|  |     - name: backend | ||||||
|       paths: |       paths: | ||||||
|         - src/** |         - src/ | ||||||
|     - component_id: frontend |     - name: frontend | ||||||
|       paths: |       paths: | ||||||
|         - src-ui/** |         - src-ui/ | ||||||
| # https://docs.codecov.com/docs/pull-request-comments | # https://docs.codecov.com/docs/pull-request-comments | ||||||
| # codecov will only comment if coverage changes | # codecov will only comment if coverage changes | ||||||
| comment: | comment: | ||||||
|   layout: "header, diff, components, flags, files" |  | ||||||
|   require_changes: true |   require_changes: true | ||||||
|   # https://docs.codecov.com/docs/javascript-bundle-analysis |   # https://docs.codecov.com/docs/javascript-bundle-analysis | ||||||
|   require_bundle_changes: true |   require_bundle_changes: true | ||||||
|   | |||||||
| @@ -76,15 +76,18 @@ RUN set -eux \ | |||||||
|     && apt-get update \ |     && apt-get update \ | ||||||
|     && apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES} |     && apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES} | ||||||
|  |  | ||||||
| ARG PYTHON_PACKAGES="ca-certificates" | ARG PYTHON_PACKAGES="\ | ||||||
|  |   python3 \ | ||||||
|  |   python3-pip \ | ||||||
|  |   python3-wheel \ | ||||||
|  |   pipenv \ | ||||||
|  |   ca-certificates" | ||||||
|  |  | ||||||
| RUN set -eux \ | RUN set -eux \ | ||||||
|   echo "Installing python packages" \ |   echo "Installing python packages" \ | ||||||
|     && apt-get update \ |     && apt-get update \ | ||||||
|     && apt-get install --yes --quiet ${PYTHON_PACKAGES} |     && apt-get install --yes --quiet ${PYTHON_PACKAGES} | ||||||
|  |  | ||||||
| COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /bin/uv |  | ||||||
|  |  | ||||||
| RUN set -eux \ | RUN set -eux \ | ||||||
|   && echo "Installing pre-built updates" \ |   && echo "Installing pre-built updates" \ | ||||||
|     && echo "Installing qpdf ${QPDF_VERSION}" \ |     && echo "Installing qpdf ${QPDF_VERSION}" \ | ||||||
| @@ -120,15 +123,13 @@ RUN set -eux \ | |||||||
| WORKDIR /usr/src/paperless/src/docker/ | WORKDIR /usr/src/paperless/src/docker/ | ||||||
|  |  | ||||||
| COPY [ \ | COPY [ \ | ||||||
|   "docker/rootfs/etc/ImageMagick-6/paperless-policy.xml", \ |   "docker/imagemagick-policy.xml", \ | ||||||
|   "./" \ |   "./" \ | ||||||
| ] | ] | ||||||
|  |  | ||||||
| RUN set -eux \ | RUN set -eux \ | ||||||
|   && echo "Configuring ImageMagick" \ |   && echo "Configuring ImageMagick" \ | ||||||
|     && mv paperless-policy.xml /etc/ImageMagick-6/policy.xml |     && mv imagemagick-policy.xml /etc/ImageMagick-6/policy.xml | ||||||
|  |  | ||||||
| COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /bin/uv |  | ||||||
|  |  | ||||||
| # Packages needed only for building a few quick Python | # Packages needed only for building a few quick Python | ||||||
| # dependencies | # dependencies | ||||||
| @@ -139,17 +140,18 @@ ARG BUILD_PACKAGES="\ | |||||||
|   libpq-dev \ |   libpq-dev \ | ||||||
|   # https://github.com/PyMySQL/mysqlclient#linux |   # https://github.com/PyMySQL/mysqlclient#linux | ||||||
|   default-libmysqlclient-dev \ |   default-libmysqlclient-dev \ | ||||||
|   pkg-config" |   pkg-config \ | ||||||
|  |   pre-commit" | ||||||
|  |  | ||||||
| # hadolint ignore=DL3042 | # hadolint ignore=DL3042 | ||||||
| RUN --mount=type=cache,target=/root/.cache/uv,id=pip-cache \ | RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \ | ||||||
|   set -eux \ |   set -eux \ | ||||||
|   && echo "Installing build system packages" \ |   && echo "Installing build system packages" \ | ||||||
|     && apt-get update \ |     && apt-get update \ | ||||||
|     && apt-get install --yes --quiet ${BUILD_PACKAGES} |     && apt-get install --yes --quiet ${BUILD_PACKAGES} | ||||||
|  |  | ||||||
| RUN set -eux \ | RUN set -eux \ | ||||||
|   && npm update -g pnpm |   && npm update npm -g | ||||||
|  |  | ||||||
| # add users, setup scripts | # add users, setup scripts | ||||||
| # Mount the compiled frontend to expected location | # Mount the compiled frontend to expected location | ||||||
| @@ -167,6 +169,9 @@ RUN set -eux \ | |||||||
|     && mkdir --parents --verbose /usr/src/paperless/paperless-ngx/.venv \ |     && mkdir --parents --verbose /usr/src/paperless/paperless-ngx/.venv \ | ||||||
|   && echo "Adjusting all permissions" \ |   && echo "Adjusting all permissions" \ | ||||||
|     && chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless |     && chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless | ||||||
|  | #  && echo "Collecting static files" \ | ||||||
|  | #    && gosu paperless python3 manage.py collectstatic --clear --no-input --link \ | ||||||
|  | #    && gosu paperless python3 manage.py compilemessages | ||||||
|  |  | ||||||
| VOLUME ["/usr/src/paperless/paperless-ngx/data", \ | VOLUME ["/usr/src/paperless/paperless-ngx/data", \ | ||||||
|         "/usr/src/paperless/paperless-ngx/media", \ |         "/usr/src/paperless/paperless-ngx/media", \ | ||||||
|   | |||||||
| @@ -1,117 +0,0 @@ | |||||||
| # 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", |     "dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml", | ||||||
|     "service": "paperless-development", |     "service": "paperless-development", | ||||||
|     "workspaceFolder": "/usr/src/paperless/paperless-ngx", |     "workspaceFolder": "/usr/src/paperless/paperless-ngx", | ||||||
|     "postCreateCommand": "/bin/bash -c 'uv sync --group dev && uv run pre-commit install'", |     "postCreateCommand": "pipenv install --dev && pipenv run pre-commit install", | ||||||
|     "customizations": { |     "customizations": { | ||||||
|         "vscode": { |         "vscode": { | ||||||
|           "extensions": [ |           "extensions": [ | ||||||
|   | |||||||
| @@ -43,7 +43,7 @@ services: | |||||||
|     volumes: |     volumes: | ||||||
|       - ..:/usr/src/paperless/paperless-ngx:delegated |       - ..:/usr/src/paperless/paperless-ngx:delegated | ||||||
|       - ../.devcontainer/vscode:/usr/src/paperless/paperless-ngx/.vscode:delegated # VSCode config files |       - ../.devcontainer/vscode:/usr/src/paperless/paperless-ngx/.vscode:delegated # VSCode config files | ||||||
|       - virtualenv:/usr/src/paperless/paperless-ngx/.venv # Virtual environment persisted in volume |       - pipenv:/usr/src/paperless/paperless-ngx/.venv | ||||||
|       - /usr/src/paperless/paperless-ngx/src/documents/static/frontend # Static frontend files exist only in container |       - /usr/src/paperless/paperless-ngx/src/documents/static/frontend # Static frontend files exist only in container | ||||||
|       - /usr/src/paperless/paperless-ngx/src/.pytest_cache |       - /usr/src/paperless/paperless-ngx/src/.pytest_cache | ||||||
|       - /usr/src/paperless/paperless-ngx/.ruff_cache |       - /usr/src/paperless/paperless-ngx/.ruff_cache | ||||||
| @@ -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" |     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: |   gotenberg: | ||||||
|     image: docker.io/gotenberg/gotenberg:8.17 |     image: docker.io/gotenberg/gotenberg:7.10 | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|  |  | ||||||
|     # The Gotenberg Chromium route is used to convert .eml files. We do not |     # The Gotenberg Chromium route is used to convert .eml files. We do not | ||||||
| @@ -80,7 +80,4 @@ services: | |||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|  |  | ||||||
| volumes: | volumes: | ||||||
|   data: |   pipenv: | ||||||
|   media: |  | ||||||
|   redisdata: |  | ||||||
|   virtualenv: |  | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ | |||||||
| 			"label": "Start: Celery Worker", | 			"label": "Start: Celery Worker", | ||||||
| 			"description": "Start the Celery Worker which processes background and consume tasks", | 			"description": "Start the Celery Worker which processes background and consume tasks", | ||||||
| 			"type": "shell", | 			"type": "shell", | ||||||
| 			"command": "uv run celery --app paperless worker -l DEBUG", | 			"command": "pipenv run celery --app paperless worker -l DEBUG", | ||||||
| 			"isBackground": true, | 			"isBackground": true, | ||||||
| 			"options": { | 			"options": { | ||||||
| 				"cwd": "${workspaceFolder}/src" | 				"cwd": "${workspaceFolder}/src" | ||||||
| @@ -33,7 +33,7 @@ | |||||||
| 			"label": "Start: Frontend Angular", | 			"label": "Start: Frontend Angular", | ||||||
| 			"description": "Start the Frontend Angular Dev Server", | 			"description": "Start the Frontend Angular Dev Server", | ||||||
| 			"type": "shell", | 			"type": "shell", | ||||||
| 			"command": "pnpm start", | 			"command": "npm start", | ||||||
| 			"isBackground": true, | 			"isBackground": true, | ||||||
| 			"options": { | 			"options": { | ||||||
| 				"cwd": "${workspaceFolder}/src-ui" | 				"cwd": "${workspaceFolder}/src-ui" | ||||||
| @@ -61,7 +61,7 @@ | |||||||
| 			"label": "Start: Consumer Service (manage.py document_consumer)", | 			"label": "Start: Consumer Service (manage.py document_consumer)", | ||||||
| 			"description": "Start the Consumer Service which processes files from a directory", | 			"description": "Start the Consumer Service which processes files from a directory", | ||||||
| 			"type": "shell", | 			"type": "shell", | ||||||
| 			"command": "uv run python manage.py document_consumer", | 			"command": "pipenv run python manage.py document_consumer", | ||||||
| 			"group": "build", | 			"group": "build", | ||||||
| 			"presentation": { | 			"presentation": { | ||||||
| 				"echo": true, | 				"echo": true, | ||||||
| @@ -80,7 +80,7 @@ | |||||||
| 			"label": "Start: Backend Server (manage.py runserver)", | 			"label": "Start: Backend Server (manage.py runserver)", | ||||||
| 			"description": "Start the Backend Server which serves the Django API and the compiled Angular frontend", | 			"description": "Start the Backend Server which serves the Django API and the compiled Angular frontend", | ||||||
| 			"type": "shell", | 			"type": "shell", | ||||||
| 			"command": "uv run python manage.py runserver", | 			"command": "pipenv run python manage.py runserver", | ||||||
| 			"group": "build", | 			"group": "build", | ||||||
| 			"presentation": { | 			"presentation": { | ||||||
| 				"echo": true, | 				"echo": true, | ||||||
| @@ -99,7 +99,7 @@ | |||||||
| 			"label": "Maintenance: manage.py migrate", | 			"label": "Maintenance: manage.py migrate", | ||||||
| 			"description": "Apply database migrations", | 			"description": "Apply database migrations", | ||||||
| 			"type": "shell", | 			"type": "shell", | ||||||
| 			"command": "uv run python manage.py migrate", | 			"command": "pipenv run python manage.py migrate", | ||||||
| 			"group": "none", | 			"group": "none", | ||||||
| 			"presentation": { | 			"presentation": { | ||||||
| 				"echo": true, | 				"echo": true, | ||||||
| @@ -118,7 +118,7 @@ | |||||||
| 			"label": "Maintenance: Build Documentation", | 			"label": "Maintenance: Build Documentation", | ||||||
| 			"description": "Build the documentation with MkDocs", | 			"description": "Build the documentation with MkDocs", | ||||||
| 			"type": "shell", | 			"type": "shell", | ||||||
| 			"command": "uv run mkdocs build --config-file mkdocs.yml && uv run mkdocs serve", | 			"command": "pipenv run mkdocs build --config-file mkdocs.yml && pipenv run mkdocs serve", | ||||||
| 			"group": "none", | 			"group": "none", | ||||||
| 			"presentation": { | 			"presentation": { | ||||||
| 				"echo": true, | 				"echo": true, | ||||||
| @@ -137,7 +137,7 @@ | |||||||
| 			"label": "Maintenance: manage.py createsuperuser", | 			"label": "Maintenance: manage.py createsuperuser", | ||||||
| 			"description": "Create a superuser", | 			"description": "Create a superuser", | ||||||
| 			"type": "shell", | 			"type": "shell", | ||||||
| 			"command": "uv run python manage.py createsuperuser", | 			"command": "pipenv run python manage.py createsuperuser", | ||||||
| 			"group": "none", | 			"group": "none", | ||||||
| 			"presentation": { | 			"presentation": { | ||||||
| 				"echo": true, | 				"echo": true, | ||||||
| @@ -156,7 +156,7 @@ | |||||||
| 			"label": "Maintenance: recreate .venv", | 			"label": "Maintenance: recreate .venv", | ||||||
| 			"description": "Recreate the python virtual environment and install python dependencies", | 			"description": "Recreate the python virtual environment and install python dependencies", | ||||||
| 			"type": "shell", | 			"type": "shell", | ||||||
| 			"command": "rm -R -v .venv/* || uv install --dev", | 			"command": "rm -R -v .venv/* || pipenv install --dev", | ||||||
| 			"group": "none", | 			"group": "none", | ||||||
| 			"presentation": { | 			"presentation": { | ||||||
| 				"echo": true, | 				"echo": true, | ||||||
| @@ -173,8 +173,8 @@ | |||||||
| 		}, | 		}, | ||||||
| 		{ | 		{ | ||||||
| 			"label": "Maintenance: Install Frontend Dependencies", | 			"label": "Maintenance: Install Frontend Dependencies", | ||||||
| 			"description": "Install frontend (pnpm) dependencies", | 			"description": "Install frontend (npm) dependencies", | ||||||
| 			"type": "pnpm", | 			"type": "npm", | ||||||
| 			"script": "install", | 			"script": "install", | ||||||
| 			"path": "src-ui", | 			"path": "src-ui", | ||||||
| 			"group": "clean", | 			"group": "clean", | ||||||
| @@ -185,7 +185,7 @@ | |||||||
| 			"description": "Clean install frontend dependencies and build the frontend for production", | 			"description": "Clean install frontend dependencies and build the frontend for production", | ||||||
| 			"label": "Maintenance: Compile frontend for production", | 			"label": "Maintenance: Compile frontend for production", | ||||||
| 			"type": "shell", | 			"type": "shell", | ||||||
| 			"command": "pnpm install && ./node_modules/.bin/ng build --configuration production", | 			"command": "npm ci && ./node_modules/.bin/ng build --configuration production", | ||||||
| 			"group": "none", | 			"group": "none", | ||||||
| 			"presentation": { | 			"presentation": { | ||||||
| 				"echo": true, | 				"echo": true, | ||||||
|   | |||||||
| @@ -27,6 +27,9 @@ indent_style = space | |||||||
| [*.md] | [*.md] | ||||||
| indent_style = space | indent_style = space | ||||||
|  |  | ||||||
|  | [Pipfile.lock] | ||||||
|  | indent_style = space | ||||||
|  |  | ||||||
| # Tests don't get a line width restriction.  It's still a good idea to follow | # Tests don't get a line width restriction.  It's still a good idea to follow | ||||||
| # the 79 character rule, but in the interests of clarity, tests often need to | # the 79 character rule, but in the interests of clarity, tests often need to | ||||||
| # violate it. | # violate it. | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1 +0,0 @@ | |||||||
| github: [shamoon, stumpylog] |  | ||||||
							
								
								
									
										66
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										66
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,15 +1,12 @@ | |||||||
| # Please see the documentation for all configuration options: | # https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates#package-ecosystem | ||||||
| # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates |  | ||||||
|  |  | ||||||
| version: 2 | version: 2 | ||||||
| # Required for uv support for now |  | ||||||
| enable-beta-ecosystems: true |  | ||||||
| updates: | updates: | ||||||
|  |  | ||||||
|   # Enable version updates for pnpm |   # Enable version updates for npm | ||||||
|   - package-ecosystem: "npm" |   - package-ecosystem: "npm" | ||||||
|     target-branch: "dev" |     target-branch: "dev" | ||||||
|     # Look for `pnpm-lock.yaml` file in the `/src-ui` directory |     # Look for `package.json` and `lock` files in the `/src-ui` directory | ||||||
|     directory: "/src-ui" |     directory: "/src-ui" | ||||||
|     open-pull-requests-limit: 10 |     open-pull-requests-limit: 10 | ||||||
|     schedule: |     schedule: | ||||||
| @@ -37,8 +34,9 @@ updates: | |||||||
|           - "eslint" |           - "eslint" | ||||||
|  |  | ||||||
|   # Enable version updates for Python |   # Enable version updates for Python | ||||||
|   - package-ecosystem: "uv" |   - package-ecosystem: "pip" | ||||||
|     target-branch: "dev" |     target-branch: "dev" | ||||||
|  |     # Look for a `Pipfile` in the `root` directory | ||||||
|     directory: "/" |     directory: "/" | ||||||
|     # Check for updates once a week |     # Check for updates once a week | ||||||
|     schedule: |     schedule: | ||||||
| @@ -49,13 +47,14 @@ updates: | |||||||
|     # Add reviewers |     # Add reviewers | ||||||
|     reviewers: |     reviewers: | ||||||
|       - "paperless-ngx/backend" |       - "paperless-ngx/backend" | ||||||
|  |     ignore: | ||||||
|  |       - dependency-name: "uvicorn" | ||||||
|     groups: |     groups: | ||||||
|       development: |       development: | ||||||
|         patterns: |         patterns: | ||||||
|           - "*pytest*" |           - "*pytest*" | ||||||
|           - "ruff" |           - "ruff" | ||||||
|           - "mkdocs-material" |           - "mkdocs-material" | ||||||
|           - "pre-commit*" |  | ||||||
|       django: |       django: | ||||||
|         patterns: |         patterns: | ||||||
|           - "*django*" |           - "*django*" | ||||||
| @@ -66,10 +65,6 @@ updates: | |||||||
|         update-types: |         update-types: | ||||||
|           - "minor" |           - "minor" | ||||||
|           - "patch" |           - "patch" | ||||||
|       pre-built: |  | ||||||
|         patterns: |  | ||||||
|           - psycopg* |  | ||||||
|           - zxing-cpp |  | ||||||
|  |  | ||||||
|   # Enable updates for GitHub Actions |   # Enable updates for GitHub Actions | ||||||
|   - package-ecosystem: "github-actions" |   - package-ecosystem: "github-actions" | ||||||
| @@ -90,50 +85,3 @@ updates: | |||||||
|           - "major" |           - "major" | ||||||
|           - "minor" |           - "minor" | ||||||
|           - "patch" |           - "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*" |  | ||||||
|   | |||||||
							
								
								
									
										254
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										254
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -14,7 +14,9 @@ on: | |||||||
|       - 'translations**' |       - 'translations**' | ||||||
|  |  | ||||||
| env: | env: | ||||||
|   DEFAULT_UV_VERSION: "0.6.x" |   # This is the version of pipenv all the steps will use | ||||||
|  |   # If changing this, change Dockerfile | ||||||
|  |   DEFAULT_PIP_ENV_VERSION: "2024.4.1" | ||||||
|   # This is the default version of Python to use in most steps which aren't specific |   # This is the default version of Python to use in most steps which aren't specific | ||||||
|   DEFAULT_PYTHON_VERSION: "3.11" |   DEFAULT_PYTHON_VERSION: "3.11" | ||||||
|  |  | ||||||
| @@ -57,25 +59,24 @@ jobs: | |||||||
|         uses: actions/setup-python@v5 |         uses: actions/setup-python@v5 | ||||||
|         with: |         with: | ||||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} |           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||||
|  |           cache: "pipenv" | ||||||
|  |           cache-dependency-path: 'Pipfile.lock' | ||||||
|       - |       - | ||||||
|         name: Install uv |         name: Install pipenv | ||||||
|         uses: astral-sh/setup-uv@v5 |  | ||||||
|         with: |  | ||||||
|           version: ${{ env.DEFAULT_UV_VERSION }} |  | ||||||
|           enable-cache: true |  | ||||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} |  | ||||||
|       - |  | ||||||
|         name: Install Python dependencies |  | ||||||
|         run: | |         run: | | ||||||
|           uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen |           pip install --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} | ||||||
|  |       - | ||||||
|  |         name: Install 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 | ||||||
|       - |       - | ||||||
|         name: Make documentation |         name: Make documentation | ||||||
|         run: | |         run: | | ||||||
|           uv run \ |           pipenv --python ${{ steps.setup-python.outputs.python-version }} run mkdocs build --config-file ./mkdocs.yml | ||||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ |  | ||||||
|             --dev \ |  | ||||||
|             --frozen \ |  | ||||||
|             mkdocs build --config-file ./mkdocs.yml |  | ||||||
|       - |       - | ||||||
|         name: Deploy documentation |         name: Deploy documentation | ||||||
|         if: github.event_name == 'push' && github.ref == 'refs/heads/main' |         if: github.event_name == 'push' && github.ref == 'refs/heads/main' | ||||||
| @@ -83,11 +84,7 @@ jobs: | |||||||
|           echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME" |           echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME" | ||||||
|           git config --global user.name "${{ github.actor }}" |           git config --global user.name "${{ github.actor }}" | ||||||
|           git config --global user.email "${{ github.actor }}@users.noreply.github.com" |           git config --global user.email "${{ github.actor }}@users.noreply.github.com" | ||||||
|           uv run \ |           pipenv --python ${{ steps.setup-python.outputs.python-version }} run mkdocs gh-deploy --force --no-history | ||||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ |  | ||||||
|             --dev \ |  | ||||||
|             --frozen \ |  | ||||||
|             mkdocs gh-deploy --force --no-history |  | ||||||
|       - |       - | ||||||
|         name: Upload artifact |         name: Upload artifact | ||||||
|         uses: actions/upload-artifact@v4 |         uses: actions/upload-artifact@v4 | ||||||
| @@ -120,13 +117,12 @@ jobs: | |||||||
|         uses: actions/setup-python@v5 |         uses: actions/setup-python@v5 | ||||||
|         with: |         with: | ||||||
|           python-version: "${{ matrix.python-version }}" |           python-version: "${{ matrix.python-version }}" | ||||||
|  |           cache: "pipenv" | ||||||
|  |           cache-dependency-path: 'Pipfile.lock' | ||||||
|       - |       - | ||||||
|         name: Install uv |         name: Install pipenv | ||||||
|         uses: astral-sh/setup-uv@v5 |         run: | | ||||||
|         with: |           pip install --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} | ||||||
|           version: ${{ env.DEFAULT_UV_VERSION }} |  | ||||||
|           enable-cache: true |  | ||||||
|           python-version: ${{ steps.setup-python.outputs.python-version }} |  | ||||||
|       - |       - | ||||||
|         name: Install system dependencies |         name: Install system dependencies | ||||||
|         run: | |         run: | | ||||||
| @@ -139,14 +135,12 @@ jobs: | |||||||
|       - |       - | ||||||
|         name: Install Python dependencies |         name: Install Python dependencies | ||||||
|         run: | |         run: | | ||||||
|           uv sync \ |           pipenv --python ${{ steps.setup-python.outputs.python-version }} run python --version | ||||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ |           pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev | ||||||
|             --group testing \ |  | ||||||
|             --frozen |  | ||||||
|       - |       - | ||||||
|         name: List installed Python dependencies |         name: List installed Python dependencies | ||||||
|         run: | |         run: | | ||||||
|           uv pip list |           pipenv --python ${{ steps.setup-python.outputs.python-version }} run pip list | ||||||
|       - |       - | ||||||
|         name: Tests |         name: Tests | ||||||
|         env: |         env: | ||||||
| @@ -156,26 +150,17 @@ jobs: | |||||||
|           PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }} |           PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }} | ||||||
|           PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }} |           PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }} | ||||||
|         run: | |         run: | | ||||||
|           uv run \ |           cd src/ | ||||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ |           pipenv --python ${{ steps.setup-python.outputs.python-version }} run pytest -ra | ||||||
|             --dev \ |  | ||||||
|             --frozen \ |  | ||||||
|             pytest |  | ||||||
|       - |       - | ||||||
|         name: Upload backend test results to Codecov |         name: Upload coverage | ||||||
|         if: always() |         if: ${{ matrix.python-version == env.DEFAULT_PYTHON_VERSION }} | ||||||
|         uses: codecov/test-results-action@v1 |         uses: actions/upload-artifact@v4 | ||||||
|         with: |         with: | ||||||
|           token: ${{ secrets.CODECOV_TOKEN }} |           name: backend-coverage-report | ||||||
|           flags: backend-python-${{ matrix.python-version }} |           path: src/coverage.xml | ||||||
|           files: junit.xml |           retention-days: 7 | ||||||
|       - |           if-no-files-found: warn | ||||||
|         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 |         name: Stop containers | ||||||
|         if: always() |         if: always() | ||||||
| @@ -183,46 +168,42 @@ 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 logs | ||||||
|           docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml down |           docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml down | ||||||
|  |  | ||||||
|   install-frontend-dependencies: |   install-frontend-depedendencies: | ||||||
|     name: "Install Frontend Dependencies" |     name: "Install Frontend Dependencies" | ||||||
|     runs-on: ubuntu-24.04 |     runs-on: ubuntu-24.04 | ||||||
|     needs: |     needs: | ||||||
|       - pre-commit |       - pre-commit | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - name: Install pnpm |  | ||||||
|         uses: pnpm/action-setup@v4 |  | ||||||
|         with: |  | ||||||
|           version: 10 |  | ||||||
|       - |       - | ||||||
|         name: Use Node.js 20 |         name: Use Node.js 20 | ||||||
|         uses: actions/setup-node@v4 |         uses: actions/setup-node@v4 | ||||||
|         with: |         with: | ||||||
|           node-version: 20.x |           node-version: 20.x | ||||||
|           cache: 'pnpm' |           cache: 'npm' | ||||||
|           cache-dependency-path: 'src-ui/pnpm-lock.yaml' |           cache-dependency-path: 'src-ui/package-lock.json' | ||||||
|       - name: Cache frontend dependencies |       - name: Cache frontend dependencies | ||||||
|         id: cache-frontend-deps |         id: cache-frontend-deps | ||||||
|         uses: actions/cache@v4 |         uses: actions/cache@v4 | ||||||
|         with: |         with: | ||||||
|           path: | |           path: | | ||||||
|             ~/.pnpm-store |             ~/.npm | ||||||
|             ~/.cache |             ~/.cache | ||||||
|           key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }} |           key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }} | ||||||
|       - |       - | ||||||
|         name: Install dependencies |         name: Install dependencies | ||||||
|         if: steps.cache-frontend-deps.outputs.cache-hit != 'true' |         if: steps.cache-frontend-deps.outputs.cache-hit != 'true' | ||||||
|         run: cd src-ui && pnpm install |         run: cd src-ui && npm ci | ||||||
|       - |       - | ||||||
|         name: Install Playwright |         name: Install Playwright | ||||||
|         if: steps.cache-frontend-deps.outputs.cache-hit != 'true' |         if: steps.cache-frontend-deps.outputs.cache-hit != 'true' | ||||||
|         run: cd src-ui && pnpm playwright install --with-deps |         run: cd src-ui && npx playwright install --with-deps | ||||||
|  |  | ||||||
|   tests-frontend: |   tests-frontend: | ||||||
|     name: "Frontend Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})" |     name: "Frontend Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})" | ||||||
|     runs-on: ubuntu-24.04 |     runs-on: ubuntu-24.04 | ||||||
|     needs: |     needs: | ||||||
|       - install-frontend-dependencies |       - install-frontend-depedendencies | ||||||
|     strategy: |     strategy: | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
|       matrix: |       matrix: | ||||||
| @@ -231,88 +212,124 @@ jobs: | |||||||
|         shard-count: [4] |         shard-count: [4] | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - name: Install pnpm |  | ||||||
|         uses: pnpm/action-setup@v4 |  | ||||||
|         with: |  | ||||||
|           version: 10 |  | ||||||
|       - |       - | ||||||
|         name: Use Node.js 20 |         name: Use Node.js 20 | ||||||
|         uses: actions/setup-node@v4 |         uses: actions/setup-node@v4 | ||||||
|         with: |         with: | ||||||
|           node-version: 20.x |           node-version: 20.x | ||||||
|           cache: 'pnpm' |           cache: 'npm' | ||||||
|           cache-dependency-path: 'src-ui/pnpm-lock.yaml' |           cache-dependency-path: 'src-ui/package-lock.json' | ||||||
|       - name: Cache frontend dependencies |       - name: Cache frontend dependencies | ||||||
|         id: cache-frontend-deps |         id: cache-frontend-deps | ||||||
|         uses: actions/cache@v4 |         uses: actions/cache@v4 | ||||||
|         with: |         with: | ||||||
|           path: | |           path: | | ||||||
|             ~/.pnpm-store |             ~/.npm | ||||||
|             ~/.cache |             ~/.cache | ||||||
|           key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }} |           key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }} | ||||||
|       - name: Re-link Angular cli |       - name: Re-link Angular cli | ||||||
|         run: cd src-ui && pnpm link @angular/cli |         run: cd src-ui && npm link @angular/cli | ||||||
|       - |       - | ||||||
|         name: Linting checks |         name: Linting checks | ||||||
|         run: cd src-ui && pnpm run lint |         run: cd src-ui && npm run lint | ||||||
|       - |       - | ||||||
|         name: Run Jest unit tests |         name: Run Jest unit tests | ||||||
|         run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }} |         run: cd src-ui && npm run test -- --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }} | ||||||
|  |       - | ||||||
|  |         name: Upload Jest coverage | ||||||
|  |         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 | ||||||
|       - |       - | ||||||
|         name: Run Playwright e2e tests |         name: Run Playwright e2e tests | ||||||
|         run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }} |         run: cd src-ui && npx playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }} | ||||||
|       - |       - | ||||||
|         name: Upload frontend test results to Codecov |         name: Upload Playwright test results | ||||||
|         uses: codecov/test-results-action@v1 |  | ||||||
|         if: always() |         if: always() | ||||||
|  |         uses: actions/upload-artifact@v4 | ||||||
|         with: |         with: | ||||||
|           token: ${{ secrets.CODECOV_TOKEN }} |           name: playwright-report-${{ matrix.shard-index }} | ||||||
|           flags: frontend-node-${{ matrix.node-version }} |           path: src-ui/playwright-report | ||||||
|           directory: src-ui/ |           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 | ||||||
|       - |       - | ||||||
|         name: Upload frontend coverage to Codecov |         name: Upload frontend coverage to Codecov | ||||||
|         uses: codecov/codecov-action@v5 |         uses: codecov/codecov-action@v5 | ||||||
|         with: |         with: | ||||||
|  |           # not required for public repos, but intermittently fails otherwise | ||||||
|           token: ${{ secrets.CODECOV_TOKEN }} |           token: ${{ secrets.CODECOV_TOKEN }} | ||||||
|           flags: frontend-node-${{ matrix.node-version }} |           flags: frontend | ||||||
|           directory: src-ui/coverage/ |           directory: src-ui/coverage/ | ||||||
|  |           # dont include backend coverage files here | ||||||
|   frontend-bundle-analysis: |           files: '!coverage.xml' | ||||||
|     name: "Frontend Bundle Analysis" |  | ||||||
|     runs-on: ubuntu-24.04 |  | ||||||
|     needs: |  | ||||||
|       - tests-frontend |  | ||||||
|     steps: |  | ||||||
|       - uses: actions/checkout@v4 |  | ||||||
|       - |       - | ||||||
|         name: Install pnpm |         name: Download backend coverage | ||||||
|         uses: pnpm/action-setup@v4 |         uses: actions/download-artifact@v4 | ||||||
|         with: |         with: | ||||||
|           version: 10 |           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/ | ||||||
|       - |       - | ||||||
|         name: Use Node.js 20 |         name: Use Node.js 20 | ||||||
|         uses: actions/setup-node@v4 |         uses: actions/setup-node@v4 | ||||||
|         with: |         with: | ||||||
|           node-version: 20.x |           node-version: 20.x | ||||||
|           cache: 'pnpm' |           cache: 'npm' | ||||||
|           cache-dependency-path: 'src-ui/pnpm-lock.yaml' |           cache-dependency-path: 'src-ui/package-lock.json' | ||||||
|       - |       - | ||||||
|         name: Cache frontend dependencies |         name: Cache frontend dependencies | ||||||
|         id: cache-frontend-deps |         id: cache-frontend-deps | ||||||
|         uses: actions/cache@v4 |         uses: actions/cache@v4 | ||||||
|         with: |         with: | ||||||
|           path: | |           path: | | ||||||
|             ~/.pnpm-store |             ~/.npm | ||||||
|             ~/.cache |             ~/.cache | ||||||
|           key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }} |           key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }} | ||||||
|       - |       - | ||||||
|         name: Re-link Angular cli |         name: Re-link Angular cli | ||||||
|         run: cd src-ui && pnpm link @angular/cli |         run: cd src-ui && npm link @angular/cli | ||||||
|       - |       - | ||||||
|         name: Build frontend and upload analysis |         name: Build frontend and upload analysis | ||||||
|         env: |         env: | ||||||
|           CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} |           CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} | ||||||
|         run: cd src-ui && pnpm run build --configuration=production |         run: cd src-ui && ng build --configuration=production | ||||||
|  |  | ||||||
|   build-docker-image: |   build-docker-image: | ||||||
|     name: Build Docker image for ${{ github.ref_name }} |     name: Build Docker image for ${{ github.ref_name }} | ||||||
| @@ -455,17 +472,16 @@ jobs: | |||||||
|         uses: actions/setup-python@v5 |         uses: actions/setup-python@v5 | ||||||
|         with: |         with: | ||||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} |           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||||
|  |           cache: "pipenv" | ||||||
|  |           cache-dependency-path: 'Pipfile.lock' | ||||||
|       - |       - | ||||||
|         name: Install uv |         name: Install pipenv + tools | ||||||
|         uses: astral-sh/setup-uv@v5 |         run: | | ||||||
|         with: |           pip install --upgrade --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} setuptools wheel | ||||||
|           version: ${{ env.DEFAULT_UV_VERSION }} |  | ||||||
|           enable-cache: true |  | ||||||
|           python-version: ${{ steps.setup-python.outputs.python-version }} |  | ||||||
|       - |       - | ||||||
|         name: Install Python dependencies |         name: Install Python dependencies | ||||||
|         run: | |         run: | | ||||||
|           uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen |           pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev | ||||||
|       - |       - | ||||||
|         name: Install system dependencies |         name: Install system dependencies | ||||||
|         run: | |         run: | | ||||||
| @@ -486,21 +502,17 @@ jobs: | |||||||
|       - |       - | ||||||
|         name: Generate requirements file |         name: Generate requirements file | ||||||
|         run: | |         run: | | ||||||
|            uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt |           pipenv --python ${{ steps.setup-python.outputs.python-version }} requirements > requirements.txt | ||||||
|       - |       - | ||||||
|         name: Compile messages |         name: Compile messages | ||||||
|         run: | |         run: | | ||||||
|           cd src/ |           cd src/ | ||||||
|           uv run \ |           pipenv --python ${{ steps.setup-python.outputs.python-version }} run python3 manage.py compilemessages | ||||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ |  | ||||||
|             manage.py compilemessages |  | ||||||
|       - |       - | ||||||
|         name: Collect static files |         name: Collect static files | ||||||
|         run: | |         run: | | ||||||
|           cd src/ |           cd src/ | ||||||
|           uv run \ |           pipenv --python ${{ steps.setup-python.outputs.python-version }} run python3 manage.py collectstatic --no-input | ||||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ |  | ||||||
|             manage.py collectstatic --no-input |  | ||||||
|       - |       - | ||||||
|         name: Move files |         name: Move files | ||||||
|         run: | |         run: | | ||||||
| @@ -516,12 +528,13 @@ jobs: | |||||||
|           for file_name in .dockerignore \ |           for file_name in .dockerignore \ | ||||||
|                           .env \ |                           .env \ | ||||||
|                           Dockerfile \ |                           Dockerfile \ | ||||||
|                           pyproject.toml \ |                           Pipfile \ | ||||||
|                           uv.lock \ |                           Pipfile.lock \ | ||||||
|                           requirements.txt \ |                           requirements.txt \ | ||||||
|                           LICENSE \ |                           LICENSE \ | ||||||
|                           README.md \ |                           README.md \ | ||||||
|                           paperless.conf.example |                           paperless.conf.example \ | ||||||
|  |                           gunicorn.conf.py | ||||||
|           do |           do | ||||||
|             cp --verbose ${file_name} dist/paperless-ngx/ |             cp --verbose ${file_name} dist/paperless-ngx/ | ||||||
|           done |           done | ||||||
| @@ -618,17 +631,15 @@ jobs: | |||||||
|           ref: main |           ref: main | ||||||
|       - |       - | ||||||
|         name: Set up Python |         name: Set up Python | ||||||
|         id: setup-python |  | ||||||
|         uses: actions/setup-python@v5 |         uses: actions/setup-python@v5 | ||||||
|         with: |         with: | ||||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} |           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||||
|  |           cache: "pipenv" | ||||||
|  |           cache-dependency-path: 'Pipfile.lock' | ||||||
|       - |       - | ||||||
|         name: Install uv |         name: Install pipenv + tools | ||||||
|         uses: astral-sh/setup-uv@v5 |         run: | | ||||||
|         with: |           pip install --upgrade --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} setuptools wheel | ||||||
|           version: ${{ env.DEFAULT_UV_VERSION }} |  | ||||||
|           enable-cache: true |  | ||||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} |  | ||||||
|       - |       - | ||||||
|         name: Append Changelog to docs |         name: Append Changelog to docs | ||||||
|         id: append-Changelog |         id: append-Changelog | ||||||
| @@ -644,10 +655,7 @@ jobs: | |||||||
|           CURRENT_CHANGELOG=`tail --lines +2 changelog.md` |           CURRENT_CHANGELOG=`tail --lines +2 changelog.md` | ||||||
|           echo -e "$CURRENT_CHANGELOG" >> changelog-new.md |           echo -e "$CURRENT_CHANGELOG" >> changelog-new.md | ||||||
|           mv changelog-new.md changelog.md |           mv changelog-new.md changelog.md | ||||||
|           uv run \ |           pipenv run pre-commit run --files changelog.md || true | ||||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ |  | ||||||
|             --dev \ |  | ||||||
|             pre-commit run --files changelog.md || true |  | ||||||
|           git config --global user.name "github-actions" |           git config --global user.name "github-actions" | ||||||
|           git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" |           git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" | ||||||
|           git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA" |           git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA" | ||||||
|   | |||||||
							
								
								
									
										4
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
								
							| @@ -33,7 +33,7 @@ jobs: | |||||||
|       - |       - | ||||||
|         name: Clean temporary images |         name: Clean temporary images | ||||||
|         if: "${{ env.TOKEN != '' }}" |         if: "${{ env.TOKEN != '' }}" | ||||||
|         uses: stumpylog/image-cleaner-action/ephemeral@v0.10.0 |         uses: stumpylog/image-cleaner-action/ephemeral@v0.9.0 | ||||||
|         with: |         with: | ||||||
|           token: "${{ env.TOKEN }}" |           token: "${{ env.TOKEN }}" | ||||||
|           owner: "${{ github.repository_owner }}" |           owner: "${{ github.repository_owner }}" | ||||||
| @@ -61,7 +61,7 @@ jobs: | |||||||
|       - |       - | ||||||
|         name: Clean untagged images |         name: Clean untagged images | ||||||
|         if: "${{ env.TOKEN != '' }}" |         if: "${{ env.TOKEN != '' }}" | ||||||
|         uses: stumpylog/image-cleaner-action/untagged@v0.10.0 |         uses: stumpylog/image-cleaner-action/untagged@v0.9.0 | ||||||
|         with: |         with: | ||||||
|           token: "${{ env.TOKEN }}" |           token: "${{ env.TOKEN }}" | ||||||
|           owner: "${{ github.repository_owner }}" |           owner: "${{ github.repository_owner }}" | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -44,7 +44,6 @@ nosetests.xml | |||||||
| coverage.xml | coverage.xml | ||||||
| *,cover | *,cover | ||||||
| .pytest_cache | .pytest_cache | ||||||
| junit.xml |  | ||||||
|  |  | ||||||
| # Translations | # Translations | ||||||
| *.mo | *.mo | ||||||
|   | |||||||
| @@ -32,7 +32,7 @@ repos: | |||||||
|     rev: v2.4.0 |     rev: v2.4.0 | ||||||
|     hooks: |     hooks: | ||||||
|       - id: codespell |       - id: codespell | ||||||
|         exclude: "(^src-ui/src/locale/)|(^src-ui/pnpm-lock.yaml)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)" |         exclude: "(^src-ui/src/locale/)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)" | ||||||
|         exclude_types: |         exclude_types: | ||||||
|           - pofile |           - pofile | ||||||
|           - json |           - json | ||||||
| @@ -45,19 +45,16 @@ repos: | |||||||
|           - javascript |           - javascript | ||||||
|           - ts |           - ts | ||||||
|           - markdown |           - markdown | ||||||
|  |         exclude: "(^Pipfile\\.lock$)" | ||||||
|         additional_dependencies: |         additional_dependencies: | ||||||
|           - prettier@3.3.3 |           - prettier@3.3.3 | ||||||
|           - 'prettier-plugin-organize-imports@4.1.0' |           - 'prettier-plugin-organize-imports@4.1.0' | ||||||
|   # Python hooks |   # Python hooks | ||||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit |   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||||
|     rev: v0.9.9 |     rev: v0.9.6 | ||||||
|     hooks: |     hooks: | ||||||
|       - id: ruff |       - id: ruff | ||||||
|       - id: ruff-format |       - id: ruff-format | ||||||
|   - repo: https://github.com/tox-dev/pyproject-fmt |  | ||||||
|     rev: "v2.5.1" |  | ||||||
|     hooks: |  | ||||||
|       - id: pyproject-fmt |  | ||||||
|   # Dockerfile hooks |   # Dockerfile hooks | ||||||
|   - repo: https://github.com/AleksaC/hadolint-py |   - repo: https://github.com/AleksaC/hadolint-py | ||||||
|     rev: v2.12.0.3 |     rev: v2.12.0.3 | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								.python-version
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.python-version
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | 3.10.15 | ||||||
							
								
								
									
										87
									
								
								.ruff.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								.ruff.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | |||||||
|  | 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,6 +5,5 @@ | |||||||
| /src-ui/ @paperless-ngx/frontend | /src-ui/ @paperless-ngx/frontend | ||||||
|  |  | ||||||
| /src/ @paperless-ngx/backend | /src/ @paperless-ngx/backend | ||||||
| pyproject.toml @paperless-ngx/backend | Pipfile* @paperless-ngx/backend | ||||||
| uv.lock @paperless-ngx/backend |  | ||||||
| *.py @paperless-ngx/backend | *.py @paperless-ngx/backend | ||||||
|   | |||||||
							
								
								
									
										60
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										60
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -4,17 +4,15 @@ | |||||||
| # Stage: compile-frontend | # Stage: compile-frontend | ||||||
| # Purpose: Compiles the frontend | # Purpose: Compiles the frontend | ||||||
| # Notes: | # Notes: | ||||||
| #  - Does PNPM stuff with Typescript and such | #  - Does NPM stuff with Typescript and such | ||||||
| FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-slim AS compile-frontend | FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-slim AS compile-frontend | ||||||
|  |  | ||||||
| COPY ./src-ui /src/src-ui | COPY ./src-ui /src/src-ui | ||||||
|  |  | ||||||
| WORKDIR /src/src-ui | WORKDIR /src/src-ui | ||||||
| RUN set -eux \ | RUN set -eux \ | ||||||
|   && npm update -g pnpm \ |   && npm update npm -g \ | ||||||
|   && npm install -g corepack@latest \ |   && npm ci | ||||||
|   && corepack enable \ |  | ||||||
|   && pnpm install |  | ||||||
|  |  | ||||||
| ARG PNGX_TAG_VERSION= | ARG PNGX_TAG_VERSION= | ||||||
| # Add the tag to the environment file if its a tagged dev build | # Add the tag to the environment file if its a tagged dev build | ||||||
| @@ -28,11 +26,28 @@ esac | |||||||
| RUN set -eux \ | RUN set -eux \ | ||||||
|   && ./node_modules/.bin/ng build --configuration production |   && ./node_modules/.bin/ng build --configuration production | ||||||
|  |  | ||||||
|  | # Stage: pipenv-base | ||||||
|  | # Purpose: Generates a requirements.txt file for building | ||||||
|  | # Comments: | ||||||
|  | #  - pipenv dependencies are not left in the final image | ||||||
|  | #  - pipenv can't touch the final image somehow | ||||||
|  | FROM --platform=$BUILDPLATFORM docker.io/python:3.12-alpine AS pipenv-base | ||||||
|  |  | ||||||
|  | WORKDIR /usr/src/pipenv | ||||||
|  |  | ||||||
|  | COPY Pipfile* ./ | ||||||
|  |  | ||||||
|  | RUN set -eux \ | ||||||
|  |   && echo "Installing pipenv" \ | ||||||
|  |     && python3 -m pip install --no-cache-dir --upgrade pipenv==2024.4.1 \ | ||||||
|  |   && echo "Generating requirement.txt" \ | ||||||
|  |     && pipenv requirements > requirements.txt | ||||||
|  |  | ||||||
| # Stage: s6-overlay-base | # Stage: s6-overlay-base | ||||||
| # Purpose: Installs s6-overlay and rootfs | # Purpose: Installs s6-overlay and rootfs | ||||||
| # Comments: | # Comments: | ||||||
| #  - Don't leave anything extra in here either | #  - Don't leave anything extra in here either | ||||||
| FROM ghcr.io/astral-sh/uv:0.6.11-python3.12-bookworm-slim AS s6-overlay-base | FROM docker.io/python:3.12-slim-bookworm AS s6-overlay-base | ||||||
|  |  | ||||||
| WORKDIR /usr/src/s6 | WORKDIR /usr/src/s6 | ||||||
|  |  | ||||||
| @@ -108,12 +123,9 @@ ARG GS_VERSION=10.03.1 | |||||||
| # Set Python environment variables | # Set Python environment variables | ||||||
| ENV PYTHONDONTWRITEBYTECODE=1 \ | ENV PYTHONDONTWRITEBYTECODE=1 \ | ||||||
|     PYTHONUNBUFFERED=1 \ |     PYTHONUNBUFFERED=1 \ | ||||||
|     # Ignore warning from Whitenoise about async iterators |     # Ignore warning from Whitenoise | ||||||
|     PYTHONWARNINGS="ignore:::django.http.response:517" \ |     PYTHONWARNINGS="ignore:::django.http.response:517" \ | ||||||
|     PNGX_CONTAINERIZED=1 \ |     PNGX_CONTAINERIZED=1 | ||||||
|     # https://docs.astral.sh/uv/reference/settings/#link-mode |  | ||||||
|     UV_LINK_MODE=copy \ |  | ||||||
|     UV_CACHE_DIR=/cache/uv/ |  | ||||||
|  |  | ||||||
| # | # | ||||||
| # Begin installation and configuration | # Begin installation and configuration | ||||||
| @@ -192,29 +204,46 @@ RUN set -eux \ | |||||||
|         && rm --force --verbose *.deb \ |         && rm --force --verbose *.deb \ | ||||||
|     && rm --recursive --force --verbose /var/lib/apt/lists/* |     && 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/ | WORKDIR /usr/src/paperless/src/ | ||||||
|  |  | ||||||
| # Python dependencies | # Python dependencies | ||||||
| # Change pretty frequently | # Change pretty frequently | ||||||
| COPY --chown=1000:1000 ["pyproject.toml", "uv.lock", "/usr/src/paperless/src/"] | COPY --chown=1000:1000 --from=pipenv-base /usr/src/pipenv/requirements.txt ./ | ||||||
|  |  | ||||||
| # Packages needed only for building a few quick Python | # Packages needed only for building a few quick Python | ||||||
| # dependencies | # dependencies | ||||||
| ARG BUILD_PACKAGES="\ | ARG BUILD_PACKAGES="\ | ||||||
|   build-essential \ |   build-essential \ | ||||||
|  |   git \ | ||||||
|  |   # https://www.psycopg.org/docs/install.html#prerequisites | ||||||
|  |   libpq-dev \ | ||||||
|   # https://github.com/PyMySQL/mysqlclient#linux |   # https://github.com/PyMySQL/mysqlclient#linux | ||||||
|   default-libmysqlclient-dev \ |   default-libmysqlclient-dev \ | ||||||
|   pkg-config" |   pkg-config" | ||||||
|  |  | ||||||
|  | ARG ZXING_VERSION=2.3.0 | ||||||
|  | ARG PSYCOPG_VERSION=3.2.4 | ||||||
|  |  | ||||||
| # hadolint ignore=DL3042 | # hadolint ignore=DL3042 | ||||||
| RUN --mount=type=cache,target=${UV_CACHE_DIR},id=python-cache \ | RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \ | ||||||
|   set -eux \ |   set -eux \ | ||||||
|   && echo "Installing build system packages" \ |   && echo "Installing build system packages" \ | ||||||
|     && apt-get update \ |     && apt-get update \ | ||||||
|     && apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \ |     && apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \ | ||||||
|  |     && python3 -m pip install --upgrade wheel \ | ||||||
|   && echo "Installing Python requirements" \ |   && echo "Installing Python requirements" \ | ||||||
|     && uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt \ |     && curl --fail --silent --no-progress-meter --show-error --location --remote-name-all --parallel --parallel-max 4 \ | ||||||
|     && uv pip install --system --no-python-downloads --python-preference system --requirements requirements.txt \ |       https://github.com/paperless-ngx/builder/releases/download/psycopg-${PSYCOPG_VERSION}/psycopg_c-${PSYCOPG_VERSION}-cp312-cp312-linux_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 \ | ||||||
|   && echo "Installing NLTK data" \ |   && echo "Installing NLTK data" \ | ||||||
|     && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \ |     && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \ | ||||||
|     && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \ |     && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \ | ||||||
| @@ -239,7 +268,6 @@ COPY --from=compile-frontend --chown=1000:1000 /src/src/documents/static/fronten | |||||||
| # add users, setup scripts | # add users, setup scripts | ||||||
| # Mount the compiled frontend to expected location | # Mount the compiled frontend to expected location | ||||||
| RUN set -eux \ | RUN set -eux \ | ||||||
|   && sed -i '1s|^#!/usr/bin/env python3|#!/command/with-contenv python3|' manage.py \ |  | ||||||
|   && echo "Setting up user/group" \ |   && echo "Setting up user/group" \ | ||||||
|     && addgroup --gid 1000 paperless \ |     && addgroup --gid 1000 paperless \ | ||||||
|     && useradd --uid 1000 --gid paperless --home-dir /usr/src/paperless paperless \ |     && useradd --uid 1000 --gid paperless --home-dir /usr/src/paperless paperless \ | ||||||
|   | |||||||
							
								
								
									
										102
									
								
								Pipfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								Pipfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,102 @@ | |||||||
|  | [[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
									
									
									
										Normal file
									
								
							
							
						
						
									
										4978
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -5,7 +5,7 @@ | |||||||
|  |  | ||||||
| services: | services: | ||||||
|   gotenberg: |   gotenberg: | ||||||
|     image: docker.io/gotenberg/gotenberg:8.19 |     image: docker.io/gotenberg/gotenberg:8.7 | ||||||
|     hostname: gotenberg |     hostname: gotenberg | ||||||
|     container_name: gotenberg |     container_name: gotenberg | ||||||
|     network_mode: host |     network_mode: host | ||||||
|   | |||||||
| @@ -24,8 +24,8 @@ | |||||||
| # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' | # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' | ||||||
| #   and '.env' into a folder. | #   and '.env' into a folder. | ||||||
| # - Run 'docker compose pull'. | # - Run 'docker compose pull'. | ||||||
|  | # - Run 'docker compose run --rm webserver createsuperuser' to create a user. | ||||||
| # - Run 'docker compose up -d'. | # - Run 'docker compose up -d'. | ||||||
|  |  | ||||||
| # | # | ||||||
| # For more extensive installation and update instructions, refer to the | # For more extensive installation and update instructions, refer to the | ||||||
| # documentation. | # documentation. | ||||||
| @@ -77,7 +77,7 @@ services: | |||||||
|       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 |       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 | ||||||
|  |  | ||||||
|   gotenberg: |   gotenberg: | ||||||
|     image: docker.io/gotenberg/gotenberg:8.19 |     image: docker.io/gotenberg/gotenberg:8.7 | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|     # The gotenberg chromium route is used to convert .eml files. We do not |     # The gotenberg chromium route is used to convert .eml files. We do not | ||||||
|     # want to allow external content like tracking pixels or even javascript. |     # want to allow external content like tracking pixels or even javascript. | ||||||
|   | |||||||
| @@ -20,6 +20,7 @@ | |||||||
| # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' | # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' | ||||||
| #   and '.env' into a folder. | #   and '.env' into a folder. | ||||||
| # - Run 'docker compose pull'. | # - Run 'docker compose pull'. | ||||||
|  | # - Run 'docker compose run --rm webserver createsuperuser' to create a user. | ||||||
| # - Run 'docker compose up -d'. | # - Run 'docker compose up -d'. | ||||||
| # | # | ||||||
| # For more extensive installation and update instructions, refer to the | # For more extensive installation and update instructions, refer to the | ||||||
|   | |||||||
| @@ -22,6 +22,10 @@ | |||||||
| # - Upload 'docker-compose.env' by clicking on 'Load variables from .env file' | # - Upload 'docker-compose.env' by clicking on 'Load variables from .env file' | ||||||
| # - Modify the environment variables as needed | # - Modify the environment variables as needed | ||||||
| # - Click 'Deploy the stack' and wait for it to be deployed | # - Click 'Deploy the stack' and wait for it to be deployed | ||||||
|  | # - Open the list of containers, select paperless_webserver_1 | ||||||
|  | # - Click 'Console' and then 'Connect' to open the command line inside the container | ||||||
|  | # - Run 'python3 manage.py createsuperuser' to create a user | ||||||
|  | # - Exit the console | ||||||
| # | # | ||||||
| # For more extensive installation and update instructions, refer to the | # For more extensive installation and update instructions, refer to the | ||||||
| # documentation. | # documentation. | ||||||
| @@ -34,7 +38,7 @@ services: | |||||||
|       - redisdata:/data |       - redisdata:/data | ||||||
|  |  | ||||||
|   db: |   db: | ||||||
|     image: docker.io/library/postgres:17 |     image: docker.io/library/postgres:16 | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|     volumes: |     volumes: | ||||||
|       - pgdata:/var/lib/postgresql/data |       - pgdata:/var/lib/postgresql/data | ||||||
|   | |||||||
| @@ -24,6 +24,7 @@ | |||||||
| # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' | # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' | ||||||
| #   and '.env' into a folder. | #   and '.env' into a folder. | ||||||
| # - Run 'docker compose pull'. | # - Run 'docker compose pull'. | ||||||
|  | # - Run 'docker compose run --rm webserver createsuperuser' to create a user. | ||||||
| # - Run 'docker compose up -d'. | # - Run 'docker compose up -d'. | ||||||
| # | # | ||||||
| # For more extensive installation and update instructions, refer to the | # For more extensive installation and update instructions, refer to the | ||||||
| @@ -37,7 +38,7 @@ services: | |||||||
|       - redisdata:/data |       - redisdata:/data | ||||||
|  |  | ||||||
|   db: |   db: | ||||||
|     image: docker.io/library/postgres:17 |     image: docker.io/library/postgres:16 | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|     volumes: |     volumes: | ||||||
|       - pgdata:/var/lib/postgresql/data |       - pgdata:/var/lib/postgresql/data | ||||||
| @@ -70,7 +71,7 @@ services: | |||||||
|       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 |       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 | ||||||
|  |  | ||||||
|   gotenberg: |   gotenberg: | ||||||
|     image: docker.io/gotenberg/gotenberg:8.19 |     image: docker.io/gotenberg/gotenberg:8.7 | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|  |  | ||||||
|     # The gotenberg chromium route is used to convert .eml files. We do not |     # The gotenberg chromium route is used to convert .eml files. We do not | ||||||
|   | |||||||
| @@ -20,6 +20,7 @@ | |||||||
| # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' | # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' | ||||||
| #   and '.env' into a folder. | #   and '.env' into a folder. | ||||||
| # - Run 'docker compose pull'. | # - Run 'docker compose pull'. | ||||||
|  | # - Run 'docker compose run --rm webserver createsuperuser' to create a user. | ||||||
| # - Run 'docker compose up -d'. | # - Run 'docker compose up -d'. | ||||||
| # | # | ||||||
| # For more extensive installation and update instructions, refer to the | # For more extensive installation and update instructions, refer to the | ||||||
| @@ -33,7 +34,7 @@ services: | |||||||
|       - redisdata:/data |       - redisdata:/data | ||||||
|  |  | ||||||
|   db: |   db: | ||||||
|     image: docker.io/library/postgres:17 |     image: docker.io/library/postgres:16 | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|     volumes: |     volumes: | ||||||
|       - pgdata:/var/lib/postgresql/data |       - pgdata:/var/lib/postgresql/data | ||||||
|   | |||||||
| @@ -24,6 +24,7 @@ | |||||||
| # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' | # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' | ||||||
| #   and '.env' into a folder. | #   and '.env' into a folder. | ||||||
| # - Run 'docker compose pull'. | # - Run 'docker compose pull'. | ||||||
|  | # - Run 'docker compose run --rm webserver createsuperuser' to create a user. | ||||||
| # - Run 'docker compose up -d'. | # - Run 'docker compose up -d'. | ||||||
| # | # | ||||||
| # For more extensive installation and update instructions, refer to the | # For more extensive installation and update instructions, refer to the | ||||||
| @@ -58,7 +59,7 @@ services: | |||||||
|       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 |       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 | ||||||
|  |  | ||||||
|   gotenberg: |   gotenberg: | ||||||
|     image: docker.io/gotenberg/gotenberg:8.19 |     image: docker.io/gotenberg/gotenberg:8.7 | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|  |  | ||||||
|     # The gotenberg chromium route is used to convert .eml files. We do not |     # The gotenberg chromium route is used to convert .eml files. We do not | ||||||
|   | |||||||
| @@ -17,6 +17,7 @@ | |||||||
| # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' | # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' | ||||||
| #   and '.env' into a folder. | #   and '.env' into a folder. | ||||||
| # - Run 'docker compose pull'. | # - Run 'docker compose pull'. | ||||||
|  | # - Run 'docker compose run --rm webserver createsuperuser' to create a user. | ||||||
| # - Run 'docker compose up -d'. | # - Run 'docker compose up -d'. | ||||||
| # | # | ||||||
| # For more extensive installation and update instructions, refer to the | # For more extensive installation and update instructions, refer to the | ||||||
|   | |||||||
| @@ -18,10 +18,9 @@ for command in decrypt_documents \ | |||||||
| 	document_fuzzy_match \ | 	document_fuzzy_match \ | ||||||
| 	manage_superuser \ | 	manage_superuser \ | ||||||
| 	convert_mariadb_uuid \ | 	convert_mariadb_uuid \ | ||||||
| 	prune_audit_logs \ | 	prune_audit_logs; | ||||||
| 	createsuperuser; |  | ||||||
| do | do | ||||||
| 	echo "installing $command..." | 	echo "installing $command..." | ||||||
| 	sed "s/management_command/$command/g" management_script.sh >"$PWD/rootfs/usr/local/bin/$command" | 	sed "s/management_command/$command/g" management_script.sh >"$PWD/rootfs/usr/local/bin/$command" | ||||||
| 	chmod u=rwx,g=rwx,o=rx "$PWD/rootfs/usr/local/bin/$command" | 	chmod +x "$PWD/rootfs/usr/local/bin/$command" | ||||||
| done | done | ||||||
|   | |||||||
| @@ -1,13 +1,6 @@ | |||||||
| #!/command/with-contenv /usr/bin/bash | #!/command/with-contenv /usr/bin/bash | ||||||
| # shellcheck shell=bash | # shellcheck shell=bash | ||||||
|  |  | ||||||
|  |  | ||||||
| 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} | cd ${PAPERLESS_SRC_DIR} | ||||||
|  |  | ||||||
| if [[ -n "${USER_IS_NON_ROOT}" ]]; then | if [[ -n "${USER_IS_NON_ROOT}" ]]; then | ||||||
| @@ -15,4 +8,3 @@ else | |||||||
| else | else | ||||||
| 	exec s6-setuidgid paperless python3 manage.py document_consumer | 	exec s6-setuidgid paperless python3 manage.py document_consumer | ||||||
| fi | fi | ||||||
| fi |  | ||||||
|   | |||||||
| @@ -3,18 +3,8 @@ | |||||||
|  |  | ||||||
| cd ${PAPERLESS_SRC_DIR} | cd ${PAPERLESS_SRC_DIR} | ||||||
|  |  | ||||||
| # 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 | if [[ -n "${USER_IS_NON_ROOT}" ]]; then | ||||||
|   exec granian --interface asginl --ws --loop uvloop "paperless.asgi:application" | 	exec /usr/local/bin/gunicorn -c /usr/src/paperless/gunicorn.conf.py paperless.asgi:application | ||||||
| else | else | ||||||
|   exec s6-setuidgid paperless granian --interface asginl --ws --loop uvloop "paperless.asgi:application" | 	exec s6-setuidgid paperless /usr/local/bin/gunicorn -c /usr/src/paperless/gunicorn.conf.py paperless.asgi:application | ||||||
| fi | fi | ||||||
|   | |||||||
| @@ -1,14 +0,0 @@ | |||||||
| #!/command/with-contenv /usr/bin/bash |  | ||||||
| # shellcheck shell=bash |  | ||||||
|  |  | ||||||
| set -e |  | ||||||
|  |  | ||||||
| cd "${PAPERLESS_SRC_DIR}" |  | ||||||
|  |  | ||||||
| if [[ $(id -u) == 0 ]]; then |  | ||||||
| 	s6-setuidgid paperless python3 manage.py createsuperuser "$@" |  | ||||||
| elif [[ $(id -un) == "paperless" ]]; then |  | ||||||
| 	python3 manage.py createsuperuser "$@" |  | ||||||
| else |  | ||||||
| 	echo "Unknown user." |  | ||||||
| fi |  | ||||||
| @@ -565,15 +565,19 @@ document. | |||||||
|  |  | ||||||
| ### Managing encryption {#encryption} | ### Managing encryption {#encryption} | ||||||
|  |  | ||||||
|  | Documents can be stored in Paperless using GnuPG encryption. | ||||||
|  |  | ||||||
| !!! warning | !!! warning | ||||||
|  |  | ||||||
|     Encryption was removed in [paperless-ng 0.9](changelog.md#paperless-ng-090) |     Encryption is deprecated since [paperless-ng 0.9](changelog.md#paperless-ng-090) and doesn't really | ||||||
|     because it did not really provide any additional security, the passphrase |     provide any additional security, since you have to store the passphrase | ||||||
|     was stored in a configuration file on the same system as the documents. |     in a configuration file on the same system as the encrypted documents | ||||||
|     Furthermore, the entire text content of the documents is stored plain in |     for paperless to work. Furthermore, the entire text content of the | ||||||
|     the database, even if your documents are encrypted. Filenames are not |     documents is stored plain in the database, even if your documents are | ||||||
|     encrypted as well. Finally, the web server provides transparent access to |     encrypted. Filenames are not encrypted as well. | ||||||
|     your encrypted documents. |  | ||||||
|  |     Also, the web server provides transparent access to your encrypted | ||||||
|  |     documents. | ||||||
|  |  | ||||||
|     Consider running paperless on an encrypted filesystem instead, which |     Consider running paperless on an encrypted filesystem instead, which | ||||||
|     will then at least provide security against physical hardware theft. |     will then at least provide security against physical hardware theft. | ||||||
| @@ -629,11 +633,3 @@ entries created prior to this are not removed. This command allows you to prune | |||||||
| ```shell | ```shell | ||||||
| prune_audit_logs | prune_audit_logs | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| ### Create superuser {#create-superuser} |  | ||||||
|  |  | ||||||
| If you need to create a superuser, use the following command: |  | ||||||
|  |  | ||||||
| ```shell |  | ||||||
| createsuperuser |  | ||||||
| ``` |  | ||||||
|   | |||||||
| @@ -509,12 +509,6 @@ 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`. | 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} | ## 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 | Paperless will attempt to "clean" certain invalid PDFs with `qpdf` before processing if, for example, the mime_type | ||||||
|   | |||||||
| @@ -270,7 +270,7 @@ The following methods are supported: | |||||||
| -   `remove_tag` | -   `remove_tag` | ||||||
|     -   Requires `parameters`: `{ "tag": TAG_ID }` |     -   Requires `parameters`: `{ "tag": TAG_ID }` | ||||||
| -   `modify_tags` | -   `modify_tags` | ||||||
|     -   Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and `{ "remove_tags": [LIST_OF_TAG_IDS] }` |     -   Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and / or `{ "remove_tags": [LIST_OF_TAG_IDS] }` | ||||||
| -   `delete` | -   `delete` | ||||||
|     -   No `parameters` required |     -   No `parameters` required | ||||||
| -   `reprocess` | -   `reprocess` | ||||||
|   | |||||||
| @@ -404,7 +404,7 @@ set this value to /paperless. No trailing slash! | |||||||
| #### [`PAPERLESS_STATIC_URL=<path>`](#PAPERLESS_STATIC_URL) {#PAPERLESS_STATIC_URL} | #### [`PAPERLESS_STATIC_URL=<path>`](#PAPERLESS_STATIC_URL) {#PAPERLESS_STATIC_URL} | ||||||
|  |  | ||||||
| : Override the STATIC_URL here. Unless you're hosting Paperless off a | : Override the STATIC_URL here. Unless you're hosting Paperless off a | ||||||
| specific path like /paperless/, you probably don't need to change this. | subdomain like /paperless/, you probably don't need to change this. | ||||||
| If you do change it, be sure to include the trailing slash. | If you do change it, be sure to include the trailing slash. | ||||||
|  |  | ||||||
|     Defaults to "/static/". |     Defaults to "/static/". | ||||||
| @@ -557,20 +557,6 @@ This is for use with self-signed certificates against local IMAP servers. | |||||||
|     Settings this value has security implications for the security of your email. |     Settings this value has security implications for the security of your email. | ||||||
|     Understand what it does and be sure you need to before setting. |     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} | #### [`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. | : This variable is used to setup login and signup via social account providers which are compatible with django-allauth. | ||||||
| @@ -594,25 +580,12 @@ system. See the corresponding | |||||||
|  |  | ||||||
|     Defaults to True |     Defaults to True | ||||||
|  |  | ||||||
| #### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS=<bool>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS} | #### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS} | ||||||
|  |  | ||||||
| : 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). | : Allow users to signup for a new Paperless-ngx account. | ||||||
|  |  | ||||||
| : 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 |     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} | #### [`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 | : The protocol used when generating URLs, e.g. login callback URLs. See the corresponding | ||||||
| @@ -1057,11 +1030,6 @@ be used with caution! | |||||||
|  |  | ||||||
| ## Document Consumption {#consume_config} | ## 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} | #### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES} | ||||||
|  |  | ||||||
| : When the consumer detects a duplicate document, it will not touch | : When the consumer detects a duplicate document, it will not touch | ||||||
| @@ -1538,23 +1506,13 @@ increase RAM usage. | |||||||
|  |  | ||||||
|     Defaults to 1. |     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} | #### [`PAPERLESS_BIND_ADDR=<ip address>`](#PAPERLESS_BIND_ADDR) {#PAPERLESS_BIND_ADDR} | ||||||
|  |  | ||||||
| : The IP address the webserver will listen on inside the container. | : The IP address the webserver will listen on inside the container. | ||||||
| There are special setups where you may need to configure this value | There are special setups where you may need to configure this value | ||||||
| to restrict the Ip address or interface the webserver listens on. | 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} | #### [`PAPERLESS_PORT=<port>`](#PAPERLESS_PORT) {#PAPERLESS_PORT} | ||||||
|  |  | ||||||
| @@ -1569,11 +1527,6 @@ one pod). | |||||||
|  |  | ||||||
|     Defaults to 8000. |     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} | #### [`USERMAP_UID=<uid>`](#USERMAP_UID) {#USERMAP_UID} | ||||||
|  |  | ||||||
| : The ID of the paperless user in the container. Set this to your | : 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. |       Every command is executed directly from the root folder of the project unless specified otherwise. | ||||||
|  |  | ||||||
| 1.  Install prerequisites + [uv](https://github.com/astral-sh/uv) as mentioned in | 1.  Install prerequisites + pipenv as mentioned in | ||||||
|     [Bare metal route](setup.md#bare_metal). |     [Bare metal route](setup.md#bare_metal). | ||||||
|  |  | ||||||
| 2.  Copy `paperless.conf.example` to `paperless.conf` and enable debug | 2.  Copy `paperless.conf.example` to `paperless.conf` and enable debug | ||||||
| @@ -75,22 +75,26 @@ first-time setup. | |||||||
| 4.  Install the Python dependencies: | 4.  Install the Python dependencies: | ||||||
|  |  | ||||||
|     ```bash |     ```bash | ||||||
|     $ uv sync --group dev |     pipenv install --dev | ||||||
|     ``` |     ``` | ||||||
|  |  | ||||||
|  |     !!! note | ||||||
|  |  | ||||||
|  |         Using a virtual environment is highly recommended. You can spawn one via `pipenv shell`. | ||||||
|  |  | ||||||
| 5.  Install pre-commit hooks: | 5.  Install pre-commit hooks: | ||||||
|  |  | ||||||
|     ```bash |     ```bash | ||||||
|     $ uv run pre-commit install |     pre-commit install | ||||||
|     ``` |     ``` | ||||||
|  |  | ||||||
| 6.  Apply migrations and create a superuser (also can be done via the web UI) for your development instance: | 6.  Apply migrations and create a superuser for your development instance: | ||||||
|  |  | ||||||
|     ```bash |     ```bash | ||||||
|     # src/ |     # src/ | ||||||
|  |  | ||||||
|     $ uv run manage.py migrate |     python3 manage.py migrate | ||||||
|     $ uv run manage.py createsuperuser |     python3 manage.py createsuperuser | ||||||
|     ``` |     ``` | ||||||
|  |  | ||||||
| 7.  You can now either ... | 7.  You can now either ... | ||||||
| @@ -140,7 +144,7 @@ To build the front end once use this command: | |||||||
| ```bash | ```bash | ||||||
| # src-ui/ | # src-ui/ | ||||||
|  |  | ||||||
| $ pnpm install | $ npm install | ||||||
| $ ng build --configuration production | $ ng build --configuration production | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| @@ -160,23 +164,10 @@ $ ng build --configuration production | |||||||
|       complicated IF cases. Append `# noqa: E501` to disable this check |       complicated IF cases. Append `# noqa: E501` to disable this check | ||||||
|       for certain lines. |       for certain lines. | ||||||
|  |  | ||||||
| ### Package Management |  | ||||||
|  |  | ||||||
| Paperless uses `uv` to manage packages and virtual environments for both development and production. |  | ||||||
| To accomplish some common tasks using `uv`, follow the shortcuts below: |  | ||||||
|  |  | ||||||
| To upgrade all locked packages to the latest allowed versions: `uv lock --upgrade` |  | ||||||
|  |  | ||||||
| To upgrade a single locked package: `uv lock --upgrade-package <package>` |  | ||||||
|  |  | ||||||
| To add a new package: `uv add <package>` |  | ||||||
|  |  | ||||||
| To add a new development package `uv add --dev <package>` |  | ||||||
|  |  | ||||||
| ## Front end development | ## Front end development | ||||||
|  |  | ||||||
| The front end is built using AngularJS. In order to get started, you need Node.js (version 14.15+) and | The front end is built using AngularJS. In order to get started, you need Node.js (version 14.15+) and | ||||||
| `pnpm`. | `npm`. | ||||||
|  |  | ||||||
| !!! note | !!! note | ||||||
|  |  | ||||||
| @@ -185,7 +176,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: | 1.  Install the Angular CLI. You might need sudo privileges to perform this command: | ||||||
|  |  | ||||||
|     ```bash |     ```bash | ||||||
|     pnpm install -g @angular/cli |     npm install -g @angular/cli | ||||||
|     ``` |     ``` | ||||||
|  |  | ||||||
| 2.  Make sure that it's on your path. | 2.  Make sure that it's on your path. | ||||||
| @@ -193,7 +184,7 @@ The front end is built using AngularJS. In order to get started, you need Node.j | |||||||
| 3.  Install all necessary modules: | 3.  Install all necessary modules: | ||||||
|  |  | ||||||
|     ```bash |     ```bash | ||||||
|     pnpm install |     npm install | ||||||
|     ``` |     ``` | ||||||
|  |  | ||||||
| 4.  You can launch a development server by running: | 4.  You can launch a development server by running: | ||||||
| @@ -207,7 +198,7 @@ The front end is built using AngularJS. In order to get started, you need Node.j | |||||||
|     restart it. |     restart it. | ||||||
|  |  | ||||||
|     By default, the development server is available on `http://localhost:4200/` and is configured to access the API at |     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 and CORS 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, CORS and X-Frame-Options are in place so that the front end behaves exactly as in production. | ||||||
|  |  | ||||||
| ### Testing and code style | ### Testing and code style | ||||||
|  |  | ||||||
| @@ -341,21 +332,27 @@ LANGUAGES = [ | |||||||
| The documentation is built using material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/). | The documentation is built using material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/). | ||||||
| If you want to build the documentation locally, this is how you do it: | If you want to build the documentation locally, this is how you do it: | ||||||
|  |  | ||||||
| 1.  Build the documentation | 1.  Have an active pipenv shell (`pipenv shell`) and install Python dependencies: | ||||||
|  |  | ||||||
|     ```bash |     ```bash | ||||||
|     $ uv run mkdocs build --config-file mkdocs.yml |     pipenv install --dev | ||||||
|  |     ``` | ||||||
|  |  | ||||||
|  | 2.  Build the documentation | ||||||
|  |  | ||||||
|  |     ```bash | ||||||
|  |     mkdocs build --config-file mkdocs.yml | ||||||
|     ``` |     ``` | ||||||
|  |  | ||||||
|     _alternatively..._ |     _alternatively..._ | ||||||
|  |  | ||||||
| 2.  Serve the documentation. This will spin up a | 3.  Serve the documentation. This will spin up a | ||||||
|     copy of the documentation at http://127.0.0.1:8000 |     copy of the documentation at http://127.0.0.1:8000 | ||||||
|     that will automatically refresh every time you change |     that will automatically refresh every time you change | ||||||
|     something. |     something. | ||||||
|  |  | ||||||
|     ```bash |     ```bash | ||||||
|     $ uv run mkdocs serve |     mkdocs serve | ||||||
|     ``` |     ``` | ||||||
|  |  | ||||||
| ## Building the Docker image | ## Building the Docker image | ||||||
|   | |||||||
| @@ -133,9 +133,6 @@ Multiple options for ASGI servers exist: | |||||||
|     implementation for ASGI. |     implementation for ASGI. | ||||||
| -   `uvicorn` as a standalone server | -   `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_? | ## _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 | Currently (October 2024), forks of Redis such as Valkey or Redirect are not officially supported by our upstream | ||||||
|   | |||||||
| @@ -131,11 +131,26 @@ account. The script essentially automatically performs the steps described in [D | |||||||
|     by default but you can change the image to pull from Docker Hub by changing the `image` |     by default but you can change the image to pull from Docker Hub by changing the `image` | ||||||
|     line to `image: paperlessngx/paperless-ngx:latest`. |     line to `image: paperlessngx/paperless-ngx:latest`. | ||||||
|  |  | ||||||
| 6.  Run `docker compose up -d`. This will create and start the necessary containers. | 6.  To be able to login, you will need a "superuser". To create it, | ||||||
|  |     execute the following command: | ||||||
|  |  | ||||||
| 7.  Congratulations! Your Paperless-ngx instance should now be accessible at `http://127.0.0.1:8000` |     ```shell-session | ||||||
|     (or similar, depending on your configuration). When you first access the web interface, you will be |     docker compose run --rm webserver createsuperuser | ||||||
|     prompted to create a superuser account. |     ``` | ||||||
|  |  | ||||||
|  |     or using docker exec from within the container: | ||||||
|  |  | ||||||
|  |     ```shell-session | ||||||
|  |     python3 manage.py createsuperuser | ||||||
|  |     ``` | ||||||
|  |  | ||||||
|  |     This will guide you through the superuser setup. | ||||||
|  |  | ||||||
|  | 7.  Run `docker compose up -d`. This will create and start the necessary containers. | ||||||
|  |  | ||||||
|  | 8.  Congratulations! Your Paperless-ngx instance should now be accessible at `http://127.0.0.1:8000` | ||||||
|  |     (or similar, depending on your configuration). Use the superuser credentials you have | ||||||
|  |     created in the previous step to login. | ||||||
|  |  | ||||||
| ### Build the Docker image yourself {#docker_build} | ### Build the Docker image yourself {#docker_build} | ||||||
|  |  | ||||||
| @@ -365,20 +380,15 @@ are released, dependency support is confirmed, etc. | |||||||
|         dependencies.  This is an alternative to the above and may require adjusting |         dependencies.  This is an alternative to the above and may require adjusting | ||||||
|         the example scripts to utilize the virtual environment paths |         the example scripts to utilize the virtual environment paths | ||||||
|  |  | ||||||
|     !!! tip | 9.  Go to `/opt/paperless/src`, and execute the following commands: | ||||||
|  |  | ||||||
|         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 command: |  | ||||||
|  |  | ||||||
|     ```bash |     ```bash | ||||||
|     # This creates the database schema. |     # This creates the database schema. | ||||||
|     sudo -Hu paperless python3 manage.py migrate |     sudo -Hu paperless python3 manage.py migrate | ||||||
|     ``` |  | ||||||
|  |  | ||||||
|     When you first access the web interface you will be prompted to create a superuser account. |     # This creates your first paperless user | ||||||
|  |     sudo -Hu paperless python3 manage.py createsuperuser | ||||||
|  |     ``` | ||||||
|  |  | ||||||
| 10. Optional: Test that paperless is working by executing | 10. Optional: Test that paperless is working by executing | ||||||
|  |  | ||||||
| @@ -416,20 +426,31 @@ are released, dependency support is confirmed, etc. | |||||||
|  |  | ||||||
|     !!! note |     !!! note | ||||||
|  |  | ||||||
|         The `socket` script enables `granian` to run on port 80 without |         The `socket` script enables `gunicorn` to run on port 80 without | ||||||
|         root privileges. For this you need to uncomment the |         root privileges. For this you need to uncomment the | ||||||
|         `Require=paperless-webserver.socket` in the `webserver` script |         `Require=paperless-webserver.socket` in the `webserver` script | ||||||
|         and configure `granian` to listen on port 80 (set `GRANIAN_PORT`). |         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. | ||||||
|  |  | ||||||
|     These services rely on redis and optionally the database server, but |     These services rely on redis and optionally the database server, but | ||||||
|     don't need to be started in any particular order. The example files |     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 |     depend on redis being started. If you use a database server, you | ||||||
|     should add additional dependencies. |     should add additional dependencies. | ||||||
|  |  | ||||||
|     !!! note |     !!! warning | ||||||
|  |  | ||||||
|         For instructions on using a reverse proxy, |         The included scripts run a `gunicorn` standalone server, which is | ||||||
|         [see the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#). |         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). | ||||||
|  |  | ||||||
|     !!! warning |     !!! warning | ||||||
|  |  | ||||||
| @@ -692,10 +713,7 @@ Paperless runs on Raspberry Pi. However, some things are rather slow on | |||||||
| the Pi and configuring some options in paperless can help improve | the Pi and configuring some options in paperless can help improve | ||||||
| performance immensely: | performance immensely: | ||||||
|  |  | ||||||
| -   Stick with SQLite to save some resources. See [troubleshooting](troubleshooting.md#log-reports-creating-paperlesstask-failed) | -   Stick with SQLite to save some resources. | ||||||
|     if you encounter issues with SQLite locking. |  | ||||||
| -   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 | -   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 |     only OCR the first page of your documents. In most cases, this page | ||||||
|     contains enough information to be able to find it. |     contains enough information to be able to find it. | ||||||
|   | |||||||
| @@ -195,6 +195,34 @@ This might have multiple reasons. | |||||||
|     is not, you need to compile the front end yourself or download the |     is not, you need to compile the front end yourself or download the | ||||||
|     release archive instead of cloning the repository. |     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 | ## Error while reading metadata | ||||||
|  |  | ||||||
| You might find messages like these in your log files: | You might find messages like these in your log files: | ||||||
| @@ -292,16 +320,14 @@ many workers attempting to access the database simultaneously. | |||||||
| Consider changing to the PostgreSQL database if you will be processing | Consider changing to the PostgreSQL database if you will be processing | ||||||
| many documents at once often. Otherwise, try tweaking the | 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 | [`PAPERLESS_DB_TIMEOUT`](configuration.md#PAPERLESS_DB_TIMEOUT) setting to allow more time for the database to | ||||||
| unlock. Additionally, you can change your SQLite database to use ["Write-Ahead Logging"](https://sqlite.org/wal.html). | unlock. This may have minor performance implications. | ||||||
| These changes may have minor performance implications but can help |  | ||||||
| prevent database locking issues. |  | ||||||
|  |  | ||||||
| ## granian fails to start with "is not a valid port number" | ## gunicorn fails to start with "is not a valid port number" | ||||||
|  |  | ||||||
| You are likely running using Kubernetes, which automatically creates an | You are likely running using Kubernetes, which automatically creates an | ||||||
| environment variable named `${serviceName}_PORT`. This is | environment variable named `${serviceName}_PORT`. This is | ||||||
| the same environment variable which is used by Paperless to optionally | the same environment variable which is used by Paperless to optionally | ||||||
| change the port granian listens on. | change the port gunicorn listens on. | ||||||
|  |  | ||||||
| To fix this, set [`PAPERLESS_PORT`](configuration.md#PAPERLESS_PORT) again to your desired port, or the | To fix this, set [`PAPERLESS_PORT`](configuration.md#PAPERLESS_PORT) again to your desired port, or the | ||||||
| default of 8000. | default of 8000. | ||||||
|   | |||||||
| @@ -837,7 +837,7 @@ Paperless-ngx consists of the following components: | |||||||
|  |  | ||||||
|     ```shell-session |     ```shell-session | ||||||
|     cd /path/to/paperless/src/ |     cd /path/to/paperless/src/ | ||||||
|     granian --interface asginl --ws "paperless.asgi:application" |     gunicorn -c ../gunicorn.conf.py paperless.wsgi | ||||||
|     ``` |     ``` | ||||||
|  |  | ||||||
|     or by any other means such as Apache `mod_wsgi`. |     or by any other means such as Apache `mod_wsgi`. | ||||||
|   | |||||||
							
								
								
									
										49
									
								
								gunicorn.conf.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								gunicorn.conf.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | |||||||
|  | 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
									
									
									
									
									
								
							
							
						
						
									
										355
									
								
								pyproject.toml
									
									
									
									
									
								
							| @@ -1,355 +0,0 @@ | |||||||
| [project] |  | ||||||
| name = "paperless-ngx" |  | ||||||
| version = "2.15.0" |  | ||||||
| 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.7", |  | ||||||
|   "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[uvloop]~=2.2.0", |  | ||||||
| ] |  | ||||||
|  |  | ||||||
| [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,21 +9,7 @@ Requires=redis.service | |||||||
| User=paperless | User=paperless | ||||||
| Group=paperless | Group=paperless | ||||||
| WorkingDirectory=/opt/paperless/src | 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] | [Install] | ||||||
| WantedBy=multi-user.target | WantedBy=multi-user.target | ||||||
|   | |||||||
| @@ -1 +0,0 @@ | |||||||
| shamefully-hoist=true |  | ||||||
| @@ -178,8 +178,7 @@ | |||||||
|     "schematicCollections": [ |     "schematicCollections": [ | ||||||
|       "@angular-eslint/schematics" |       "@angular-eslint/schematics" | ||||||
|     ], |     ], | ||||||
|     "analytics": false, |     "analytics": false | ||||||
|     "packageManager": "pnpm" |  | ||||||
|   }, |   }, | ||||||
|   "schematics": { |   "schematics": { | ||||||
|     "@angular-eslint/schematics:application": { |     "@angular-eslint/schematics:application": { | ||||||
|   | |||||||
| @@ -83,17 +83,10 @@ test('date filtering', async ({ page }) => { | |||||||
|   await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' }) |   await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' }) | ||||||
|   await page.goto('/documents') |   await page.goto('/documents') | ||||||
|   await page.getByRole('button', { name: 'Dates' }).click() |   await page.getByRole('button', { name: 'Dates' }).click() | ||||||
|   await page.locator('.ng-arrow-wrapper').first().click() |   await page.getByRole('menuitem', { name: 'Within 3 months' }).first().click() | ||||||
|   await page.getByRole('option', { name: 'Within 3 months' }).click() |  | ||||||
|   await expect(page.locator('pngx-document-list')).toHaveText(/one document/i) |   await expect(page.locator('pngx-document-list')).toHaveText(/one document/i) | ||||||
|   await page |   await page.getByRole('menuitem', { name: 'Within 3 months' }).first().click() | ||||||
|     .getByRole('menuitem', { name: 'Relative dates' }) |   await page.getByLabel('Datesselected').getByRole('button').first().click() | ||||||
|     .locator('span') |  | ||||||
|     .first() |  | ||||||
|     .click() |  | ||||||
|   await page.getByRole('option', { name: 'Within 3 months' }).click() |  | ||||||
|   await page.getByLabel('Dates selected').locator('button').first().click() |  | ||||||
|   await page.getByLabel('Dates selected').locator('button').first().click() |  | ||||||
|   await page.getByRole('combobox', { name: 'Select month' }).selectOption('12') |   await page.getByRole('combobox', { name: 'Select month' }).selectOption('12') | ||||||
|   await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022') |   await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022') | ||||||
|   await page.getByText('11', { exact: true }).click() |   await page.getByText('11', { exact: true }).click() | ||||||
|   | |||||||
| @@ -7,20 +7,9 @@ module.exports = { | |||||||
|     'abstract-name-filter-service', |     'abstract-name-filter-service', | ||||||
|     'abstract-paperless-service', |     'abstract-paperless-service', | ||||||
|   ], |   ], | ||||||
|   transformIgnorePatterns: [ |   transformIgnorePatterns: [`<rootDir>/node_modules/(?!.*\\.mjs$|lodash-es)`], | ||||||
|     `<rootDir>/node_modules/.pnpm/(?!.*\\.mjs$|lodash-es)`, |  | ||||||
|   ], |  | ||||||
|   moduleNameMapper: { |   moduleNameMapper: { | ||||||
|     '^src/(.*)': '<rootDir>/src/$1', |     '^src/(.*)': '<rootDir>/src/$1', | ||||||
|   }, |   }, | ||||||
|   workerIdleMemoryLimit: '512MB', |   workerIdleMemoryLimit: '512MB', | ||||||
|   reporters: [ |  | ||||||
|     'default', |  | ||||||
|     [ |  | ||||||
|       'jest-junit', |  | ||||||
|       { |  | ||||||
|         classNameTemplate: '{filepath}/{classname}: {title}', |  | ||||||
|       }, |  | ||||||
|     ], |  | ||||||
|   ], |  | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										1094
									
								
								src-ui/messages.xlf
									
									
									
									
									
								
							
							
						
						
									
										1094
									
								
								src-ui/messages.xlf
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										19090
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										19090
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -2,7 +2,6 @@ | |||||||
|   "name": "paperless-ui", |   "name": "paperless-ui", | ||||||
|   "version": "0.0.0", |   "version": "0.0.0", | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "preinstall": "npx only-allow pnpm", |  | ||||||
|     "ng": "ng", |     "ng": "ng", | ||||||
|     "start": "ng serve", |     "start": "ng serve", | ||||||
|     "build": "ng build", |     "build": "ng build", | ||||||
| @@ -12,17 +11,17 @@ | |||||||
|   }, |   }, | ||||||
|   "private": true, |   "private": true, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@angular/cdk": "^19.2.7", |     "@angular/cdk": "^19.1.2", | ||||||
|     "@angular/common": "~19.2.4", |     "@angular/common": "~19.1.4", | ||||||
|     "@angular/compiler": "~19.2.4", |     "@angular/compiler": "~19.1.4", | ||||||
|     "@angular/core": "~19.2.4", |     "@angular/core": "~19.1.4", | ||||||
|     "@angular/forms": "~19.2.4", |     "@angular/forms": "~19.1.4", | ||||||
|     "@angular/localize": "~19.2.4", |     "@angular/localize": "~19.1.4", | ||||||
|     "@angular/platform-browser": "~19.2.4", |     "@angular/platform-browser": "~19.1.4", | ||||||
|     "@angular/platform-browser-dynamic": "~19.2.4", |     "@angular/platform-browser-dynamic": "~19.1.4", | ||||||
|     "@angular/router": "~19.2.4", |     "@angular/router": "~19.1.4", | ||||||
|     "@ng-bootstrap/ng-bootstrap": "^18.0.0", |     "@ng-bootstrap/ng-bootstrap": "^18.0.0", | ||||||
|     "@ng-select/ng-select": "^14.2.6", |     "@ng-select/ng-select": "^14.2.0", | ||||||
|     "@ngneat/dirty-check-forms": "^3.0.3", |     "@ngneat/dirty-check-forms": "^3.0.3", | ||||||
|     "@popperjs/core": "^2.11.8", |     "@popperjs/core": "^2.11.8", | ||||||
|     "bootstrap": "^5.3.3", |     "bootstrap": "^5.3.3", | ||||||
| @@ -30,56 +29,46 @@ | |||||||
|     "mime-names": "^1.0.0", |     "mime-names": "^1.0.0", | ||||||
|     "ng2-pdf-viewer": "^10.4.0", |     "ng2-pdf-viewer": "^10.4.0", | ||||||
|     "ngx-bootstrap-icons": "^1.9.3", |     "ngx-bootstrap-icons": "^1.9.3", | ||||||
|     "ngx-color": "^10.0.0", |     "ngx-color": "^9.0.0", | ||||||
|     "ngx-cookie-service": "^19.1.2", |     "ngx-cookie-service": "^19.1.0", | ||||||
|     "ngx-device-detector": "^9.0.0", |     "ngx-device-detector": "^9.0.0", | ||||||
|     "ngx-file-drop": "^16.0.0", |     "ngx-file-drop": "^16.0.0", | ||||||
|     "ngx-ui-tour-ng-bootstrap": "^16.0.0", |     "ngx-ui-tour-ng-bootstrap": "^16.0.0", | ||||||
|     "rxjs": "^7.8.2", |     "rxjs": "^7.8.1", | ||||||
|     "tslib": "^2.8.1", |     "tslib": "^2.8.1", | ||||||
|     "utif": "^3.1.0", |     "utif": "^3.1.0", | ||||||
|     "uuid": "^11.1.0", |     "uuid": "^11.0.5", | ||||||
|     "zone.js": "^0.15.0" |     "zone.js": "^0.15.0" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@angular-builders/custom-webpack": "^19.0.0", |     "@angular-builders/custom-webpack": "^19.0.0", | ||||||
|     "@angular-builders/jest": "^19.0.0", |     "@angular-builders/jest": "^19.0.0", | ||||||
|     "@angular-devkit/build-angular": "^19.2.5", |     "@angular-devkit/build-angular": "^19.0.4", | ||||||
|     "@angular-devkit/core": "^19.2.5", |     "@angular-devkit/core": "^19.1.5", | ||||||
|     "@angular-devkit/schematics": "^19.2.5", |     "@angular-devkit/schematics": "^19.1.5", | ||||||
|     "@angular-eslint/builder": "19.3.0", |     "@angular-eslint/builder": "19.0.2", | ||||||
|     "@angular-eslint/eslint-plugin": "19.3.0", |     "@angular-eslint/eslint-plugin": "19.0.2", | ||||||
|     "@angular-eslint/eslint-plugin-template": "19.3.0", |     "@angular-eslint/eslint-plugin-template": "19.0.2", | ||||||
|     "@angular-eslint/schematics": "19.3.0", |     "@angular-eslint/schematics": "19.0.2", | ||||||
|     "@angular-eslint/template-parser": "19.3.0", |     "@angular-eslint/template-parser": "19.0.2", | ||||||
|     "@angular/cli": "~19.2.5", |     "@angular/cli": "~19.1.5", | ||||||
|     "@angular/compiler-cli": "~19.2.4", |     "@angular/compiler-cli": "~19.1.4", | ||||||
|     "@codecov/webpack-plugin": "^1.9.0", |     "@codecov/webpack-plugin": "^1.8.0", | ||||||
|     "@playwright/test": "^1.51.1", |     "@playwright/test": "^1.50.1", | ||||||
|     "@types/jest": "^29.5.14", |     "@types/jest": "^29.5.14", | ||||||
|     "@types/node": "^22.13.17", |     "@types/node": "^22.13.0", | ||||||
|     "@typescript-eslint/eslint-plugin": "^8.29.0", |     "@typescript-eslint/eslint-plugin": "^8.22.0", | ||||||
|     "@typescript-eslint/parser": "^8.29.0", |     "@typescript-eslint/parser": "^8.22.0", | ||||||
|     "@typescript-eslint/utils": "^8.29.0", |     "@typescript-eslint/utils": "^8.0.0", | ||||||
|     "eslint": "^9.23.0", |     "eslint": "^9.19.0", | ||||||
|     "jest": "29.7.0", |     "jest": "29.7.0", | ||||||
|     "jest-environment-jsdom": "^29.7.0", |     "jest-environment-jsdom": "^29.7.0", | ||||||
|     "jest-junit": "^16.0.0", |     "jest-preset-angular": "^14.4.2", | ||||||
|     "jest-preset-angular": "^14.5.4", |  | ||||||
|     "jest-websocket-mock": "^2.5.0", |     "jest-websocket-mock": "^2.5.0", | ||||||
|     "patch-package": "^8.0.0", |     "patch-package": "^8.0.0", | ||||||
|     "prettier-plugin-organize-imports": "^4.1.0", |     "prettier-plugin-organize-imports": "^4.1.0", | ||||||
|     "ts-node": "~10.9.1", |     "ts-node": "~10.9.1", | ||||||
|     "typescript": "^5.5.4" |     "typescript": "^5.5.4" | ||||||
|   }, |   }, | ||||||
|   "pnpm": { |  | ||||||
|     "onlyBuiltDependencies": [ |  | ||||||
|       "@parcel/watcher", |  | ||||||
|       "canvas", |  | ||||||
|       "esbuild", |  | ||||||
|       "lmdb", |  | ||||||
|       "msgpackr-extract" |  | ||||||
|     ] |  | ||||||
|   }, |  | ||||||
|   "typings": "./src/typings.d.ts" |   "typings": "./src/typings.d.ts" | ||||||
| } | } | ||||||
|   | |||||||
| @@ -21,7 +21,7 @@ export default defineConfig({ | |||||||
|   /* Run your local dev server before starting the tests */ |   /* Run your local dev server before starting the tests */ | ||||||
|   webServer: { |   webServer: { | ||||||
|     port, |     port, | ||||||
|     command: 'pnpm run start', |     command: 'npm run start', | ||||||
|     reuseExistingServer: !process.env.CI, |     reuseExistingServer: !process.env.CI, | ||||||
|     timeout: 2 * 60 * 1000, |     timeout: 2 * 60 * 1000, | ||||||
|   }, |   }, | ||||||
|   | |||||||
							
								
								
									
										12438
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										12438
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -36,13 +36,7 @@ export const routes: Routes = [ | |||||||
|     component: AppFrameComponent, |     component: AppFrameComponent, | ||||||
|     canDeactivate: [DirtyDocGuard], |     canDeactivate: [DirtyDocGuard], | ||||||
|     children: [ |     children: [ | ||||||
|       { |       { path: 'dashboard', component: DashboardComponent }, | ||||||
|         path: 'dashboard', |  | ||||||
|         component: DashboardComponent, |  | ||||||
|         data: { |  | ||||||
|           componentName: 'AppFrameComponent', |  | ||||||
|         }, |  | ||||||
|       }, |  | ||||||
|       { |       { | ||||||
|         path: 'documents', |         path: 'documents', | ||||||
|         component: DocumentListComponent, |         component: DocumentListComponent, | ||||||
| @@ -53,7 +47,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.View, |             action: PermissionAction.View, | ||||||
|             type: PermissionType.Document, |             type: PermissionType.Document, | ||||||
|           }, |           }, | ||||||
|           componentName: 'DocumentListComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -66,7 +59,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.View, |             action: PermissionAction.View, | ||||||
|             type: PermissionType.SavedView, |             type: PermissionType.SavedView, | ||||||
|           }, |           }, | ||||||
|           componentName: 'DocumentListComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -78,7 +70,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.View, |             action: PermissionAction.View, | ||||||
|             type: PermissionType.Document, |             type: PermissionType.Document, | ||||||
|           }, |           }, | ||||||
|           componentName: 'DocumentDetailComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -90,7 +81,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.View, |             action: PermissionAction.View, | ||||||
|             type: PermissionType.Document, |             type: PermissionType.Document, | ||||||
|           }, |           }, | ||||||
|           componentName: 'DocumentDetailComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -102,7 +92,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.View, |             action: PermissionAction.View, | ||||||
|             type: PermissionType.Document, |             type: PermissionType.Document, | ||||||
|           }, |           }, | ||||||
|           componentName: 'DocumentAsnComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -114,7 +103,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.View, |             action: PermissionAction.View, | ||||||
|             type: PermissionType.Tag, |             type: PermissionType.Tag, | ||||||
|           }, |           }, | ||||||
|           componentName: 'TagListComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -126,7 +114,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.View, |             action: PermissionAction.View, | ||||||
|             type: PermissionType.DocumentType, |             type: PermissionType.DocumentType, | ||||||
|           }, |           }, | ||||||
|           componentName: 'DocumentTypeListComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -138,7 +125,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.View, |             action: PermissionAction.View, | ||||||
|             type: PermissionType.Correspondent, |             type: PermissionType.Correspondent, | ||||||
|           }, |           }, | ||||||
|           componentName: 'CorrespondentListComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -150,7 +136,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.View, |             action: PermissionAction.View, | ||||||
|             type: PermissionType.StoragePath, |             type: PermissionType.StoragePath, | ||||||
|           }, |           }, | ||||||
|           componentName: 'StoragePathListComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -159,7 +144,6 @@ export const routes: Routes = [ | |||||||
|         canActivate: [PermissionsGuard], |         canActivate: [PermissionsGuard], | ||||||
|         data: { |         data: { | ||||||
|           requireAdmin: true, |           requireAdmin: true, | ||||||
|           componentName: 'LogsComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -171,7 +155,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.Delete, |             action: PermissionAction.Delete, | ||||||
|             type: PermissionType.Document, |             type: PermissionType.Document, | ||||||
|           }, |           }, | ||||||
|           componentName: 'TrashComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       // redirect old paths |       // redirect old paths | ||||||
| @@ -197,7 +180,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.Change, |             action: PermissionAction.Change, | ||||||
|             type: PermissionType.UISettings, |             type: PermissionType.UISettings, | ||||||
|           }, |           }, | ||||||
|           componentName: 'SettingsComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -210,7 +192,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.View, |             action: PermissionAction.View, | ||||||
|             type: PermissionType.UISettings, |             type: PermissionType.UISettings, | ||||||
|           }, |           }, | ||||||
|           componentName: 'SettingsComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -222,7 +203,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.Change, |             action: PermissionAction.Change, | ||||||
|             type: PermissionType.AppConfig, |             type: PermissionType.AppConfig, | ||||||
|           }, |           }, | ||||||
|           componentName: 'ConfigComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -234,7 +214,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.View, |             action: PermissionAction.View, | ||||||
|             type: PermissionType.PaperlessTask, |             type: PermissionType.PaperlessTask, | ||||||
|           }, |           }, | ||||||
|           componentName: 'TasksComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -246,7 +225,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.View, |             action: PermissionAction.View, | ||||||
|             type: PermissionType.CustomField, |             type: PermissionType.CustomField, | ||||||
|           }, |           }, | ||||||
|           componentName: 'CustomFieldsComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -258,7 +236,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.View, |             action: PermissionAction.View, | ||||||
|             type: PermissionType.Workflow, |             type: PermissionType.Workflow, | ||||||
|           }, |           }, | ||||||
|           componentName: 'WorkflowsComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -270,7 +247,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.View, |             action: PermissionAction.View, | ||||||
|             type: PermissionType.MailAccount, |             type: PermissionType.MailAccount, | ||||||
|           }, |           }, | ||||||
|           componentName: 'MailComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -282,7 +258,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.View, |             action: PermissionAction.View, | ||||||
|             type: PermissionType.User, |             type: PermissionType.User, | ||||||
|           }, |           }, | ||||||
|           componentName: 'UsersAndGroupsComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -294,7 +269,6 @@ export const routes: Routes = [ | |||||||
|             action: PermissionAction.View, |             action: PermissionAction.View, | ||||||
|             type: PermissionType.SavedView, |             type: PermissionType.SavedView, | ||||||
|           }, |           }, | ||||||
|           componentName: 'SavedViewsComponent', |  | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|   | |||||||
| @@ -118,7 +118,7 @@ | |||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|             <div class="row"> |             <div class="row mb-3"> | ||||||
|               <div class="col-md-3 col-form-label pt-0"> |               <div class="col-md-3 col-form-label pt-0"> | ||||||
|                 <span i18n>Sidebar</span> |                 <span i18n>Sidebar</span> | ||||||
|               </div> |               </div> | ||||||
| @@ -129,7 +129,7 @@ | |||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|             <div class="row"> |             <div class="row mb-3"> | ||||||
|               <div class="col-md-3 col-form-label pt-0"> |               <div class="col-md-3 col-form-label pt-0"> | ||||||
|                 <span i18n>Dark mode</span> |                 <span i18n>Dark mode</span> | ||||||
|               </div> |               </div> | ||||||
| @@ -165,7 +165,7 @@ | |||||||
|                   <p i18n> |                   <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. |                     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"> |                   <p> | ||||||
|                     <em i18n>No tracking data is collected by the app in any way.</em> |                     <em i18n>No tracking data is collected by the app in any way.</em> | ||||||
|                   </p> |                   </p> | ||||||
|                 </ng-template> |                 </ng-template> | ||||||
| @@ -173,7 +173,7 @@ | |||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|             <h5 class="mt-3" i18n>Saved Views</h5> |             <h5 class="mt-3" i18n>Saved Views</h5> | ||||||
|             <div class="row"> |             <div class="row mb-3"> | ||||||
|               <div class="col"> |               <div class="col"> | ||||||
|                 <pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check> |                 <pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check> | ||||||
|               </div> |               </div> | ||||||
| @@ -183,15 +183,15 @@ | |||||||
|           <div class="col-xl-6 ps-xl-5"> |           <div class="col-xl-6 ps-xl-5"> | ||||||
|             <h5 class="mt-3 mt-md-0" i18n>Document editing</h5> |             <h5 class="mt-3 mt-md-0" i18n>Document editing</h5> | ||||||
|  |  | ||||||
|             <div class="row"> |             <div class="row mb-3"> | ||||||
|               <div class="col"> |               <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> |                 <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> |             </div> | ||||||
|  |  | ||||||
|             <div class="row"> |             <div class="row mb-3"> | ||||||
|               <div class="col-md-3 col-form-label pt-0"> |               <div class="col-2"> | ||||||
|                 <span i18n>Default zoom</span> |                 <span i18n>Default zoom:</span> | ||||||
|               </div> |               </div> | ||||||
|               <div class="col"> |               <div class="col"> | ||||||
|                 <select class="form-select" formControlName="pdfViewerDefaultZoom"> |                 <select class="form-select" formControlName="pdfViewerDefaultZoom"> | ||||||
| @@ -202,7 +202,7 @@ | |||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|             <div class="row"> |             <div class="row mb-3"> | ||||||
|               <div class="col"> |               <div class="col"> | ||||||
|                 <pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check> |                 <pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check> | ||||||
|               </div> |               </div> | ||||||
| @@ -214,22 +214,10 @@ | |||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|             <h5 class="mt-3" i18n>Global search</h5> |             <h5 class="mt-3" i18n>Notes</h5> | ||||||
|             <div class="row"> |  | ||||||
|               <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="row mb-3"> | ||||||
|               <div class="col-md-3 col-form-label pt-0"> |               <div class="col"> | ||||||
|                 <span i18n>Full search links to</span> |                 <pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check> | ||||||
|               </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> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
| @@ -241,10 +229,26 @@ | |||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|             <h5 class="mt-3" i18n>Notes</h5> |             <h5 class="mt-3" i18n>Global search</h5> | ||||||
|             <div class="row mb-3"> |             <div class="row mb-3"> | ||||||
|               <div class="col"> |               <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"> | ||||||
|  |                 <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> | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
| @@ -263,7 +267,7 @@ | |||||||
|         <div class="row mb-3"> |         <div class="row mb-3"> | ||||||
|           <div class="col"> |           <div class="col"> | ||||||
|             <p i18n> |             <p i18n> | ||||||
|               Settings apply to this user account for objects (Tags, Mail Rules, etc. but not documents) created via the web UI. |             Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI | ||||||
|           </p> |           </p> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
| @@ -303,7 +307,7 @@ | |||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|         <div class="row"> |         <div class="row mb-3"> | ||||||
|           <div class="col-md-3 col-form-label pt-0"> |           <div class="col-md-3 col-form-label pt-0"> | ||||||
|             <span i18n>Default Edit Permissions</span> |             <span i18n>Default Edit Permissions</span> | ||||||
|           </div> |           </div> | ||||||
| @@ -342,7 +346,7 @@ | |||||||
|  |  | ||||||
|         <h5 i18n>Document processing</h5> |         <h5 i18n>Document processing</h5> | ||||||
|  |  | ||||||
|         <div class="row"> |         <div class="row mb-3"> | ||||||
|           <div class="col"> |           <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 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> |             <pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check> | ||||||
|   | |||||||
| @@ -325,8 +325,6 @@ describe('SettingsComponent', () => { | |||||||
|     component['systemStatus'].database.status = SystemStatusItemStatus.OK |     component['systemStatus'].database.status = SystemStatusItemStatus.OK | ||||||
|     component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK |     component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK | ||||||
|     component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK |     component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK | ||||||
|     component['systemStatus'].tasks.sanity_check_status = |  | ||||||
|       SystemStatusItemStatus.OK |  | ||||||
|     expect(component.systemStatusHasErrors).toBeFalsy() |     expect(component.systemStatusHasErrors).toBeFalsy() | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -164,10 +164,7 @@ export class SettingsComponent | |||||||
|       this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR || |       this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR || | ||||||
|       this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR || |       this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR || | ||||||
|       this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR || |       this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR || | ||||||
|       this.systemStatus.tasks.classifier_status === |       this.systemStatus.tasks.classifier_status === SystemStatusItemStatus.ERROR | ||||||
|         SystemStatusItemStatus.ERROR || |  | ||||||
|       this.systemStatus.tasks.sanity_check_status === |  | ||||||
|         SystemStatusItemStatus.ERROR |  | ||||||
|     ) |     ) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -15,7 +15,7 @@ | |||||||
|     </svg> |     </svg> | ||||||
|     <div class="ms-2 ms-md-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled"> |     <div class="ms-2 ms-md-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled"> | ||||||
|       @if (customAppTitle?.length) { |       @if (customAppTitle?.length) { | ||||||
|         <div class="d-flex flex-column align-items-start custom-title"> |         <div class="d-flex flex-column align-items-start"> | ||||||
|           <span class="title">{{customAppTitle}}</span> |           <span class="title">{{customAppTitle}}</span> | ||||||
|           <span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span> |           <span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span> | ||||||
|         </div> |         </div> | ||||||
|   | |||||||
| @@ -244,7 +244,7 @@ main { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| @media screen and (min-width: 366px) and (max-width: 768px) { | @media screen and (max-width: 768px) { | ||||||
|   .navbar-toggler { |   .navbar-toggler { | ||||||
|     // compensate for 2 buttons on the right |     // compensate for 2 buttons on the right | ||||||
|     margin-right: 45px; |     margin-right: 45px; | ||||||
| @@ -257,13 +257,6 @@ 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.show .dropdown-toggle, | ||||||
| :host ::ng-deep .dropdown-toggle:hover { | :host ::ng-deep .dropdown-toggle:hover { | ||||||
|   opacity: 0.7; |   opacity: 0.7; | ||||||
|   | |||||||
| @@ -21,7 +21,7 @@ | |||||||
|     } |     } | ||||||
|     <div class="scroll-list"> |     <div class="scroll-list"> | ||||||
|       @for (toast of toasts; track toast.id) { |       @for (toast of toasts; track toast.id) { | ||||||
|         <pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast> |         <pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (close)="toastService.closeToast(toast)"></pngx-toast> | ||||||
|       } |       } | ||||||
|       </div> |       </div> | ||||||
|   </div> |   </div> | ||||||
|   | |||||||
| @@ -28,16 +28,10 @@ | |||||||
|         </select> |         </select> | ||||||
|     </div> |     </div> | ||||||
|     <div class="form-check form-switch mt-4"> |     <div class="form-check form-switch mt-4"> | ||||||
|       <input class="form-check-input" type="checkbox" role="switch" id="archiveFallbackSwitch" [(ngModel)]="archiveFallback"> |  | ||||||
|       <label class="form-check-label" for="archiveFallbackSwitch" i18n>Try to include archive version in merge for non-PDF files</label> |  | ||||||
|     </div> |  | ||||||
|     <div class="form-check form-switch mt-2"> |  | ||||||
|       <input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalsSwitch" [(ngModel)]="deleteOriginals" [disabled]="!userOwnsAllDocuments"> |       <input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalsSwitch" [(ngModel)]="deleteOriginals" [disabled]="!userOwnsAllDocuments"> | ||||||
|       <label class="form-check-label" for="deleteOriginalsSwitch" i18n>Delete original documents after successful merge</label> |       <label class="form-check-label" for="deleteOriginalsSwitch" i18n>Delete original documents after successful merge</label> | ||||||
|     </div> |     </div> | ||||||
|     @if (!archiveFallback) { |  | ||||||
|     <p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p> |     <p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p> | ||||||
|     } |  | ||||||
| </div> | </div> | ||||||
| <div class="modal-footer"> | <div class="modal-footer"> | ||||||
|     <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled"> |     <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled"> | ||||||
|   | |||||||
| @@ -29,7 +29,6 @@ export class MergeConfirmDialogComponent | |||||||
|   implements OnInit |   implements OnInit | ||||||
| { | { | ||||||
|   public documentIDs: number[] = [] |   public documentIDs: number[] = [] | ||||||
|   public archiveFallback: boolean = false |  | ||||||
|   public deleteOriginals: boolean = false |   public deleteOriginals: boolean = false | ||||||
|   private _documents: Document[] = [] |   private _documents: Document[] = [] | ||||||
|   get documents(): Document[] { |   get documents(): Document[] { | ||||||
|   | |||||||
| @@ -84,7 +84,7 @@ export class SplitConfirmDialogComponent | |||||||
|   addSplit() { |   addSplit() { | ||||||
|     if (this.page === this.totalPages) return |     if (this.page === this.totalPages) return | ||||||
|     this.pages.add(this.page) |     this.pages.add(this.page) | ||||||
|     this.pages = new Set(Array.from(this.pages).sort((a, b) => a - b)) |     this.pages = new Set(Array.from(this.pages).sort()) | ||||||
|     this.confirmButtonEnabled = this.pages.size > 0 |     this.confirmButtonEnabled = this.pages.size > 0 | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| <div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions" placement="bottom-end"> | <div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)"> | ||||||
|     <button class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle> |     <button class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle> | ||||||
|       <i-bs name="ui-radios"></i-bs> |       <i-bs name="ui-radios"></i-bs> | ||||||
|       <div class="d-none d-sm-inline"> <ng-container i18n>Custom Fields</ng-container></div> |       <div class="d-none d-sm-inline"> <ng-container i18n>Custom Fields</ng-container></div> | ||||||
|   | |||||||
| @@ -21,7 +21,6 @@ import { | |||||||
| } from 'src/app/services/permissions.service' | } from 'src/app/services/permissions.service' | ||||||
| import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' | import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' | ||||||
| import { ToastService } from 'src/app/services/toast.service' | import { ToastService } from 'src/app/services/toast.service' | ||||||
| import { pngxPopperOptions } from 'src/app/utils/popper-options' |  | ||||||
| import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' | import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' | ||||||
| import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' | import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' | ||||||
|  |  | ||||||
| @@ -37,8 +36,6 @@ import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit | |||||||
|   ], |   ], | ||||||
| }) | }) | ||||||
| export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissions { | export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissions { | ||||||
|   public popperOptions = pngxPopperOptions |  | ||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
|   documentId: number |   documentId: number | ||||||
|  |  | ||||||
|   | |||||||
| @@ -34,7 +34,7 @@ import { | |||||||
|   CustomFieldQueryElement, |   CustomFieldQueryElement, | ||||||
|   CustomFieldQueryExpression, |   CustomFieldQueryExpression, | ||||||
| } from 'src/app/utils/custom-field-query-element' | } from 'src/app/utils/custom-field-query-element' | ||||||
| import { pngxPopperOptions } from 'src/app/utils/popper-options' | import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options' | ||||||
| import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' | import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' | ||||||
| import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' | import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' | ||||||
| import { DocumentLinkComponent } from '../input/document-link/document-link.component' | import { DocumentLinkComponent } from '../input/document-link/document-link.component' | ||||||
| @@ -183,7 +183,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm | |||||||
|   public CustomFieldDataType = CustomFieldDataType |   public CustomFieldDataType = CustomFieldDataType | ||||||
|   public CUSTOM_FIELD_QUERY_MAX_DEPTH = CUSTOM_FIELD_QUERY_MAX_DEPTH |   public CUSTOM_FIELD_QUERY_MAX_DEPTH = CUSTOM_FIELD_QUERY_MAX_DEPTH | ||||||
|   public CUSTOM_FIELD_QUERY_MAX_ATOMS = CUSTOM_FIELD_QUERY_MAX_ATOMS |   public CUSTOM_FIELD_QUERY_MAX_ATOMS = CUSTOM_FIELD_QUERY_MAX_ATOMS | ||||||
|   public popperOptions = pngxPopperOptions |   public popperOptions = popperOptionsReenablePreventOverflow | ||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
|   title: string |   title: string | ||||||
|   | |||||||
| @@ -1,37 +1,35 @@ | |||||||
| <div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions" [placement]="placement"> | <div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions"> | ||||||
|   <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> |   <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> | ||||||
|     <i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs> |     <i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs> | ||||||
|     <div class="d-none d-sm-inline"> {{title}}</div> |     <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> |     <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span> | ||||||
|   </button> |   </button> | ||||||
|   <div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}"> |   <div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}"> | ||||||
|     <h6 class="dropdown-header border-bottom" i18n>Created</h6> |     <div class="row d-flex"> | ||||||
|  |       <div class="col border-end"> | ||||||
|         <div class="list-group list-group-flush"> |         <div class="list-group list-group-flush"> | ||||||
|       <div class="list-group-item d-flex p-2 select-item" role="menuitem"> |           <h6 class="dropdown-header border-bottom" i18n>Created</h6> | ||||||
|  |           @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="selected-icon"> |               <div class="selected-icon"> | ||||||
|           @if (createdRelativeDate) { |                 @if (createdRelativeDate === rd.id) { | ||||||
|             <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()"> |                   <i-bs width="1em" height="1em" name="check"></i-bs> | ||||||
|               <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> | ||||||
|         <div class="input-group input-group-sm small ps-1 pe-2"> |               <div class="d-flex justify-content-between w-100 align-items-center ps-2"> | ||||||
|           <ng-select class="w-100" name="createdRelativeDate" |                 <div class="pe-4"> | ||||||
|           [items]="relativeDates" [(ngModel)]="createdRelativeDate" |                   {{rd.name}} | ||||||
|           bindValue="id" |                 </div> | ||||||
|           bindLabel="name" |                 <div class="text-muted small pe-2"> | ||||||
|           clearable="false" |                   <span class="small"> | ||||||
|           placeholder="Relative dates" |                     {{ rd.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container> | ||||||
|           i18n-placeholder |                   </span> | ||||||
|           (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> | ||||||
|               </div> |               </div> | ||||||
|  |             </button> | ||||||
|  |           } | ||||||
|           <div class="list-group-item d-flex p-2" role="menuitem"> |           <div class="list-group-item d-flex p-2" role="menuitem"> | ||||||
|  |  | ||||||
|             <div class="selected-icon"> |             <div class="selected-icon"> | ||||||
|               @if (createdDateFrom) { |               @if (createdDateFrom) { | ||||||
|                 <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()"> |                 <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()"> | ||||||
| @@ -54,8 +52,10 @@ | |||||||
|                 </div> |                 </div> | ||||||
|               </ng-template> |               </ng-template> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|           </div> |           </div> | ||||||
|           <div class="list-group-item d-flex p-2" role="menuitem"> |           <div class="list-group-item d-flex p-2" role="menuitem"> | ||||||
|  |  | ||||||
|             <div class="selected-icon"> |             <div class="selected-icon"> | ||||||
|               @if (createdDateTo) { |               @if (createdDateTo) { | ||||||
|                 <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()"> |                 <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()"> | ||||||
| @@ -81,33 +81,31 @@ | |||||||
|  |  | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|  |       </div> | ||||||
|  |       <div class="col"> | ||||||
|         <h6 class="dropdown-header border-bottom" i18n>Added</h6> |         <h6 class="dropdown-header border-bottom" i18n>Added</h6> | ||||||
|         <div class="list-group list-group-flush"> |         <div class="list-group list-group-flush"> | ||||||
|       <div class="list-group-item d-flex p-2 select-item" role="menuitem"> |           @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="selected-icon"> |               <div class="selected-icon"> | ||||||
|           @if (addedRelativeDate) { |                 @if (addedRelativeDate === rd.id) { | ||||||
|             <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedRelativeDate()"> |                   <i-bs width="1em" height="1em" name="check"></i-bs> | ||||||
|               <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> | ||||||
|         <div class="input-group input-group-sm small ps-1 pe-2"> |               <div class="d-flex justify-content-between w-100 align-items-center ps-2"> | ||||||
|           <ng-select class="w-100" name="addedRelativeDate" |                 <div class="pe-4"> | ||||||
|             [items]="relativeDates" [(ngModel)]="addedRelativeDate" |                   {{rd.name}} | ||||||
|             bindValue="id" |                 </div> | ||||||
|             bindLabel="name" |                 <div class="text-muted small pe-2"> | ||||||
|             clearable="false" |                   <span class="small"> | ||||||
|             placeholder="Relative dates" |                     {{ rd.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container> | ||||||
|             i18n-placeholder |                   </span> | ||||||
|             (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> | ||||||
|               </div> |               </div> | ||||||
|  |             </button> | ||||||
|  |           } | ||||||
|           <div class="list-group-item d-flex p-2" role="menuitem"> |           <div class="list-group-item d-flex p-2" role="menuitem"> | ||||||
|  |  | ||||||
|             <div class="selected-icon"> |             <div class="selected-icon"> | ||||||
|               @if (addedDateFrom) { |               @if (addedDateFrom) { | ||||||
|                 <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()"> |                 <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()"> | ||||||
| @@ -130,8 +128,10 @@ | |||||||
|                 </div> |                 </div> | ||||||
|               </ng-template> |               </ng-template> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|           </div> |           </div> | ||||||
|           <div class="list-group-item d-flex p-2" role="menuitem"> |           <div class="list-group-item d-flex p-2" role="menuitem"> | ||||||
|  |  | ||||||
|             <div class="selected-icon"> |             <div class="selected-icon"> | ||||||
|               @if (addedDateTo) { |               @if (addedDateTo) { | ||||||
|                 <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()"> |                 <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()"> | ||||||
| @@ -154,6 +154,9 @@ | |||||||
|                 </div> |                 </div> | ||||||
|               </ng-template> |               </ng-template> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|   | |||||||
| @@ -1,7 +1,16 @@ | |||||||
| .date-dropdown { | .date-dropdown { | ||||||
|   --bs-dropdown-min-width: 22rem; |  | ||||||
|   white-space: nowrap; |   white-space: nowrap; | ||||||
|  |  | ||||||
|  |   @media(min-width: 768px) { | ||||||
|  |     --bs-dropdown-min-width: 40rem; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @media screen and (max-width: 767px) { | ||||||
|  |     .border-end { | ||||||
|  |       border: none !important; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   .btn-link { |   .btn-link { | ||||||
|     line-height: 1; |     line-height: 1; | ||||||
|   } |   } | ||||||
| @@ -12,10 +21,6 @@ | |||||||
|   min-height: 1em; |   min-height: 1em; | ||||||
| } | } | ||||||
|  |  | ||||||
| .select-item .selected-icon { |  | ||||||
|   line-height: 2em; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .input-group-sm { | .input-group-sm { | ||||||
|   .form-control { |   .form-control { | ||||||
|     font-size: 0.875rem; |     font-size: 0.875rem; | ||||||
|   | |||||||
| @@ -82,12 +82,10 @@ describe('DatesDropdownComponent', () => { | |||||||
|   it('should support relative dates', fakeAsync(() => { |   it('should support relative dates', fakeAsync(() => { | ||||||
|     let result: DateSelection |     let result: DateSelection | ||||||
|     component.datesSet.subscribe((date) => (result = date)) |     component.datesSet.subscribe((date) => (result = date)) | ||||||
|     component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown |     component.setCreatedRelativeDate(null) | ||||||
|     component.onSetCreatedRelativeDate({ |     component.setCreatedRelativeDate(RelativeDate.WITHIN_1_WEEK) | ||||||
|       id: RelativeDate.WITHIN_1_WEEK, |     component.setAddedRelativeDate(null) | ||||||
|     } as any) |     component.setAddedRelativeDate(RelativeDate.WITHIN_1_WEEK) | ||||||
|     component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown |  | ||||||
|     component.onSetAddedRelativeDate({ id: RelativeDate.WITHIN_1_WEEK } as any) |  | ||||||
|     tick(500) |     tick(500) | ||||||
|     expect(result).toEqual({ |     expect(result).toEqual({ | ||||||
|       createdFrom: null, |       createdFrom: null, | ||||||
| @@ -149,19 +147,8 @@ describe('DatesDropdownComponent', () => { | |||||||
|     expect(component.addedDateTo).toBeNull() |     expect(component.addedDateTo).toBeNull() | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should support clearRelativeDate', () => { |  | ||||||
|     component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK |  | ||||||
|     component.clearCreatedRelativeDate() |  | ||||||
|     expect(component.createdRelativeDate).toBeNull() |  | ||||||
|  |  | ||||||
|     component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK |  | ||||||
|     component.clearAddedRelativeDate() |  | ||||||
|     expect(component.addedRelativeDate).toBeNull() |  | ||||||
|   }) |  | ||||||
|  |  | ||||||
|   it('should limit keyboard events', () => { |   it('should limit keyboard events', () => { | ||||||
|     const input: HTMLInputElement = |     const input: HTMLInputElement = fixture.nativeElement.querySelector('input') | ||||||
|       fixture.nativeElement.querySelector('input.form-control') |  | ||||||
|     let event: KeyboardEvent = new KeyboardEvent('keypress', { |     let event: KeyboardEvent = new KeyboardEvent('keypress', { | ||||||
|       key: '9', |       key: '9', | ||||||
|     }) |     }) | ||||||
| @@ -176,19 +163,4 @@ describe('DatesDropdownComponent', () => { | |||||||
|     input.dispatchEvent(event) |     input.dispatchEvent(event) | ||||||
|     expect(eventSpy).toHaveBeenCalled() |     expect(eventSpy).toHaveBeenCalled() | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should support debounce', fakeAsync(() => { |  | ||||||
|     let result: DateSelection |  | ||||||
|     component.datesSet.subscribe((date) => (result = date)) |  | ||||||
|     component.onChangeDebounce() |  | ||||||
|     tick(500) |  | ||||||
|     expect(result).toEqual({ |  | ||||||
|       createdFrom: null, |  | ||||||
|       createdTo: null, |  | ||||||
|       createdRelativeDateID: null, |  | ||||||
|       addedFrom: null, |  | ||||||
|       addedTo: null, |  | ||||||
|       addedRelativeDateID: null, |  | ||||||
|     }) |  | ||||||
|   })) |  | ||||||
| }) | }) | ||||||
|   | |||||||
| @@ -13,14 +13,13 @@ import { | |||||||
|   NgbDatepickerModule, |   NgbDatepickerModule, | ||||||
|   NgbDropdownModule, |   NgbDropdownModule, | ||||||
| } from '@ng-bootstrap/ng-bootstrap' | } from '@ng-bootstrap/ng-bootstrap' | ||||||
| import { NgSelectModule } from '@ng-select/ng-select' |  | ||||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||||
| import { Subject, Subscription } from 'rxjs' | import { Subject, Subscription } from 'rxjs' | ||||||
| import { debounceTime } from 'rxjs/operators' | import { debounceTime } from 'rxjs/operators' | ||||||
| import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' | import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' | ||||||
| import { SettingsService } from 'src/app/services/settings.service' | import { SettingsService } from 'src/app/services/settings.service' | ||||||
| import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' | import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' | ||||||
| import { pngxPopperOptions } from 'src/app/utils/popper-options' | import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options' | ||||||
| import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' | import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' | ||||||
|  |  | ||||||
| export interface DateSelection { | export interface DateSelection { | ||||||
| @@ -33,14 +32,10 @@ export interface DateSelection { | |||||||
| } | } | ||||||
|  |  | ||||||
| export enum RelativeDate { | export enum RelativeDate { | ||||||
|   WITHIN_1_WEEK = 1, |   WITHIN_1_WEEK = 0, | ||||||
|   WITHIN_1_MONTH = 2, |   WITHIN_1_MONTH = 1, | ||||||
|   WITHIN_3_MONTHS = 3, |   WITHIN_3_MONTHS = 2, | ||||||
|   WITHIN_1_YEAR = 4, |   WITHIN_1_YEAR = 3, | ||||||
|   THIS_YEAR = 5, |  | ||||||
|   THIS_MONTH = 6, |  | ||||||
|   TODAY = 7, |  | ||||||
|   YESTERDAY = 8, |  | ||||||
| } | } | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
| @@ -54,14 +49,13 @@ export enum RelativeDate { | |||||||
|     NgxBootstrapIconsModule, |     NgxBootstrapIconsModule, | ||||||
|     NgbDatepickerModule, |     NgbDatepickerModule, | ||||||
|     NgbDropdownModule, |     NgbDropdownModule, | ||||||
|     NgSelectModule, |  | ||||||
|     FormsModule, |     FormsModule, | ||||||
|     ReactiveFormsModule, |     ReactiveFormsModule, | ||||||
|     NgClass, |     NgClass, | ||||||
|   ], |   ], | ||||||
| }) | }) | ||||||
| export class DatesDropdownComponent implements OnInit, OnDestroy { | export class DatesDropdownComponent implements OnInit, OnDestroy { | ||||||
|   public popperOptions = pngxPopperOptions |   public popperOptions = popperOptionsReenablePreventOverflow | ||||||
|  |  | ||||||
|   constructor(settings: SettingsService) { |   constructor(settings: SettingsService) { | ||||||
|     this.datePlaceHolder = settings.getLocalizedDateInputFormat() |     this.datePlaceHolder = settings.getLocalizedDateInputFormat() | ||||||
| @@ -88,64 +82,44 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { | |||||||
|       name: $localize`Within 1 year`, |       name: $localize`Within 1 year`, | ||||||
|       date: new Date().setFullYear(new Date().getFullYear() - 1), |       date: new Date().setFullYear(new Date().getFullYear() - 1), | ||||||
|     }, |     }, | ||||||
|     { |  | ||||||
|       id: RelativeDate.THIS_YEAR, |  | ||||||
|       name: $localize`This year`, |  | ||||||
|       date: new Date('1/1/' + new Date().getFullYear()), |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: RelativeDate.THIS_MONTH, |  | ||||||
|       name: $localize`This month`, |  | ||||||
|       date: new Date().setDate(1), |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: RelativeDate.TODAY, |  | ||||||
|       name: $localize`Today`, |  | ||||||
|       date: new Date().setHours(0, 0, 0, 0), |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       id: RelativeDate.YESTERDAY, |  | ||||||
|       name: $localize`Yesterday`, |  | ||||||
|       date: new Date().setDate(new Date().getDate() - 1), |  | ||||||
|     }, |  | ||||||
|   ] |   ] | ||||||
|  |  | ||||||
|   datePlaceHolder: string |   datePlaceHolder: string | ||||||
|  |  | ||||||
|   // created |   // created | ||||||
|   @Input() |   @Input() | ||||||
|   createdDateTo: string = null |   createdDateTo: string | ||||||
|  |  | ||||||
|   @Output() |   @Output() | ||||||
|   createdDateToChange = new EventEmitter<string>() |   createdDateToChange = new EventEmitter<string>() | ||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
|   createdDateFrom: string = null |   createdDateFrom: string | ||||||
|  |  | ||||||
|   @Output() |   @Output() | ||||||
|   createdDateFromChange = new EventEmitter<string>() |   createdDateFromChange = new EventEmitter<string>() | ||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
|   createdRelativeDate: RelativeDate = null |   createdRelativeDate: RelativeDate | ||||||
|  |  | ||||||
|   @Output() |   @Output() | ||||||
|   createdRelativeDateChange = new EventEmitter<number>() |   createdRelativeDateChange = new EventEmitter<number>() | ||||||
|  |  | ||||||
|   // added |   // added | ||||||
|   @Input() |   @Input() | ||||||
|   addedDateTo: string = null |   addedDateTo: string | ||||||
|  |  | ||||||
|   @Output() |   @Output() | ||||||
|   addedDateToChange = new EventEmitter<string>() |   addedDateToChange = new EventEmitter<string>() | ||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
|   addedDateFrom: string = null |   addedDateFrom: string | ||||||
|  |  | ||||||
|   @Output() |   @Output() | ||||||
|   addedDateFromChange = new EventEmitter<string>() |   addedDateFromChange = new EventEmitter<string>() | ||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
|   addedRelativeDate: RelativeDate = null |   addedRelativeDate: RelativeDate | ||||||
|  |  | ||||||
|   @Output() |   @Output() | ||||||
|   addedRelativeDateChange = new EventEmitter<number>() |   addedRelativeDateChange = new EventEmitter<number>() | ||||||
| @@ -159,9 +133,6 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { | |||||||
|   @Input() |   @Input() | ||||||
|   disabled: boolean = false |   disabled: boolean = false | ||||||
|  |  | ||||||
|   @Input() |  | ||||||
|   placement: string = 'bottom-start' |  | ||||||
|  |  | ||||||
|   public readonly today: string = new Date().toISOString().split('T')[0] |   public readonly today: string = new Date().toISOString().split('T')[0] | ||||||
|  |  | ||||||
|   get isActive(): boolean { |   get isActive(): boolean { | ||||||
| @@ -201,17 +172,17 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { | |||||||
|     this.onChange() |     this.onChange() | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   onSetCreatedRelativeDate(rd: { id: number; name: string; date: number }) { |   setCreatedRelativeDate(rd: RelativeDate) { | ||||||
|     // createdRelativeDate is set by ngModel |  | ||||||
|     this.createdDateTo = null |     this.createdDateTo = null | ||||||
|     this.createdDateFrom = null |     this.createdDateFrom = null | ||||||
|  |     this.createdRelativeDate = this.createdRelativeDate == rd ? null : rd | ||||||
|     this.onChange() |     this.onChange() | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   onSetAddedRelativeDate(rd: { id: number; name: string; date: number }) { |   setAddedRelativeDate(rd: RelativeDate) { | ||||||
|     // addedRelativeDate is set by ngModel |  | ||||||
|     this.addedDateTo = null |     this.addedDateTo = null | ||||||
|     this.addedDateFrom = null |     this.addedDateFrom = null | ||||||
|  |     this.addedRelativeDate = this.addedRelativeDate == rd ? null : rd | ||||||
|     this.onChange() |     this.onChange() | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -253,11 +224,6 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { | |||||||
|     this.onChange() |     this.onChange() | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   clearCreatedRelativeDate() { |  | ||||||
|     this.createdRelativeDate = null |  | ||||||
|     this.onChange() |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   clearAddedTo() { |   clearAddedTo() { | ||||||
|     this.addedDateTo = null |     this.addedDateTo = null | ||||||
|     this.onChange() |     this.onChange() | ||||||
| @@ -268,11 +234,6 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { | |||||||
|     this.onChange() |     this.onChange() | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   clearAddedRelativeDate() { |  | ||||||
|     this.addedRelativeDate = null |  | ||||||
|     this.onChange() |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   // prevent chars other than numbers and separators |   // prevent chars other than numbers and separators | ||||||
|   onKeyPress(event: KeyboardEvent) { |   onKeyPress(event: KeyboardEvent) { | ||||||
|     if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) { |     if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) { | ||||||
|   | |||||||
| @@ -189,7 +189,6 @@ | |||||||
|             <pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select> |             <pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select> | ||||||
|             <pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select> |             <pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select> | ||||||
|             <pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select> |             <pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select> | ||||||
|             <pngx-input-custom-fields-values formControlName="assign_custom_fields_values" [selectedFields]="formGroup.get('assign_custom_fields').value" (removeSelectedField)="removeSelectedCustomField($event, formGroup)"></pngx-input-custom-fields-values> |  | ||||||
|           </div> |           </div> | ||||||
|           <div class="col"> |           <div class="col"> | ||||||
|             <pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select> |             <pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select> | ||||||
|   | |||||||
| @@ -2,12 +2,7 @@ import { CdkDragDrop } from '@angular/cdk/drag-drop' | |||||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||||
| import { | import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||||
|   FormControl, |  | ||||||
|   FormGroup, |  | ||||||
|   FormsModule, |  | ||||||
|   ReactiveFormsModule, |  | ||||||
| } from '@angular/forms' |  | ||||||
| import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' | import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' | ||||||
| import { NgSelectModule } from '@ng-select/ng-select' | import { NgSelectModule } from '@ng-select/ng-select' | ||||||
| import { of } from 'rxjs' | import { of } from 'rxjs' | ||||||
| @@ -374,19 +369,4 @@ describe('WorkflowEditDialogComponent', () => { | |||||||
|     expect(component.objectForm.get('actions').value[0].email).toBeNull() |     expect(component.objectForm.get('actions').value[0].email).toBeNull() | ||||||
|     expect(component.objectForm.get('actions').value[0].webhook).toBeNull() |     expect(component.objectForm.get('actions').value[0].webhook).toBeNull() | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should remove selected custom field from the form group', () => { |  | ||||||
|     const formGroup = new FormGroup({ |  | ||||||
|       assign_custom_fields: new FormControl([1, 2, 3]), |  | ||||||
|     }) |  | ||||||
|  |  | ||||||
|     component.removeSelectedCustomField(2, formGroup) |  | ||||||
|     expect(formGroup.get('assign_custom_fields').value).toEqual([1, 3]) |  | ||||||
|  |  | ||||||
|     component.removeSelectedCustomField(1, formGroup) |  | ||||||
|     expect(formGroup.get('assign_custom_fields').value).toEqual([3]) |  | ||||||
|  |  | ||||||
|     component.removeSelectedCustomField(3, formGroup) |  | ||||||
|     expect(formGroup.get('assign_custom_fields').value).toEqual([]) |  | ||||||
|   }) |  | ||||||
| }) | }) | ||||||
|   | |||||||
| @@ -47,7 +47,6 @@ import { WorkflowService } from 'src/app/services/rest/workflow.service' | |||||||
| import { SettingsService } from 'src/app/services/settings.service' | import { SettingsService } from 'src/app/services/settings.service' | ||||||
| import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' | import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' | ||||||
| import { CheckComponent } from '../../input/check/check.component' | import { CheckComponent } from '../../input/check/check.component' | ||||||
| import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component' |  | ||||||
| import { EntriesComponent } from '../../input/entries/entries.component' | import { EntriesComponent } from '../../input/entries/entries.component' | ||||||
| import { NumberComponent } from '../../input/number/number.component' | import { NumberComponent } from '../../input/number/number.component' | ||||||
| import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component' | import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component' | ||||||
| @@ -152,7 +151,6 @@ const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter( | |||||||
|     SelectComponent, |     SelectComponent, | ||||||
|     TextAreaComponent, |     TextAreaComponent, | ||||||
|     TagsComponent, |     TagsComponent, | ||||||
|     CustomFieldsValuesComponent, |  | ||||||
|     PermissionsGroupComponent, |     PermissionsGroupComponent, | ||||||
|     PermissionsUserComponent, |     PermissionsUserComponent, | ||||||
|     ConfirmButtonComponent, |     ConfirmButtonComponent, | ||||||
| @@ -441,9 +439,6 @@ export class WorkflowEditDialogComponent | |||||||
|         assign_change_users: new FormControl(action.assign_change_users), |         assign_change_users: new FormControl(action.assign_change_users), | ||||||
|         assign_change_groups: new FormControl(action.assign_change_groups), |         assign_change_groups: new FormControl(action.assign_change_groups), | ||||||
|         assign_custom_fields: new FormControl(action.assign_custom_fields), |         assign_custom_fields: new FormControl(action.assign_custom_fields), | ||||||
|         assign_custom_fields_values: new FormControl( |  | ||||||
|           action.assign_custom_fields_values |  | ||||||
|         ), |  | ||||||
|         remove_tags: new FormControl(action.remove_tags), |         remove_tags: new FormControl(action.remove_tags), | ||||||
|         remove_all_tags: new FormControl(action.remove_all_tags), |         remove_all_tags: new FormControl(action.remove_all_tags), | ||||||
|         remove_document_types: new FormControl(action.remove_document_types), |         remove_document_types: new FormControl(action.remove_document_types), | ||||||
| @@ -570,7 +565,6 @@ export class WorkflowEditDialogComponent | |||||||
|       assign_change_users: [], |       assign_change_users: [], | ||||||
|       assign_change_groups: [], |       assign_change_groups: [], | ||||||
|       assign_custom_fields: [], |       assign_custom_fields: [], | ||||||
|       assign_custom_fields_values: {}, |  | ||||||
|       remove_tags: [], |       remove_tags: [], | ||||||
|       remove_all_tags: false, |       remove_all_tags: false, | ||||||
|       remove_document_types: [], |       remove_document_types: [], | ||||||
| @@ -649,12 +643,4 @@ export class WorkflowEditDialogComponent | |||||||
|       }) |       }) | ||||||
|     super.save() |     super.save() | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   public removeSelectedCustomField(fieldId: number, group: FormGroup) { |  | ||||||
|     group |  | ||||||
|       .get('assign_custom_fields') |  | ||||||
|       .setValue( |  | ||||||
|         group.get('assign_custom_fields').value.filter((id) => id !== fieldId) |  | ||||||
|       ) |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,32 +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)="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> |  | ||||||
| @@ -1,72 +0,0 @@ | |||||||
| 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() |  | ||||||
|   }) |  | ||||||
| }) |  | ||||||
| @@ -1,78 +0,0 @@ | |||||||
| 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,7 +7,6 @@ import { | |||||||
|   tick, |   tick, | ||||||
| } from '@angular/core/testing' | } from '@angular/core/testing' | ||||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||||
| import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type' |  | ||||||
| import { | import { | ||||||
|   DEFAULT_MATCHING_ALGORITHM, |   DEFAULT_MATCHING_ALGORITHM, | ||||||
|   MATCH_ALL, |   MATCH_ALL, | ||||||
| @@ -45,11 +44,6 @@ const nullItem = { | |||||||
|   name: 'Not assigned', |   name: 'Not assigned', | ||||||
| } | } | ||||||
|  |  | ||||||
| const negativeNullItem = { |  | ||||||
|   id: NEGATIVE_NULL_FILTER_VALUE, |  | ||||||
|   name: 'Not assigned', |  | ||||||
| } |  | ||||||
|  |  | ||||||
| let selectionModel: FilterableDropdownSelectionModel | let selectionModel: FilterableDropdownSelectionModel | ||||||
|  |  | ||||||
| describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => { | describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => { | ||||||
| @@ -70,7 +64,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|     hotkeyService = TestBed.inject(HotKeyService) |     hotkeyService = TestBed.inject(HotKeyService) | ||||||
|     fixture = TestBed.createComponent(FilterableDropdownComponent) |     fixture = TestBed.createComponent(FilterableDropdownComponent) | ||||||
|     component = fixture.componentInstance |     component = fixture.componentInstance | ||||||
|     component.selectionModel = new FilterableDropdownSelectionModel() |  | ||||||
|     selectionModel = new FilterableDropdownSelectionModel() |     selectionModel = new FilterableDropdownSelectionModel() | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
| @@ -81,7 +74,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should support reset', () => { |   it('should support reset', () => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
|     selectionModel.set(items[0].id, ToggleableItemState.Selected) |     selectionModel.set(items[0].id, ToggleableItemState.Selected) | ||||||
|     expect(selectionModel.getSelectedItems()).toHaveLength(1) |     expect(selectionModel.getSelectedItems()).toHaveLength(1) | ||||||
| @@ -103,7 +96,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should emit change when items selected', () => { |   it('should emit change when items selected', () => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
|     let newModel: FilterableDropdownSelectionModel |     let newModel: FilterableDropdownSelectionModel | ||||||
|     component.selectionModelChange.subscribe((model) => (newModel = model)) |     component.selectionModelChange.subscribe((model) => (newModel = model)) | ||||||
| @@ -117,11 +110,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|     selectionModel.set(items[0].id, ToggleableItemState.NotSelected) |     selectionModel.set(items[0].id, ToggleableItemState.NotSelected) | ||||||
|     expect(newModel.getSelectedItems()).toEqual([]) |     expect(newModel.getSelectedItems()).toEqual([]) | ||||||
|  |  | ||||||
|     expect(component.selectionModel.items).toEqual([nullItem, ...items]) |     expect(component.items).toEqual([nullItem, ...items]) | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should emit change when items excluded', () => { |   it('should emit change when items excluded', () => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
|     let newModel: FilterableDropdownSelectionModel |     let newModel: FilterableDropdownSelectionModel | ||||||
|     component.selectionModelChange.subscribe((model) => (newModel = model)) |     component.selectionModelChange.subscribe((model) => (newModel = model)) | ||||||
| @@ -131,7 +124,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should emit change when items excluded', () => { |   it('should emit change when items excluded', () => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
|     let newModel: FilterableDropdownSelectionModel |     let newModel: FilterableDropdownSelectionModel | ||||||
|     component.selectionModelChange.subscribe((model) => (newModel = model)) |     component.selectionModelChange.subscribe((model) => (newModel = model)) | ||||||
| @@ -146,8 +139,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should exclude items when excluded and not editing', () => { |   it('should exclude items when excluded and not editing', () => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.selectionModel.manyToOne = true |     component.manyToOne = true | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
|     selectionModel.set(items[0].id, ToggleableItemState.Selected) |     selectionModel.set(items[0].id, ToggleableItemState.Selected) | ||||||
|     component.excludeClicked(items[0].id) |     component.excludeClicked(items[0].id) | ||||||
| @@ -156,8 +149,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should toggle when items excluded and editing', () => { |   it('should toggle when items excluded and editing', () => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.selectionModel.manyToOne = true |     component.manyToOne = true | ||||||
|     component.editing = true |     component.editing = true | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
|     selectionModel.set(items[0].id, ToggleableItemState.NotSelected) |     selectionModel.set(items[0].id, ToggleableItemState.NotSelected) | ||||||
| @@ -167,8 +160,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should hide count for item if adding will increase size of set', () => { |   it('should hide count for item if adding will increase size of set', () => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.selectionModel.manyToOne = true |     component.manyToOne = true | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
|     expect(component.hideCount(items[0])).toBeFalsy() |     expect(component.hideCount(items[0])).toBeFalsy() | ||||||
|     selectionModel.logicalOperator = LogicalOperator.Or |     selectionModel.logicalOperator = LogicalOperator.Or | ||||||
| @@ -177,7 +170,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|  |  | ||||||
|   it('should enforce single select when editing', () => { |   it('should enforce single select when editing', () => { | ||||||
|     component.editing = true |     component.editing = true | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
|     let newModel: FilterableDropdownSelectionModel |     let newModel: FilterableDropdownSelectionModel | ||||||
|     component.selectionModelChange.subscribe((model) => (newModel = model)) |     component.selectionModelChange.subscribe((model) => (newModel = model)) | ||||||
| @@ -189,11 +182,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should support manyToOne selecting', () => { |   it('should support manyToOne selecting', () => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     selectionModel.manyToOne = false |     selectionModel.manyToOne = false | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
|     component.selectionModel.manyToOne = true |     component.manyToOne = true | ||||||
|     expect(component.selectionModel.manyToOne).toBeTruthy() |     expect(component.manyToOne).toBeTruthy() | ||||||
|     let newModel: FilterableDropdownSelectionModel |     let newModel: FilterableDropdownSelectionModel | ||||||
|     component.selectionModelChange.subscribe((model) => (newModel = model)) |     component.selectionModelChange.subscribe((model) => (newModel = model)) | ||||||
|  |  | ||||||
| @@ -204,10 +197,12 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should dynamically enable / disable modifier toggle', () => { |   it('should dynamically enable / disable modifier toggle', () => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
|     expect(component.modifierToggleEnabled).toBeTruthy() |     expect(component.modifierToggleEnabled).toBeTruthy() | ||||||
|     component.selectionModel.manyToOne = true |     selectionModel.toggle(null) | ||||||
|  |     expect(component.modifierToggleEnabled).toBeFalsy() | ||||||
|  |     component.manyToOne = true | ||||||
|     expect(component.modifierToggleEnabled).toBeFalsy() |     expect(component.modifierToggleEnabled).toBeFalsy() | ||||||
|     selectionModel.toggle(items[0].id) |     selectionModel.toggle(items[0].id) | ||||||
|     selectionModel.toggle(items[1].id) |     selectionModel.toggle(items[1].id) | ||||||
| @@ -215,7 +210,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should apply changes and close when apply button clicked', () => { |   it('should apply changes and close when apply button clicked', () => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.icon = 'tag-fill' |     component.icon = 'tag-fill' | ||||||
|     component.editing = true |     component.editing = true | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
| @@ -237,7 +232,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should apply on close if enabled', () => { |   it('should apply on close if enabled', () => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.icon = 'tag-fill' |     component.icon = 'tag-fill' | ||||||
|     component.editing = true |     component.editing = true | ||||||
|     component.applyOnClose = true |     component.applyOnClose = true | ||||||
| @@ -255,7 +250,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should focus text filter on open, support filtering, clear on close', fakeAsync(() => { |   it('should focus text filter on open, support filtering, clear on close', fakeAsync(() => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.icon = 'tag-fill' |     component.icon = 'tag-fill' | ||||||
|     fixture.nativeElement |     fixture.nativeElement | ||||||
|       .querySelector('button') |       .querySelector('button') | ||||||
| @@ -282,7 +277,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   })) |   })) | ||||||
|  |  | ||||||
|   it('should toggle & close on enter inside filter field if 1 item remains', fakeAsync(() => { |   it('should toggle & close on enter inside filter field if 1 item remains', fakeAsync(() => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.icon = 'tag-fill' |     component.icon = 'tag-fill' | ||||||
|     expect(component.selectionModel.getSelectedItems()).toEqual([]) |     expect(component.selectionModel.getSelectedItems()).toEqual([]) | ||||||
|     fixture.nativeElement |     fixture.nativeElement | ||||||
| @@ -302,7 +297,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   })) |   })) | ||||||
|  |  | ||||||
|   it('should apply & close on enter inside filter field if 1 item remains if editing', fakeAsync(() => { |   it('should apply & close on enter inside filter field if 1 item remains if editing', fakeAsync(() => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.icon = 'tag-fill' |     component.icon = 'tag-fill' | ||||||
|     component.editing = true |     component.editing = true | ||||||
|     let applyResult: ChangedItems |     let applyResult: ChangedItems | ||||||
| @@ -324,7 +319,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   })) |   })) | ||||||
|  |  | ||||||
|   it('should support arrow keyboard navigation', fakeAsync(() => { |   it('should support arrow keyboard navigation', fakeAsync(() => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.icon = 'tag-fill' |     component.icon = 'tag-fill' | ||||||
|     fixture.nativeElement |     fixture.nativeElement | ||||||
|       .querySelector('button') |       .querySelector('button') | ||||||
| @@ -369,7 +364,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   })) |   })) | ||||||
|  |  | ||||||
|   it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => { |   it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.icon = 'tag-fill' |     component.icon = 'tag-fill' | ||||||
|     fixture.nativeElement |     fixture.nativeElement | ||||||
|       .querySelector('button') |       .querySelector('button') | ||||||
| @@ -405,7 +400,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   })) |   })) | ||||||
|  |  | ||||||
|   it('should support arrow keyboard navigation after click', fakeAsync(() => { |   it('should support arrow keyboard navigation after click', fakeAsync(() => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.icon = 'tag-fill' |     component.icon = 'tag-fill' | ||||||
|     fixture.nativeElement |     fixture.nativeElement | ||||||
|       .querySelector('button') |       .querySelector('button') | ||||||
| @@ -430,9 +425,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   })) |   })) | ||||||
|  |  | ||||||
|   it('should toggle logical operator', fakeAsync(() => { |   it('should toggle logical operator', fakeAsync(() => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.icon = 'tag-fill' |     component.icon = 'tag-fill' | ||||||
|     component.selectionModel.manyToOne = true |     component.manyToOne = true | ||||||
|     selectionModel.set(items[0].id, ToggleableItemState.Selected) |     selectionModel.set(items[0].id, ToggleableItemState.Selected) | ||||||
|     selectionModel.set(items[1].id, ToggleableItemState.Selected) |     selectionModel.set(items[1].id, ToggleableItemState.Selected) | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
| @@ -459,7 +454,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   })) |   })) | ||||||
|  |  | ||||||
|   it('should toggle intersection include / exclude', fakeAsync(() => { |   it('should toggle intersection include / exclude', fakeAsync(() => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.icon = 'tag-fill' |     component.icon = 'tag-fill' | ||||||
|     selectionModel.set(items[0].id, ToggleableItemState.Selected) |     selectionModel.set(items[0].id, ToggleableItemState.Selected) | ||||||
|     selectionModel.set(items[1].id, ToggleableItemState.Selected) |     selectionModel.set(items[1].id, ToggleableItemState.Selected) | ||||||
| @@ -488,53 +483,22 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|     expect(changedResult.getExcludedItems()).toEqual(items) |     expect(changedResult.getExcludedItems()).toEqual(items) | ||||||
|   })) |   })) | ||||||
|  |  | ||||||
|   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', () => { |   it('selection model should sort items by state', () => { | ||||||
|  |     component.items = items.concat([{ id: null, name: 'Null B' }]) | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
|     component.selectionModel.items = items.concat([{ id: 3, name: 'Item3' }]) |  | ||||||
|     selectionModel.toggle(items[1].id) |     selectionModel.toggle(items[1].id) | ||||||
|     selectionModel.apply() |     selectionModel.apply() | ||||||
|     expect(selectionModel.items.length).toEqual(4) |  | ||||||
|     expect(selectionModel.items).toEqual([ |     expect(selectionModel.items).toEqual([ | ||||||
|       nullItem, |       nullItem, | ||||||
|  |       { id: null, name: 'Null B' }, | ||||||
|       items[1], |       items[1], | ||||||
|       { id: 3, name: 'Item3' }, |  | ||||||
|       items[0], |       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', () => { |   it('selection model should sort items by state and document counts = 0, if set', () => { | ||||||
|     const tagA = { id: 4, name: 'Tag A' } |     const tagA = { id: 4, name: 'Tag A' } | ||||||
|     component.selectionModel.items = items.concat([tagA]) |     component.items = items.concat([tagA]) | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
|     component.documentCounts = [ |     component.documentCounts = [ | ||||||
|       { id: 1, document_count: 0 }, // Tag1 |       { id: 1, document_count: 0 }, // Tag1 | ||||||
| @@ -565,7 +529,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should set support create, keep open model and call createRef method', fakeAsync(() => { |   it('should set support create, keep open model and call createRef method', fakeAsync(() => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.icon = 'tag-fill' |     component.icon = 'tag-fill' | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
|     fixture.nativeElement |     fixture.nativeElement | ||||||
| @@ -585,7 +549,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   })) |   })) | ||||||
|  |  | ||||||
|   it('should call create on enter inside filter field if 0 items remain while editing', fakeAsync(() => { |   it('should call create on enter inside filter field if 0 items remain while editing', fakeAsync(() => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.icon = 'tag-fill' |     component.icon = 'tag-fill' | ||||||
|     component.editing = true |     component.editing = true | ||||||
|     component.createRef = jest.fn() |     component.createRef = jest.fn() | ||||||
| @@ -605,7 +569,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|     const id = 1 |     const id = 1 | ||||||
|     const state = ToggleableItemState.Selected |     const state = ToggleableItemState.Selected | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
|     component.selectionModel.manyToOne = true |     component.manyToOne = true | ||||||
|     component.selectionModel.singleSelect = true |     component.selectionModel.singleSelect = true | ||||||
|     component.selectionModel.intersection = Intersection.Include |     component.selectionModel.intersection = Intersection.Include | ||||||
|     component.selectionModel['temporarySelectionStates'].set(id, state) |     component.selectionModel['temporarySelectionStates'].set(id, state) | ||||||
| @@ -632,7 +596,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should support shortcut keys', () => { |   it('should support shortcut keys', () => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.icon = 'tag-fill' |     component.icon = 'tag-fill' | ||||||
|     component.shortcutKey = 't' |     component.shortcutKey = 't' | ||||||
|     fixture.detectChanges() |     fixture.detectChanges() | ||||||
| @@ -642,7 +606,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | |||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should support an extra button and not apply changes when clicked', () => { |   it('should support an extra button and not apply changes when clicked', () => { | ||||||
|     component.selectionModel.items = items |     component.items = items | ||||||
|     component.icon = 'tag-fill' |     component.icon = 'tag-fill' | ||||||
|     component.extraButtonTitle = 'Extra' |     component.extraButtonTitle = 'Extra' | ||||||
|     component.selectionModel = selectionModel |     component.selectionModel = selectionModel | ||||||
|   | |||||||
| @@ -12,13 +12,12 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms' | |||||||
| import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' | import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' | ||||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||||
| import { Subject, filter, takeUntil } from 'rxjs' | 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 { MatchingModel } from 'src/app/data/matching-model' | ||||||
| import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' | import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' | ||||||
| import { FilterPipe } from 'src/app/pipes/filter.pipe' | import { FilterPipe } from 'src/app/pipes/filter.pipe' | ||||||
| import { HotKeyService } from 'src/app/services/hot-key.service' | import { HotKeyService } from 'src/app/services/hot-key.service' | ||||||
| import { SelectionDataItem } from 'src/app/services/rest/document.service' | import { SelectionDataItem } from 'src/app/services/rest/document.service' | ||||||
| import { pngxPopperOptions } from 'src/app/utils/popper-options' | import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options' | ||||||
| import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' | import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' | ||||||
| import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' | import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' | ||||||
| import { | import { | ||||||
| @@ -62,56 +61,15 @@ export class FilterableDropdownSelectionModel { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   set items(items: MatchingModel[]) { |   set items(items: MatchingModel[]) { | ||||||
|     if (items) { |     this._items = items | ||||||
|       this._items = Array.from(items) |  | ||||||
|     this.sortItems() |     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() { |   private sortItems() { | ||||||
|     this._items.sort((a, b) => { |     this._items.sort((a, b) => { | ||||||
|       if ( |       if (a.id == null && b.id != null) { | ||||||
|         (a.id == null && b.id != null) || |  | ||||||
|         (a.id == NEGATIVE_NULL_FILTER_VALUE && |  | ||||||
|           b.id != NEGATIVE_NULL_FILTER_VALUE) |  | ||||||
|       ) { |  | ||||||
|         return -1 |         return -1 | ||||||
|       } else if ( |       } else if (a.id != null && b.id == null) { | ||||||
|         (a.id != null && b.id == null) || |  | ||||||
|         (a.id != NEGATIVE_NULL_FILTER_VALUE && |  | ||||||
|           b.id == NEGATIVE_NULL_FILTER_VALUE) |  | ||||||
|       ) { |  | ||||||
|         return 1 |         return 1 | ||||||
|       } else if ( |       } else if ( | ||||||
|         this.getNonTemporary(a.id) == ToggleableItemState.NotSelected && |         this.getNonTemporary(a.id) == ToggleableItemState.NotSelected && | ||||||
| @@ -272,7 +230,6 @@ export class FilterableDropdownSelectionModel { | |||||||
|  |  | ||||||
|   set logicalOperator(operator: LogicalOperator) { |   set logicalOperator(operator: LogicalOperator) { | ||||||
|     this.temporaryLogicalOperator = operator |     this.temporaryLogicalOperator = operator | ||||||
|     this.setNullItem() |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   toggleOperator() { |   toggleOperator() { | ||||||
| @@ -285,7 +242,6 @@ export class FilterableDropdownSelectionModel { | |||||||
|  |  | ||||||
|   set intersection(intersection: Intersection) { |   set intersection(intersection: Intersection) { | ||||||
|     this.temporaryIntersection = intersection |     this.temporaryIntersection = intersection | ||||||
|     this.setNullItem() |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   toggleIntersection() { |   toggleIntersection() { | ||||||
| @@ -294,20 +250,9 @@ export class FilterableDropdownSelectionModel { | |||||||
|       this.intersection == Intersection.Include |       this.intersection == Intersection.Include | ||||||
|         ? ToggleableItemState.Selected |         ? ToggleableItemState.Selected | ||||||
|         : ToggleableItemState.Excluded |         : ToggleableItemState.Excluded | ||||||
|  |  | ||||||
|     this.temporarySelectionStates.forEach((state, key) => { |     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.temporarySelectionStates.set(key, newState) | ||||||
|       } |  | ||||||
|     }) |     }) | ||||||
|  |  | ||||||
|     this.changed.next(this) |     this.changed.next(this) | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -329,7 +274,6 @@ export class FilterableDropdownSelectionModel { | |||||||
|     this.temporarySelectionStates.clear() |     this.temporarySelectionStates.clear() | ||||||
|     this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And |     this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And | ||||||
|     this.temporaryIntersection = this._intersection = Intersection.Include |     this.temporaryIntersection = this._intersection = Intersection.Include | ||||||
|     this.setNullItem() |  | ||||||
|     if (fireEvent) { |     if (fireEvent) { | ||||||
|       this.changed.next(this) |       this.changed.next(this) | ||||||
|     } |     } | ||||||
| @@ -361,10 +305,8 @@ export class FilterableDropdownSelectionModel { | |||||||
|  |  | ||||||
|   isNoneSelected() { |   isNoneSelected() { | ||||||
|     return ( |     return ( | ||||||
|       (this.selectionSize() == 1 && |       this.selectionSize() == 1 && | ||||||
|         this.get(null) == ToggleableItemState.Selected) || |       this.get(null) == ToggleableItemState.Selected | ||||||
|       (this.intersection == Intersection.Exclude && |  | ||||||
|         this.get(NEGATIVE_NULL_FILTER_VALUE) == ToggleableItemState.Excluded) |  | ||||||
|     ) |     ) | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -438,17 +380,29 @@ export class FilterableDropdownComponent | |||||||
|   @ViewChild('dropdown') dropdown: NgbDropdown |   @ViewChild('dropdown') dropdown: NgbDropdown | ||||||
|   @ViewChild('buttonItems') buttonItems: ElementRef |   @ViewChild('buttonItems') buttonItems: ElementRef | ||||||
|  |  | ||||||
|   public popperOptions = pngxPopperOptions |   public popperOptions = popperOptionsReenablePreventOverflow | ||||||
|  |  | ||||||
|   filterText: string |   filterText: string | ||||||
|  |  | ||||||
|   _selectionModel: FilterableDropdownSelectionModel |   @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, | ||||||
|  |       }) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   get items(): MatchingModel[] { |   get items(): MatchingModel[] { | ||||||
|     return this._selectionModel.items |     return this._selectionModel.items | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @Input({ required: true }) |   _selectionModel: FilterableDropdownSelectionModel = | ||||||
|  |     new FilterableDropdownSelectionModel() | ||||||
|  |  | ||||||
|  |   @Input() | ||||||
|   set selectionModel(model: FilterableDropdownSelectionModel) { |   set selectionModel(model: FilterableDropdownSelectionModel) { | ||||||
|     if (this.selectionModel) { |     if (this.selectionModel) { | ||||||
|       this.selectionModel.changed.complete() |       this.selectionModel.changed.complete() | ||||||
| @@ -469,6 +423,11 @@ export class FilterableDropdownComponent | |||||||
|   @Output() |   @Output() | ||||||
|   selectionModelChange = new EventEmitter<FilterableDropdownSelectionModel>() |   selectionModelChange = new EventEmitter<FilterableDropdownSelectionModel>() | ||||||
|  |  | ||||||
|  |   @Input() | ||||||
|  |   set manyToOne(manyToOne: boolean) { | ||||||
|  |     this.selectionModel.manyToOne = manyToOne | ||||||
|  |   } | ||||||
|  |  | ||||||
|   get manyToOne() { |   get manyToOne() { | ||||||
|     return this.selectionModel.manyToOne |     return this.selectionModel.manyToOne | ||||||
|   } |   } | ||||||
| @@ -525,7 +484,7 @@ export class FilterableDropdownComponent | |||||||
|     return this.manyToOne |     return this.manyToOne | ||||||
|       ? this.selectionModel.selectionSize() > 1 && |       ? this.selectionModel.selectionSize() > 1 && | ||||||
|           this.selectionModel.getExcludedItems().length == 0 |           this.selectionModel.getExcludedItems().length == 0 | ||||||
|       : true |       : !this.selectionModel.isNoneSelected() | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   get name(): string { |   get name(): string { | ||||||
|   | |||||||
| @@ -1,77 +0,0 @@ | |||||||
| <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> |  | ||||||
| @@ -1,3 +0,0 @@ | |||||||
| :host ::ng-deep .list-group-item .mb-3 { |  | ||||||
|   margin-bottom: 0 !important; |  | ||||||
| } |  | ||||||
| @@ -1,69 +0,0 @@ | |||||||
| 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) |  | ||||||
|   }) |  | ||||||
| }) |  | ||||||
| @@ -1,90 +0,0 @@ | |||||||
| 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) |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -30,24 +30,25 @@ | |||||||
|     [placeholder]="placeholder" |     [placeholder]="placeholder" | ||||||
|     [notFoundText]="notFoundText" |     [notFoundText]="notFoundText" | ||||||
|     [multiple]="true" |     [multiple]="true" | ||||||
|  |     bindValue="id" | ||||||
|     [compareWith]="compareDocuments" |     [compareWith]="compareDocuments" | ||||||
|     [trackByFn]="trackByFn" |     [trackByFn]="trackByFn" | ||||||
|     [minTermLength]="2" |     [minTermLength]="2" | ||||||
|     [loading]="loading" |     [loading]="loading" | ||||||
|     [typeahead]="documentsInput$" |     [typeahead]="documentsInput$" | ||||||
|     (mousedown)="$event.stopImmediatePropagation()" |     (mousedown)="$event.stopImmediatePropagation()" | ||||||
|     (change)="onChange(selectedDocumentIDs)"> |     (change)="onChange(selectedDocuments)"> | ||||||
|     <ng-template ng-label-tmp let-document="item"> |     <ng-template ng-label-tmp let-document="item"> | ||||||
|       <div class="d-flex align-items-center"> |       <div class="d-flex align-items-center"> | ||||||
|         @if (!disabled) { |         @if (!disabled) { | ||||||
|           <button class="btn p-0 lh-1" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title><i-bs name="x"></i-bs></button> |           <button class="btn p-0 lh-1" (click)="unselect(document)" title="Remove link" i18n-title><i-bs name="x"></i-bs></button> | ||||||
|         } |         } | ||||||
|         @if (document.title) { |         @if (document.title) { | ||||||
|           <a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title> |           <a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title> | ||||||
|             <i-bs width="0.9em" height="0.9em" name="file-text"></i-bs> <span>{{document.title}}</span> |             <i-bs width="0.9em" height="0.9em" name="file-text"></i-bs> <span>{{document.title}}</span> | ||||||
|           </a> |           </a> | ||||||
|         } @else { |         } @else { | ||||||
|           <span class="badge bg-light text-muted" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title> |           <span class="badge bg-light text-muted" (click)="unselect(document)" title="Remove link" i18n-title> | ||||||
|             <i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill"></i-bs> <span i18n>Not found</span> |             <i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill"></i-bs> <span i18n>Not found</span> | ||||||
|           </span> |           </span> | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -74,11 +74,6 @@ describe('DocumentLinkComponent', () => { | |||||||
|     expect(component.selectedDocuments).toEqual([documents[1], documents[0]]) |     expect(component.selectedDocuments).toEqual([documents[1], documents[0]]) | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should retrieve document IDs from selected documents', () => { |  | ||||||
|     component.selectedDocuments = documents |  | ||||||
|     expect(component.selectedDocumentIDs).toEqual([1, 12, 16, 23]) |  | ||||||
|   }) |  | ||||||
|  |  | ||||||
|   it('should search API on select text input', () => { |   it('should search API on select text input', () => { | ||||||
|     const listSpy = jest.spyOn(documentService, 'listFiltered') |     const listSpy = jest.spyOn(documentService, 'listFiltered') | ||||||
|     listSpy.mockImplementation( |     listSpy.mockImplementation( | ||||||
|   | |||||||
| @@ -71,10 +71,6 @@ export class DocumentLinkComponent | |||||||
|   @Input() |   @Input() | ||||||
|   placeholder: string = $localize`Search for documents` |   placeholder: string = $localize`Search for documents` | ||||||
|  |  | ||||||
|   get selectedDocumentIDs(): number[] { |  | ||||||
|     return this.selectedDocuments.map((d) => d.id) |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   constructor(private documentsService: DocumentService) { |   constructor(private documentsService: DocumentService) { | ||||||
|     super() |     super() | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ | |||||||
|           (change)="onChange(value)"> |           (change)="onChange(value)"> | ||||||
|  |  | ||||||
|           <ng-template ng-label-tmp let-item="item"> |           <ng-template ng-label-tmp let-item="item"> | ||||||
|             <button class="tag-wrap btn p-0 d-flex align-items-center" (click)="removeTag(item.id)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove tag" i18n-title> |             <button class="tag-wrap btn p-0 d-flex align-items-center" (click)="removeTag($event, item.id)" title="Remove tag" i18n-title> | ||||||
|               <i-bs name="x" style="margin-inline-end: 1px;"></i-bs> |               <i-bs name="x" style="margin-inline-end: 1px;"></i-bs> | ||||||
|               @if (item.id && tags) { |               @if (item.id && tags) { | ||||||
|                 <pngx-tag style="background-color: none;" [tag]="getTag(item.id)"></pngx-tag> |                 <pngx-tag style="background-color: none;" [tag]="getTag(item.id)"></pngx-tag> | ||||||
|   | |||||||
| @@ -154,11 +154,11 @@ describe('TagsComponent', () => { | |||||||
|   it('support remove tags', () => { |   it('support remove tags', () => { | ||||||
|     component.tags = tags |     component.tags = tags | ||||||
|     component.value = [1, 2] |     component.value = [1, 2] | ||||||
|     component.removeTag(2) |     component.removeTag(new PointerEvent('point'), 2) | ||||||
|     expect(component.value).toEqual([1]) |     expect(component.value).toEqual([1]) | ||||||
|  |  | ||||||
|     component.disabled = true |     component.disabled = true | ||||||
|     component.removeTag(1) |     component.removeTag(new PointerEvent('point'), 1) | ||||||
|     expect(component.value).toEqual([1]) |     expect(component.value).toEqual([1]) | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -118,10 +118,13 @@ export class TagsComponent implements OnInit, ControlValueAccessor { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   removeTag(tagID: number) { |   removeTag(event: PointerEvent, id: number) { | ||||||
|     if (this.disabled) return |     if (this.disabled) return | ||||||
|  |  | ||||||
|     let index = this.value.indexOf(tagID) |     // prevent opening dropdown | ||||||
|  |     event.stopImmediatePropagation() | ||||||
|  |  | ||||||
|  |     let index = this.value.indexOf(id) | ||||||
|     if (index > -1) { |     if (index > -1) { | ||||||
|       let oldValue = this.value |       let oldValue = this.value | ||||||
|       oldValue.splice(index, 1) |       oldValue.splice(index, 1) | ||||||
|   | |||||||
| @@ -0,0 +1,14 @@ | |||||||
|  | <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> | ||||||
| @@ -0,0 +1,36 @@ | |||||||
|  | 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() | ||||||
|  |   }) | ||||||
|  | }) | ||||||
| @@ -0,0 +1,33 @@ | |||||||
|  | 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() | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,68 +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)="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> |  | ||||||
| @@ -1,3 +0,0 @@ | |||||||
| .copied-badge { |  | ||||||
|     right: 15em; |  | ||||||
| } |  | ||||||
| @@ -0,0 +1,70 @@ | |||||||
|  | <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> | ||||||
| @@ -0,0 +1,14 @@ | |||||||
|  | .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; | ||||||
|  | } | ||||||
| @@ -11,18 +11,17 @@ import { | |||||||
|   tick, |   tick, | ||||||
| } from '@angular/core/testing' | } from '@angular/core/testing' | ||||||
| import { By } from '@angular/platform-browser' | import { By } from '@angular/platform-browser' | ||||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' |  | ||||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||||
| import { of, throwError } from 'rxjs' | import { of, throwError } from 'rxjs' | ||||||
| import { FileVersion, ShareLink } from 'src/app/data/share-link' | import { FileVersion, ShareLink } from 'src/app/data/share-link' | ||||||
| import { ShareLinkService } from 'src/app/services/rest/share-link.service' | import { ShareLinkService } from 'src/app/services/rest/share-link.service' | ||||||
| import { ToastService } from 'src/app/services/toast.service' | import { ToastService } from 'src/app/services/toast.service' | ||||||
| import { environment } from 'src/environments/environment' | import { environment } from 'src/environments/environment' | ||||||
| import { ShareLinksDialogComponent } from './share-links-dialog.component' | import { ShareLinksDropdownComponent } from './share-links-dropdown.component' | ||||||
| 
 | 
 | ||||||
| describe('ShareLinksDialogComponent', () => { | describe('ShareLinksDropdownComponent', () => { | ||||||
|   let component: ShareLinksDialogComponent |   let component: ShareLinksDropdownComponent | ||||||
|   let fixture: ComponentFixture<ShareLinksDialogComponent> |   let fixture: ComponentFixture<ShareLinksDropdownComponent> | ||||||
|   let shareLinkService: ShareLinkService |   let shareLinkService: ShareLinkService | ||||||
|   let toastService: ToastService |   let toastService: ToastService | ||||||
|   let httpController: HttpTestingController |   let httpController: HttpTestingController | ||||||
| @@ -31,17 +30,16 @@ describe('ShareLinksDialogComponent', () => { | |||||||
|   beforeEach(() => { |   beforeEach(() => { | ||||||
|     TestBed.configureTestingModule({ |     TestBed.configureTestingModule({ | ||||||
|       imports: [ |       imports: [ | ||||||
|         ShareLinksDialogComponent, |         ShareLinksDropdownComponent, | ||||||
|         NgxBootstrapIconsModule.pick(allIcons), |         NgxBootstrapIconsModule.pick(allIcons), | ||||||
|       ], |       ], | ||||||
|       providers: [ |       providers: [ | ||||||
|         provideHttpClient(withInterceptorsFromDi()), |         provideHttpClient(withInterceptorsFromDi()), | ||||||
|         provideHttpClientTesting(), |         provideHttpClientTesting(), | ||||||
|         NgbActiveModal, |  | ||||||
|       ], |       ], | ||||||
|     }) |     }) | ||||||
| 
 | 
 | ||||||
|     fixture = TestBed.createComponent(ShareLinksDialogComponent) |     fixture = TestBed.createComponent(ShareLinksDropdownComponent) | ||||||
|     shareLinkService = TestBed.inject(ShareLinkService) |     shareLinkService = TestBed.inject(ShareLinkService) | ||||||
|     toastService = TestBed.inject(ToastService) |     toastService = TestBed.inject(ToastService) | ||||||
|     httpController = TestBed.inject(HttpTestingController) |     httpController = TestBed.inject(HttpTestingController) | ||||||
| @@ -234,11 +232,4 @@ describe('ShareLinksDialogComponent', () => { | |||||||
|       ] |       ] | ||||||
|     ).toBeTruthy() |     ).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 { Clipboard } from '@angular/cdk/clipboard' | ||||||
| import { Component, Input, OnInit } from '@angular/core' | import { Component, Input, OnInit } from '@angular/core' | ||||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' | ||||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||||
| import { first } from 'rxjs' | import { first } from 'rxjs' | ||||||
| import { FileVersion, ShareLink } from 'src/app/data/share-link' | import { FileVersion, ShareLink } from 'src/app/data/share-link' | ||||||
| @@ -10,12 +10,17 @@ import { ToastService } from 'src/app/services/toast.service' | |||||||
| import { environment } from 'src/environments/environment' | import { environment } from 'src/environments/environment' | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'pngx-share-links-dialog', |   selector: 'pngx-share-links-dropdown', | ||||||
|   templateUrl: './share-links-dialog.component.html', |   templateUrl: './share-links-dropdown.component.html', | ||||||
|   styleUrls: ['./share-links-dialog.component.scss'], |   styleUrls: ['./share-links-dropdown.component.scss'], | ||||||
|   imports: [FormsModule, ReactiveFormsModule, NgxBootstrapIconsModule], |   imports: [ | ||||||
|  |     FormsModule, | ||||||
|  |     ReactiveFormsModule, | ||||||
|  |     NgbDropdownModule, | ||||||
|  |     NgxBootstrapIconsModule, | ||||||
|  |   ], | ||||||
| }) | }) | ||||||
| export class ShareLinksDialogComponent implements OnInit { | export class ShareLinksDropdownComponent implements OnInit { | ||||||
|   EXPIRATION_OPTIONS = [ |   EXPIRATION_OPTIONS = [ | ||||||
|     { label: $localize`1 day`, value: 1 }, |     { label: $localize`1 day`, value: 1 }, | ||||||
|     { label: $localize`7 days`, value: 7 }, |     { label: $localize`7 days`, value: 7 }, | ||||||
| @@ -36,6 +41,9 @@ export class ShareLinksDialogComponent implements OnInit { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   @Input() | ||||||
|  |   disabled: boolean = false | ||||||
|  | 
 | ||||||
|   private _hasArchiveVersion: boolean = true |   private _hasArchiveVersion: boolean = true | ||||||
| 
 | 
 | ||||||
|   @Input() |   @Input() | ||||||
| @@ -59,7 +67,6 @@ export class ShareLinksDialogComponent implements OnInit { | |||||||
|   useArchiveVersion: boolean = true |   useArchiveVersion: boolean = true | ||||||
| 
 | 
 | ||||||
|   constructor( |   constructor( | ||||||
|     private activeModal: NgbActiveModal, |  | ||||||
|     private shareLinkService: ShareLinkService, |     private shareLinkService: ShareLinkService, | ||||||
|     private toastService: ToastService, |     private toastService: ToastService, | ||||||
|     private clipboard: Clipboard |     private clipboard: Clipboard | ||||||
| @@ -162,8 +169,4 @@ export class ShareLinksDialogComponent implements OnInit { | |||||||
|         }, |         }, | ||||||
|       }) |       }) | ||||||
|   } |   } | ||||||
| 
 |  | ||||||
|   close() { |  | ||||||
|     this.activeModal.close() |  | ||||||
|   } |  | ||||||
| } | } | ||||||
| @@ -12,7 +12,7 @@ | |||||||
|     </div> |     </div> | ||||||
|   } @else { |   } @else { | ||||||
|     <div class="row row-cols-1 row-cols-md-4 g-3"> |     <div class="row row-cols-1 row-cols-md-4 g-3"> | ||||||
|       <div class="col"> |       <div class="col-4"> | ||||||
|         <div class="card bg-light h-100"> |         <div class="card bg-light h-100"> | ||||||
|           <div class="card-header"> |           <div class="card-header"> | ||||||
|             <h6 class="card-title mb-0" i18n>Environment</h6> |             <h6 class="card-title mb-0" i18n>Environment</h6> | ||||||
| @@ -46,14 +46,14 @@ | |||||||
|               <dd>{{status.database.type}}</dd> |               <dd>{{status.database.type}}</dd> | ||||||
|               <dt i18n>Status</dt> |               <dt i18n>Status</dt> | ||||||
|               <dd> |               <dd> | ||||||
|                 <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="databaseStatus" triggers="click mouseenter:mouseleave"> |                 <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="databaseStatus" triggers="mouseenter:mouseleave"> | ||||||
|                   {{status.database.status}} |                   {{status.database.status}} | ||||||
|                   @if (status.database.status === 'OK') { |                   @if (status.database.status === 'OK') { | ||||||
|                     <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs> |                     <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs> | ||||||
|                   } @else { |                   } @else { | ||||||
|                     <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs> |                     <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs> | ||||||
|                   } |                   } | ||||||
|                 </button> |                 </div> | ||||||
|                 <ng-template #databaseStatus> |                 <ng-template #databaseStatus> | ||||||
|                   @if (status.database.status === 'OK') { |                   @if (status.database.status === 'OK') { | ||||||
|                     {{status.database.url}} |                     {{status.database.url}} | ||||||
| @@ -64,7 +64,7 @@ | |||||||
|               </dd> |               </dd> | ||||||
|               <dt i18n>Migration Status</dt> |               <dt i18n>Migration Status</dt> | ||||||
|               <dd> |               <dd> | ||||||
|                 <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="migrationStatus" triggers="click mouseenter:mouseleave"> |                 <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"> | ||||||
|                   @if (status.database.migration_status.unapplied_migrations.length === 0) { |                   @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> |                     <ng-container i18n>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs> | ||||||
|                   } @else { |                   } @else { | ||||||
| @@ -81,7 +81,7 @@ | |||||||
|                       </ul> |                       </ul> | ||||||
|                     } |                     } | ||||||
|                   </ng-template> |                   </ng-template> | ||||||
|                 </button> |                 </div> | ||||||
|               </dd> |               </dd> | ||||||
|             </dl> |             </dl> | ||||||
|           </div> |           </div> | ||||||
| @@ -97,14 +97,14 @@ | |||||||
|             <dl class="card-text"> |             <dl class="card-text"> | ||||||
|               <dt i18n>Redis Status</dt> |               <dt i18n>Redis Status</dt> | ||||||
|               <dd> |               <dd> | ||||||
|                 <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="redisStatus" triggers="click mouseenter:mouseleave"> |                 <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="redisStatus" triggers="mouseenter:mouseleave"> | ||||||
|                   {{status.tasks.redis_status}} |                   {{status.tasks.redis_status}} | ||||||
|                   @if (status.tasks.redis_status === 'OK') { |                   @if (status.tasks.redis_status === 'OK') { | ||||||
|                     <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs> |                     <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs> | ||||||
|                   } @else { |                   } @else { | ||||||
|                     <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs> |                     <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs> | ||||||
|                   } |                   } | ||||||
|                 </button> |                 </div> | ||||||
|                 <ng-template #redisStatus> |                 <ng-template #redisStatus> | ||||||
|                   @if (status.tasks.redis_status === 'OK') { |                   @if (status.tasks.redis_status === 'OK') { | ||||||
|                     {{status.tasks.redis_url}} |                     {{status.tasks.redis_url}} | ||||||
| @@ -115,14 +115,14 @@ | |||||||
|               </dd> |               </dd> | ||||||
|               <dt i18n>Celery Status</dt> |               <dt i18n>Celery Status</dt> | ||||||
|               <dd> |               <dd> | ||||||
|                 <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="celeryStatus" triggers="click mouseenter:mouseleave"> |                 <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="celeryStatus" triggers="mouseenter:mouseleave"> | ||||||
|                   {{status.tasks.celery_status}} |                   {{status.tasks.celery_status}} | ||||||
|                   @if (status.tasks.celery_status === 'OK') { |                   @if (status.tasks.celery_status === 'OK') { | ||||||
|                     <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs> |                     <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs> | ||||||
|                   } @else { |                   } @else { | ||||||
|                     <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs> |                     <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs> | ||||||
|                   } |                   } | ||||||
|                 </button> |                 </div> | ||||||
|                 <ng-template #celeryStatus> |                 <ng-template #celeryStatus> | ||||||
|                   @if (status.tasks.celery_status === 'OK') { |                   @if (status.tasks.celery_status === 'OK') { | ||||||
|                     {{status.tasks.celery_url}} |                     {{status.tasks.celery_url}} | ||||||
| @@ -144,8 +144,8 @@ | |||||||
|           <div class="card-body"> |           <div class="card-body"> | ||||||
|             <dl class="card-text"> |             <dl class="card-text"> | ||||||
|               <dt i18n>Search Index</dt> |               <dt i18n>Search Index</dt> | ||||||
|               <dd class="d-flex align-items-center"> |               <dd> | ||||||
|                 <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="indexStatus" triggers="click mouseenter:mouseleave"> |                 <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"> | ||||||
|                   {{status.tasks.index_status}} |                   {{status.tasks.index_status}} | ||||||
|                   @if (status.tasks.index_status === 'OK') { |                   @if (status.tasks.index_status === 'OK') { | ||||||
|                     @if (isStale(status.tasks.index_last_modified)) { |                     @if (isStale(status.tasks.index_last_modified)) { | ||||||
| @@ -156,17 +156,7 @@ | |||||||
|                   } @else { |                   } @else { | ||||||
|                     <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs> |                     <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs> | ||||||
|                   } |                   } | ||||||
|                 </button> |                 </div> | ||||||
|                 @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> |               </dd> | ||||||
|               <ng-template #indexStatus> |               <ng-template #indexStatus> | ||||||
|                 @if (status.tasks.index_status === 'OK') { |                 @if (status.tasks.index_status === 'OK') { | ||||||
| @@ -176,8 +166,8 @@ | |||||||
|                 } |                 } | ||||||
|               </ng-template> |               </ng-template> | ||||||
|               <dt i18n>Classifier</dt> |               <dt i18n>Classifier</dt> | ||||||
|               <dd class="d-flex align-items-center"> |               <dd> | ||||||
|                 <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="classifierStatus" triggers="click mouseenter:mouseleave"> |                 <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave"> | ||||||
|                   {{status.tasks.classifier_status}} |                   {{status.tasks.classifier_status}} | ||||||
|                   @if (status.tasks.classifier_status === 'OK') { |                   @if (status.tasks.classifier_status === 'OK') { | ||||||
|                     @if (isStale(status.tasks.classifier_last_trained)) { |                     @if (isStale(status.tasks.classifier_last_trained)) { | ||||||
| @@ -190,17 +180,7 @@ | |||||||
|                     [class.text-danger]="status.tasks.classifier_status === SystemStatusItemStatus.ERROR" |                     [class.text-danger]="status.tasks.classifier_status === SystemStatusItemStatus.ERROR" | ||||||
|                     [class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING"></i-bs> |                     [class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING"></i-bs> | ||||||
|                   } |                   } | ||||||
|                 </button> |                 </div> | ||||||
|                 @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> |               </dd> | ||||||
|               <ng-template #classifierStatus> |               <ng-template #classifierStatus> | ||||||
|                 @if (status.tasks.classifier_status === 'OK') { |                 @if (status.tasks.classifier_status === 'OK') { | ||||||
| @@ -210,8 +190,8 @@ | |||||||
|                 } |                 } | ||||||
|               </ng-template> |               </ng-template> | ||||||
|               <dt i18n>Sanity Checker</dt> |               <dt i18n>Sanity Checker</dt> | ||||||
|               <dd class="d-flex align-items-center"> |               <dd> | ||||||
|                 <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="sanityCheckerStatus" triggers="click mouseenter:mouseleave"> |                 <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="sanityCheckerStatus" triggers="mouseenter:mouseleave"> | ||||||
|                   {{status.tasks.sanity_check_status}} |                   {{status.tasks.sanity_check_status}} | ||||||
|                   @if (status.tasks.sanity_check_status === 'OK') { |                   @if (status.tasks.sanity_check_status === 'OK') { | ||||||
|                     @if (isStale(status.tasks.sanity_check_last_run)) { |                     @if (isStale(status.tasks.sanity_check_last_run)) { | ||||||
| @@ -224,17 +204,7 @@ | |||||||
|                     [class.text-danger]="status.tasks.sanity_check_status === SystemStatusItemStatus.ERROR" |                     [class.text-danger]="status.tasks.sanity_check_status === SystemStatusItemStatus.ERROR" | ||||||
|                     [class.text-warning]="status.tasks.sanity_check_status === SystemStatusItemStatus.WARNING"></i-bs> |                     [class.text-warning]="status.tasks.sanity_check_status === SystemStatusItemStatus.WARNING"></i-bs> | ||||||
|                   } |                   } | ||||||
|                 </button> |                 </div> | ||||||
|                 @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> |               </dd> | ||||||
|               <ng-template #sanityCheckerStatus> |               <ng-template #sanityCheckerStatus> | ||||||
|                 @if (status.tasks.sanity_check_status === 'OK') { |                 @if (status.tasks.sanity_check_status === 'OK') { | ||||||
| @@ -251,7 +221,7 @@ | |||||||
|   } |   } | ||||||
| </div> | </div> | ||||||
| <div class="modal-footer"> | <div class="modal-footer"> | ||||||
|   <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()"> |   <button class="btn btn-sm btn-outline-secondary" (click)="copy()"> | ||||||
|     @if (!copied) { |     @if (!copied) { | ||||||
|       <i-bs name="clipboard-fill"></i-bs>  |       <i-bs name="clipboard-fill"></i-bs>  | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,3 +1,3 @@ | |||||||
| .btn.small { | .border-primary { | ||||||
|   font-size: 0.75rem; |   --bs-border-color: var(--bs-primary); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -9,16 +9,11 @@ import { | |||||||
| } from '@angular/core/testing' | } from '@angular/core/testing' | ||||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||||
| import { of, throwError } from 'rxjs' |  | ||||||
| import { PaperlessTaskName } from 'src/app/data/paperless-task' |  | ||||||
| import { | import { | ||||||
|   InstallType, |   InstallType, | ||||||
|   SystemStatus, |   SystemStatus, | ||||||
|   SystemStatusItemStatus, |   SystemStatusItemStatus, | ||||||
| } from 'src/app/data/system-status' | } 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' | import { SystemStatusDialogComponent } from './system-status-dialog.component' | ||||||
|  |  | ||||||
| const status: SystemStatus = { | const status: SystemStatus = { | ||||||
| @@ -59,9 +54,6 @@ describe('SystemStatusDialogComponent', () => { | |||||||
|   let component: SystemStatusDialogComponent |   let component: SystemStatusDialogComponent | ||||||
|   let fixture: ComponentFixture<SystemStatusDialogComponent> |   let fixture: ComponentFixture<SystemStatusDialogComponent> | ||||||
|   let clipboard: Clipboard |   let clipboard: Clipboard | ||||||
|   let tasksService: TasksService |  | ||||||
|   let systemStatusService: SystemStatusService |  | ||||||
|   let toastService: ToastService |  | ||||||
|  |  | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
|     await TestBed.configureTestingModule({ |     await TestBed.configureTestingModule({ | ||||||
| @@ -80,9 +72,6 @@ describe('SystemStatusDialogComponent', () => { | |||||||
|     component = fixture.componentInstance |     component = fixture.componentInstance | ||||||
|     component.status = status |     component.status = status | ||||||
|     clipboard = TestBed.inject(Clipboard) |     clipboard = TestBed.inject(Clipboard) | ||||||
|     tasksService = TestBed.inject(TasksService) |  | ||||||
|     systemStatusService = TestBed.inject(SystemStatusService) |  | ||||||
|     toastService = TestBed.inject(ToastService) |  | ||||||
|     fixture.detectChanges() |     fixture.detectChanges() | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
| @@ -109,37 +98,4 @@ describe('SystemStatusDialogComponent', () => { | |||||||
|     expect(component.isStale(date.toISOString())).toBeTruthy() |     expect(component.isStale(date.toISOString())).toBeTruthy() | ||||||
|     expect(component.isStale(date.toISOString(), 26)).toBeFalsy() |     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` |  | ||||||
|     ) |  | ||||||
|   }) |  | ||||||
| }) | }) | ||||||
|   | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user