mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Compare commits
	
		
			56 Commits
		
	
	
		
			feature-pa
			...
			cae38a151e
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | cae38a151e | ||
|   | 485dad01b7 | ||
|   | cf48f47a8c | ||
|   | f2667f5afa | ||
|   | d73118d226 | ||
|   | dd9e9a8c56 | ||
|   | 76d363f22d | ||
|   | aaaa6c1393 | ||
|   | bed82215a0 | ||
|   | f8aaa5cb32 | ||
|   | 1e489a0666 | ||
|   | edc7181843 | ||
|   | 89e5c08a1f | ||
|   | 0faa9e8865 | ||
|   | f205c4d0e2 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 344b2bc0eb | ||
|   | 817aad7c8b | ||
|   | d82555e644 | ||
|   | f3e6ed56b9 | ||
|   | 780d1c67e9 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 2b72397a4d | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 6c13ffaa01 | ||
|   | eb8e124971 | ||
|   | 1bc77546eb | ||
|   | 5a453653e2 | ||
|   | 16f17829b6 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 3cf1c04a83 | ||
|   | bc90ccc555 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 90a332a02c | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 0098d1bdd5 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | f6fef18a73 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 6563ec6770 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 755cf8619f | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | c6d389100c | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 20c4b65273 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 86c94c7508 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 798ece411e | ||
|   | 654c9ca273 | ||
|   | 628d85080f | ||
|   | 865e9fe233 | ||
|   | 0eb765c3e8 | ||
|   | ddeb741a85 | ||
|   | b9bcff22f8 | ||
|   | 2d52226732 | ||
|   | ec34197b59 | ||
|   | edc0e6f859 | ||
|   | 61cb5103ed | ||
|   | d364436817 | ||
|   | 827fcba277 | ||
|   | 3104417076 | ||
|   | 047f7c3619 | ||
|   | a548c32c1f | ||
|   | ea911e73c6 | ||
|   | 6b7fb286f7 | ||
|   | b40479632b | ||
|   | c122c60d3f | 
| @@ -76,18 +76,15 @@ RUN set -eux \ | ||||
|     && apt-get update \ | ||||
|     && apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES} | ||||
|  | ||||
| ARG PYTHON_PACKAGES="\ | ||||
|   python3 \ | ||||
|   python3-pip \ | ||||
|   python3-wheel \ | ||||
|   pipenv \ | ||||
|   ca-certificates" | ||||
| ARG PYTHON_PACKAGES="ca-certificates" | ||||
|  | ||||
| RUN set -eux \ | ||||
|   echo "Installing python packages" \ | ||||
|     && apt-get update \ | ||||
|     && apt-get install --yes --quiet ${PYTHON_PACKAGES} | ||||
|  | ||||
| COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /bin/uv | ||||
|  | ||||
| RUN set -eux \ | ||||
|   && echo "Installing pre-built updates" \ | ||||
|     && echo "Installing qpdf ${QPDF_VERSION}" \ | ||||
| @@ -123,13 +120,15 @@ RUN set -eux \ | ||||
| WORKDIR /usr/src/paperless/src/docker/ | ||||
|  | ||||
| COPY [ \ | ||||
|   "docker/imagemagick-policy.xml", \ | ||||
|   "docker/rootfs/etc/ImageMagick-6/paperless-policy.xml", \ | ||||
|   "./" \ | ||||
| ] | ||||
|  | ||||
| RUN set -eux \ | ||||
|   && echo "Configuring ImageMagick" \ | ||||
|     && mv imagemagick-policy.xml /etc/ImageMagick-6/policy.xml | ||||
|     && mv paperless-policy.xml /etc/ImageMagick-6/policy.xml | ||||
|  | ||||
| COPY --from=ghcr.io/astral-sh/uv:0.6 /uv /bin/uv | ||||
|  | ||||
| # Packages needed only for building a few quick Python | ||||
| # dependencies | ||||
| @@ -140,11 +139,10 @@ ARG BUILD_PACKAGES="\ | ||||
|   libpq-dev \ | ||||
|   # https://github.com/PyMySQL/mysqlclient#linux | ||||
|   default-libmysqlclient-dev \ | ||||
|   pkg-config \ | ||||
|   pre-commit" | ||||
|   pkg-config" | ||||
|  | ||||
| # hadolint ignore=DL3042 | ||||
| RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \ | ||||
| RUN --mount=type=cache,target=/root/.cache/uv,id=pip-cache \ | ||||
|   set -eux \ | ||||
|   && echo "Installing build system packages" \ | ||||
|     && apt-get update \ | ||||
| @@ -169,9 +167,6 @@ RUN set -eux \ | ||||
|     && mkdir --parents --verbose /usr/src/paperless/paperless-ngx/.venv \ | ||||
|   && echo "Adjusting all permissions" \ | ||||
|     && chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless | ||||
| #  && echo "Collecting static files" \ | ||||
| #    && gosu paperless python3 manage.py collectstatic --clear --no-input --link \ | ||||
| #    && gosu paperless python3 manage.py compilemessages | ||||
|  | ||||
| VOLUME ["/usr/src/paperless/paperless-ngx/data", \ | ||||
|         "/usr/src/paperless/paperless-ngx/media", \ | ||||
|   | ||||
							
								
								
									
										117
									
								
								.devcontainer/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								.devcontainer/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,117 @@ | ||||
| # Paperless-ngx Development Environment | ||||
|  | ||||
| ## Overview | ||||
|  | ||||
| Welcome to the Paperless-ngx development environment! This setup uses VSCode DevContainers to provide a consistent and seamless development experience. | ||||
|  | ||||
| ### What are DevContainers? | ||||
|  | ||||
| DevContainers are a feature in VSCode that allows you to develop within a Docker container. This ensures that your development environment is consistent across different machines and setups. By defining a containerized environment, you can eliminate the "works on my machine" problem. | ||||
|  | ||||
| ### Advantages of DevContainers | ||||
|  | ||||
| - **Consistency**: Same environment for all developers. | ||||
| - **Isolation**: Separate development environment from your local machine. | ||||
| - **Reproducibility**: Easily recreate the environment on any machine. | ||||
| - **Pre-configured Tools**: Include all necessary tools and dependencies in the container. | ||||
|  | ||||
| ## DevContainer Setup | ||||
|  | ||||
| The DevContainer configuration provides up all the necessary services for Paperless-ngx, including: | ||||
|  | ||||
| - Redis | ||||
| - Gotenberg | ||||
| - Tika | ||||
|  | ||||
| Data is stored using Docker volumes to ensure persistence across container restarts. | ||||
|  | ||||
| ## Configuration Files | ||||
|  | ||||
| The setup includes debugging configurations (`launch.json`) and tasks (`tasks.json`) to help you manage and debug various parts of the project: | ||||
|  | ||||
| - **Backend Debugging:** | ||||
|   - `manage.py runserver` | ||||
|   - `manage.py document-consumer` | ||||
|   - `celery` | ||||
| - **Maintenance Tasks:** | ||||
|   - Create superuser | ||||
|   - Run migrations | ||||
|   - Recreate virtual environment (`.venv` with `uv`) | ||||
|   - Compile frontend assets | ||||
|  | ||||
| ## Getting Started | ||||
|  | ||||
| ### Step 1: Running the DevContainer | ||||
|  | ||||
| To start the DevContainer: | ||||
|  | ||||
| 1. Open VSCode. | ||||
| 2. Open the project folder. | ||||
| 3. Open the command palette: | ||||
|    - **Windows/Linux**: `Ctrl+Shift+P` | ||||
|    - **Mac**: `Cmd+Shift+P` | ||||
| 4. Type and select `Dev Containers: Rebuild and Reopen in Container`. | ||||
|  | ||||
| VSCode will build and start the DevContainer environment. | ||||
|  | ||||
| ### Step 2: Initial Setup | ||||
|  | ||||
| Once the DevContainer is up and running, perform the following steps: | ||||
|  | ||||
| 1. **Compile Frontend Assets**: | ||||
|  | ||||
|    - Open the command palette: | ||||
|      - **Windows/Linux**: `Ctrl+Shift+P` | ||||
|      - **Mac**: `Cmd+Shift+P` | ||||
|    - Select `Tasks: Run Task`. | ||||
|    - Choose `Frontend Compile`. | ||||
|  | ||||
| 2. **Run Database Migrations**: | ||||
|  | ||||
|    - Open the command palette: | ||||
|      - **Windows/Linux**: `Ctrl+Shift+P` | ||||
|      - **Mac**: `Cmd+Shift+P` | ||||
|    - Select `Tasks: Run Task`. | ||||
|    - Choose `Migrate Database`. | ||||
|  | ||||
| 3. **Create Superuser**: | ||||
|    - Open the command palette: | ||||
|      - **Windows/Linux**: `Ctrl+Shift+P` | ||||
|      - **Mac**: `Cmd+Shift+P` | ||||
|    - Select `Tasks: Run Task`. | ||||
|    - Choose `Create Superuser`. | ||||
|  | ||||
| ### Debugging and Running Services | ||||
|  | ||||
| You can start and debug backend services either as debugging sessions via `launch.json` or as tasks. | ||||
|  | ||||
| #### Using `launch.json` | ||||
|  | ||||
| 1. Press `F5` or go to the **Run and Debug** view in VSCode. | ||||
| 2. Select the desired configuration: | ||||
|    - `Runserver` | ||||
|    - `Document Consumer` | ||||
|    - `Celery` | ||||
|  | ||||
| #### Using Tasks | ||||
|  | ||||
| 1. Open the command palette: | ||||
|    - **Windows/Linux**: `Ctrl+Shift+P` | ||||
|    - **Mac**: `Cmd+Shift+P` | ||||
| 2. Select `Tasks: Run Task`. | ||||
| 3. Choose the desired task: | ||||
|    - `Runserver` | ||||
|    - `Document Consumer` | ||||
|    - `Celery` | ||||
|  | ||||
| ### Additional Maintenance Tasks | ||||
|  | ||||
| Additional tasks are available for common maintenance operations: | ||||
|  | ||||
| - **Recreate .venv**: For setting up the virtual environment using `uv`. | ||||
| - **Migrate Database**: To apply database migrations. | ||||
| - **Create Superuser**: To create an admin user for the application. | ||||
|  | ||||
| ## Let's Get Started! | ||||
|  | ||||
| Follow the steps above to get your development environment up and running. Happy coding! | ||||
| @@ -3,7 +3,7 @@ | ||||
|     "dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml", | ||||
|     "service": "paperless-development", | ||||
|     "workspaceFolder": "/usr/src/paperless/paperless-ngx", | ||||
|     "postCreateCommand": "pipenv install --dev && pipenv run pre-commit install", | ||||
|     "postCreateCommand": "/bin/bash -c 'uv sync --group dev && uv run pre-commit install'", | ||||
|     "customizations": { | ||||
|         "vscode": { | ||||
|           "extensions": [ | ||||
|   | ||||
| @@ -43,7 +43,7 @@ services: | ||||
|     volumes: | ||||
|       - ..:/usr/src/paperless/paperless-ngx:delegated | ||||
|       - ../.devcontainer/vscode:/usr/src/paperless/paperless-ngx/.vscode:delegated # VSCode config files | ||||
|       - pipenv:/usr/src/paperless/paperless-ngx/.venv | ||||
|       - virtualenv:/usr/src/paperless/paperless-ngx/.venv # Virtual environment persisted in volume | ||||
|       - /usr/src/paperless/paperless-ngx/src/documents/static/frontend # Static frontend files exist only in container | ||||
|       - /usr/src/paperless/paperless-ngx/src/.pytest_cache | ||||
|       - /usr/src/paperless/paperless-ngx/.ruff_cache | ||||
| @@ -65,7 +65,7 @@ services: | ||||
|     command: /bin/sh -c "chown -R paperless:paperless /usr/src/paperless/paperless-ngx/src/documents/static/frontend && chown -R paperless:paperless /usr/src/paperless/paperless-ngx/.ruff_cache && while sleep 1000; do :; done" | ||||
|  | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:7.10 | ||||
|     image: docker.io/gotenberg/gotenberg:8.17 | ||||
|     restart: unless-stopped | ||||
|  | ||||
|     # The Gotenberg Chromium route is used to convert .eml files. We do not | ||||
| @@ -80,4 +80,7 @@ services: | ||||
|     restart: unless-stopped | ||||
|  | ||||
| volumes: | ||||
|   pipenv: | ||||
|   data: | ||||
|   media: | ||||
|   redisdata: | ||||
|   virtualenv: | ||||
|   | ||||
| @@ -5,7 +5,7 @@ | ||||
| 			"label": "Start: Celery Worker", | ||||
| 			"description": "Start the Celery Worker which processes background and consume tasks", | ||||
| 			"type": "shell", | ||||
| 			"command": "pipenv run celery --app paperless worker -l DEBUG", | ||||
| 			"command": "uv run celery --app paperless worker -l DEBUG", | ||||
| 			"isBackground": true, | ||||
| 			"options": { | ||||
| 				"cwd": "${workspaceFolder}/src" | ||||
| @@ -61,7 +61,7 @@ | ||||
| 			"label": "Start: Consumer Service (manage.py document_consumer)", | ||||
| 			"description": "Start the Consumer Service which processes files from a directory", | ||||
| 			"type": "shell", | ||||
| 			"command": "pipenv run python manage.py document_consumer", | ||||
| 			"command": "uv run python manage.py document_consumer", | ||||
| 			"group": "build", | ||||
| 			"presentation": { | ||||
| 				"echo": true, | ||||
| @@ -80,7 +80,7 @@ | ||||
| 			"label": "Start: Backend Server (manage.py runserver)", | ||||
| 			"description": "Start the Backend Server which serves the Django API and the compiled Angular frontend", | ||||
| 			"type": "shell", | ||||
| 			"command": "pipenv run python manage.py runserver", | ||||
| 			"command": "uv run python manage.py runserver", | ||||
| 			"group": "build", | ||||
| 			"presentation": { | ||||
| 				"echo": true, | ||||
| @@ -99,7 +99,7 @@ | ||||
| 			"label": "Maintenance: manage.py migrate", | ||||
| 			"description": "Apply database migrations", | ||||
| 			"type": "shell", | ||||
| 			"command": "pipenv run python manage.py migrate", | ||||
| 			"command": "uv run python manage.py migrate", | ||||
| 			"group": "none", | ||||
| 			"presentation": { | ||||
| 				"echo": true, | ||||
| @@ -118,7 +118,7 @@ | ||||
| 			"label": "Maintenance: Build Documentation", | ||||
| 			"description": "Build the documentation with MkDocs", | ||||
| 			"type": "shell", | ||||
| 			"command": "pipenv run mkdocs build --config-file mkdocs.yml && pipenv run mkdocs serve", | ||||
| 			"command": "uv run mkdocs build --config-file mkdocs.yml && uv run mkdocs serve", | ||||
| 			"group": "none", | ||||
| 			"presentation": { | ||||
| 				"echo": true, | ||||
| @@ -137,7 +137,7 @@ | ||||
| 			"label": "Maintenance: manage.py createsuperuser", | ||||
| 			"description": "Create a superuser", | ||||
| 			"type": "shell", | ||||
| 			"command": "pipenv run python manage.py createsuperuser", | ||||
| 			"command": "uv run python manage.py createsuperuser", | ||||
| 			"group": "none", | ||||
| 			"presentation": { | ||||
| 				"echo": true, | ||||
| @@ -156,7 +156,7 @@ | ||||
| 			"label": "Maintenance: recreate .venv", | ||||
| 			"description": "Recreate the python virtual environment and install python dependencies", | ||||
| 			"type": "shell", | ||||
| 			"command": "rm -R -v .venv/* || pipenv install --dev", | ||||
| 			"command": "rm -R -v .venv/* || uv install --dev", | ||||
| 			"group": "none", | ||||
| 			"presentation": { | ||||
| 				"echo": true, | ||||
|   | ||||
| @@ -27,9 +27,6 @@ indent_style = space | ||||
| [*.md] | ||||
| indent_style = space | ||||
|  | ||||
| [Pipfile.lock] | ||||
| indent_style = space | ||||
|  | ||||
| # Tests don't get a line width restriction.  It's still a good idea to follow | ||||
| # the 79 character rule, but in the interests of clarity, tests often need to | ||||
| # violate it. | ||||
|   | ||||
							
								
								
									
										1
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| github: [shamoon, stumpylog] | ||||
							
								
								
									
										12
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,6 +1,8 @@ | ||||
| # https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/configuration-options-for-dependency-updates#package-ecosystem | ||||
|  | ||||
| version: 2 | ||||
| # Required for uv support for now | ||||
| enable-beta-ecosystems: true | ||||
| updates: | ||||
|  | ||||
|   # Enable version updates for npm | ||||
| @@ -34,9 +36,8 @@ updates: | ||||
|           - "eslint" | ||||
|  | ||||
|   # Enable version updates for Python | ||||
|   - package-ecosystem: "pip" | ||||
|   - package-ecosystem: "uv" | ||||
|     target-branch: "dev" | ||||
|     # Look for a `Pipfile` in the `root` directory | ||||
|     directory: "/" | ||||
|     # Check for updates once a week | ||||
|     schedule: | ||||
| @@ -47,14 +48,13 @@ updates: | ||||
|     # Add reviewers | ||||
|     reviewers: | ||||
|       - "paperless-ngx/backend" | ||||
|     ignore: | ||||
|       - dependency-name: "uvicorn" | ||||
|     groups: | ||||
|       development: | ||||
|         patterns: | ||||
|           - "*pytest*" | ||||
|           - "ruff" | ||||
|           - "mkdocs-material" | ||||
|           - "pre-commit*" | ||||
|       django: | ||||
|         patterns: | ||||
|           - "*django*" | ||||
| @@ -65,6 +65,10 @@ updates: | ||||
|         update-types: | ||||
|           - "minor" | ||||
|           - "patch" | ||||
|       pre-built: | ||||
|         patterns: | ||||
|           - psycopg* | ||||
|           - zxing-cpp | ||||
|  | ||||
|   # Enable updates for GitHub Actions | ||||
|   - package-ecosystem: "github-actions" | ||||
|   | ||||
							
								
								
									
										148
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										148
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -14,9 +14,7 @@ on: | ||||
|       - 'translations**' | ||||
|  | ||||
| env: | ||||
|   # This is the version of pipenv all the steps will use | ||||
|   # If changing this, change Dockerfile | ||||
|   DEFAULT_PIP_ENV_VERSION: "2024.4.1" | ||||
|   DEFAULT_UV_VERSION: "0.6.x" | ||||
|   # This is the default version of Python to use in most steps which aren't specific | ||||
|   DEFAULT_PYTHON_VERSION: "3.11" | ||||
|  | ||||
| @@ -59,24 +57,25 @@ jobs: | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|           cache: "pipenv" | ||||
|           cache-dependency-path: 'Pipfile.lock' | ||||
|       - | ||||
|         name: Install pipenv | ||||
|         run: | | ||||
|           pip install --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} | ||||
|         name: Install uv | ||||
|         uses: astral-sh/setup-uv@v5 | ||||
|         with: | ||||
|           version: ${{ env.DEFAULT_UV_VERSION }} | ||||
|           enable-cache: true | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - | ||||
|         name: Install dependencies | ||||
|         name: Install Python dependencies | ||||
|         run: | | ||||
|           pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev | ||||
|       - | ||||
|         name: List installed Python dependencies | ||||
|         run: | | ||||
|           pipenv --python ${{ steps.setup-python.outputs.python-version }} run pip list | ||||
|           uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen | ||||
|       - | ||||
|         name: Make documentation | ||||
|         run: | | ||||
|           pipenv --python ${{ steps.setup-python.outputs.python-version }} run mkdocs build --config-file ./mkdocs.yml | ||||
|           uv run \ | ||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ | ||||
|             --dev \ | ||||
|             --frozen \ | ||||
|             mkdocs build --config-file ./mkdocs.yml | ||||
|       - | ||||
|         name: Deploy documentation | ||||
|         if: github.event_name == 'push' && github.ref == 'refs/heads/main' | ||||
| @@ -84,7 +83,11 @@ jobs: | ||||
|           echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME" | ||||
|           git config --global user.name "${{ github.actor }}" | ||||
|           git config --global user.email "${{ github.actor }}@users.noreply.github.com" | ||||
|           pipenv --python ${{ steps.setup-python.outputs.python-version }} run mkdocs gh-deploy --force --no-history | ||||
|           uv run \ | ||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ | ||||
|             --dev \ | ||||
|             --frozen \ | ||||
|             mkdocs gh-deploy --force --no-history | ||||
|       - | ||||
|         name: Upload artifact | ||||
|         uses: actions/upload-artifact@v4 | ||||
| @@ -117,12 +120,13 @@ jobs: | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: "${{ matrix.python-version }}" | ||||
|           cache: "pipenv" | ||||
|           cache-dependency-path: 'Pipfile.lock' | ||||
|       - | ||||
|         name: Install pipenv | ||||
|         run: | | ||||
|           pip install --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} | ||||
|         name: Install uv | ||||
|         uses: astral-sh/setup-uv@v5 | ||||
|         with: | ||||
|           version: ${{ env.DEFAULT_UV_VERSION }} | ||||
|           enable-cache: true | ||||
|           python-version: ${{ steps.setup-python.outputs.python-version }} | ||||
|       - | ||||
|         name: Install system dependencies | ||||
|         run: | | ||||
| @@ -135,12 +139,14 @@ jobs: | ||||
|       - | ||||
|         name: Install Python dependencies | ||||
|         run: | | ||||
|           pipenv --python ${{ steps.setup-python.outputs.python-version }} run python --version | ||||
|           pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev | ||||
|           uv sync \ | ||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ | ||||
|             --group testing \ | ||||
|             --frozen | ||||
|       - | ||||
|         name: List installed Python dependencies | ||||
|         run: | | ||||
|           pipenv --python ${{ steps.setup-python.outputs.python-version }} run pip list | ||||
|           uv pip list | ||||
|       - | ||||
|         name: Tests | ||||
|         env: | ||||
| @@ -150,17 +156,22 @@ jobs: | ||||
|           PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }} | ||||
|           PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }} | ||||
|         run: | | ||||
|           cd src/ | ||||
|           pipenv --python ${{ steps.setup-python.outputs.python-version }} run pytest -ra | ||||
|           uv run \ | ||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ | ||||
|             --dev \ | ||||
|             --frozen \ | ||||
|             pytest | ||||
|       - | ||||
|         name: Upload coverage | ||||
|         if: ${{ matrix.python-version == env.DEFAULT_PYTHON_VERSION }} | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         with: | ||||
|           name: backend-coverage-report | ||||
|           path: src/coverage.xml | ||||
|           path: | | ||||
|             coverage.xml | ||||
|             junit.xml | ||||
|           retention-days: 7 | ||||
|           if-no-files-found: warn | ||||
|           if-no-files-found: error | ||||
|       - | ||||
|         name: Stop containers | ||||
|         if: always() | ||||
| @@ -234,6 +245,8 @@ jobs: | ||||
|         run: cd src-ui && npm run lint | ||||
|       - | ||||
|         name: Run Jest unit tests | ||||
|         env: | ||||
|           JEST_JUNIT_OUTPUT_FILE: junit-report-${{ matrix.shard-index }}.xml | ||||
|         run: cd src-ui && npm run test -- --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }} | ||||
|       - | ||||
|         name: Upload Jest coverage | ||||
| @@ -246,7 +259,7 @@ jobs: | ||||
|             src-ui/coverage/lcov.info | ||||
|             src-ui/coverage/clover.xml | ||||
|           retention-days: 7 | ||||
|           if-no-files-found: warn | ||||
|           if-no-files-found: error | ||||
|       - | ||||
|         name: Run Playwright e2e tests | ||||
|         run: cd src-ui && npx playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }} | ||||
| @@ -258,6 +271,14 @@ jobs: | ||||
|           name: playwright-report-${{ matrix.shard-index }} | ||||
|           path: src-ui/playwright-report | ||||
|           retention-days: 7 | ||||
|       - | ||||
|         name: Upload frontend test results | ||||
|         if: always() | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         with: | ||||
|           name: junit-report-${{ matrix.shard-index }} | ||||
|           path: src-ui/junit-report-${{ matrix.shard-index }}.xml | ||||
|           retention-days: 7 | ||||
|  | ||||
|   tests-coverage-upload: | ||||
|     name: "Upload to Codecov" | ||||
| @@ -281,6 +302,13 @@ jobs: | ||||
|           path: src-ui/coverage/ | ||||
|           pattern: playwright-report-* | ||||
|           merge-multiple: true | ||||
|       - | ||||
|         name: Download frontend test results | ||||
|         uses: actions/download-artifact@v4 | ||||
|         with: | ||||
|           path: src-ui/junit/ | ||||
|           pattern: junit-report-* | ||||
|           merge-multiple: true | ||||
|       - | ||||
|         name: Upload frontend coverage to Codecov | ||||
|         uses: codecov/codecov-action@v5 | ||||
| @@ -291,6 +319,14 @@ jobs: | ||||
|           directory: src-ui/coverage/ | ||||
|           # dont include backend coverage files here | ||||
|           files: '!coverage.xml' | ||||
|       - | ||||
|         name: Upload frontend test results to Codecov | ||||
|         if: ${{ !cancelled() }} | ||||
|         uses: codecov/test-results-action@v1 | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: frontend | ||||
|           directory: src-ui/junit/ | ||||
|       - | ||||
|         name: Download backend coverage | ||||
|         uses: actions/download-artifact@v4 | ||||
| @@ -306,6 +342,14 @@ jobs: | ||||
|           # future expansion | ||||
|           flags: backend | ||||
|           directory: src/ | ||||
|       - | ||||
|         name: Upload backend test results to Codecov | ||||
|         if: ${{ !cancelled() }} | ||||
|         uses: codecov/test-results-action@v1 | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: backend | ||||
|           directory: src/ | ||||
|       - | ||||
|         name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v4 | ||||
| @@ -472,16 +516,17 @@ jobs: | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|           cache: "pipenv" | ||||
|           cache-dependency-path: 'Pipfile.lock' | ||||
|       - | ||||
|         name: Install pipenv + tools | ||||
|         run: | | ||||
|           pip install --upgrade --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} setuptools wheel | ||||
|         name: Install uv | ||||
|         uses: astral-sh/setup-uv@v5 | ||||
|         with: | ||||
|           version: ${{ env.DEFAULT_UV_VERSION }} | ||||
|           enable-cache: true | ||||
|           python-version: ${{ steps.setup-python.outputs.python-version }} | ||||
|       - | ||||
|         name: Install Python dependencies | ||||
|         run: | | ||||
|           pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev | ||||
|           uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen | ||||
|       - | ||||
|         name: Install system dependencies | ||||
|         run: | | ||||
| @@ -502,17 +547,21 @@ jobs: | ||||
|       - | ||||
|         name: Generate requirements file | ||||
|         run: | | ||||
|           pipenv --python ${{ steps.setup-python.outputs.python-version }} requirements > requirements.txt | ||||
|            uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt | ||||
|       - | ||||
|         name: Compile messages | ||||
|         run: | | ||||
|           cd src/ | ||||
|           pipenv --python ${{ steps.setup-python.outputs.python-version }} run python3 manage.py compilemessages | ||||
|           uv run \ | ||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ | ||||
|             manage.py compilemessages | ||||
|       - | ||||
|         name: Collect static files | ||||
|         run: | | ||||
|           cd src/ | ||||
|           pipenv --python ${{ steps.setup-python.outputs.python-version }} run python3 manage.py collectstatic --no-input | ||||
|           uv run \ | ||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ | ||||
|             manage.py collectstatic --no-input | ||||
|       - | ||||
|         name: Move files | ||||
|         run: | | ||||
| @@ -528,13 +577,13 @@ jobs: | ||||
|           for file_name in .dockerignore \ | ||||
|                           .env \ | ||||
|                           Dockerfile \ | ||||
|                           Pipfile \ | ||||
|                           Pipfile.lock \ | ||||
|                           pyproject.toml \ | ||||
|                           uv.lock \ | ||||
|                           requirements.txt \ | ||||
|                           LICENSE \ | ||||
|                           README.md \ | ||||
|                           paperless.conf.example \ | ||||
|                           gunicorn.conf.py | ||||
|                           webserver.py | ||||
|           do | ||||
|             cp --verbose ${file_name} dist/paperless-ngx/ | ||||
|           done | ||||
| @@ -631,15 +680,17 @@ jobs: | ||||
|           ref: main | ||||
|       - | ||||
|         name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|           cache: "pipenv" | ||||
|           cache-dependency-path: 'Pipfile.lock' | ||||
|       - | ||||
|         name: Install pipenv + tools | ||||
|         run: | | ||||
|           pip install --upgrade --user pipenv==${{ env.DEFAULT_PIP_ENV_VERSION }} setuptools wheel | ||||
|         name: Install uv | ||||
|         uses: astral-sh/setup-uv@v5 | ||||
|         with: | ||||
|           version: ${{ env.DEFAULT_UV_VERSION }} | ||||
|           enable-cache: true | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - | ||||
|         name: Append Changelog to docs | ||||
|         id: append-Changelog | ||||
| @@ -655,7 +706,10 @@ jobs: | ||||
|           CURRENT_CHANGELOG=`tail --lines +2 changelog.md` | ||||
|           echo -e "$CURRENT_CHANGELOG" >> changelog-new.md | ||||
|           mv changelog-new.md changelog.md | ||||
|           pipenv run pre-commit run --files changelog.md || true | ||||
|           uv run \ | ||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ | ||||
|             --dev \ | ||||
|             pre-commit run --files changelog.md || true | ||||
|           git config --global user.name "github-actions" | ||||
|           git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" | ||||
|           git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA" | ||||
|   | ||||
							
								
								
									
										4
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
								
							| @@ -33,7 +33,7 @@ jobs: | ||||
|       - | ||||
|         name: Clean temporary images | ||||
|         if: "${{ env.TOKEN != '' }}" | ||||
|         uses: stumpylog/image-cleaner-action/ephemeral@v0.9.0 | ||||
|         uses: stumpylog/image-cleaner-action/ephemeral@v0.10.0 | ||||
|         with: | ||||
|           token: "${{ env.TOKEN }}" | ||||
|           owner: "${{ github.repository_owner }}" | ||||
| @@ -61,7 +61,7 @@ jobs: | ||||
|       - | ||||
|         name: Clean untagged images | ||||
|         if: "${{ env.TOKEN != '' }}" | ||||
|         uses: stumpylog/image-cleaner-action/untagged@v0.9.0 | ||||
|         uses: stumpylog/image-cleaner-action/untagged@v0.10.0 | ||||
|         with: | ||||
|           token: "${{ env.TOKEN }}" | ||||
|           owner: "${{ github.repository_owner }}" | ||||
|   | ||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -44,6 +44,7 @@ nosetests.xml | ||||
| coverage.xml | ||||
| *,cover | ||||
| .pytest_cache | ||||
| junit.xml | ||||
|  | ||||
| # Translations | ||||
| *.mo | ||||
|   | ||||
| @@ -45,16 +45,19 @@ repos: | ||||
|           - javascript | ||||
|           - ts | ||||
|           - markdown | ||||
|         exclude: "(^Pipfile\\.lock$)" | ||||
|         additional_dependencies: | ||||
|           - prettier@3.3.3 | ||||
|           - 'prettier-plugin-organize-imports@4.1.0' | ||||
|   # Python hooks | ||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||
|     rev: v0.9.6 | ||||
|     rev: v0.9.9 | ||||
|     hooks: | ||||
|       - id: ruff | ||||
|       - id: ruff-format | ||||
|   - repo: https://github.com/tox-dev/pyproject-fmt | ||||
|     rev: "v2.5.1" | ||||
|     hooks: | ||||
|       - id: pyproject-fmt | ||||
|   # Dockerfile hooks | ||||
|   - repo: https://github.com/AleksaC/hadolint-py | ||||
|     rev: v2.12.0.3 | ||||
|   | ||||
| @@ -1 +0,0 @@ | ||||
| 3.10.15 | ||||
							
								
								
									
										87
									
								
								.ruff.toml
									
									
									
									
									
								
							
							
						
						
									
										87
									
								
								.ruff.toml
									
									
									
									
									
								
							| @@ -1,87 +0,0 @@ | ||||
| fix = true | ||||
| line-length = 88 | ||||
| respect-gitignore = true | ||||
| src = ["src"] | ||||
| target-version = "py310" | ||||
| output-format = "grouped" | ||||
| show-fixes = true | ||||
|  | ||||
| # https://docs.astral.sh/ruff/settings/ | ||||
| # https://docs.astral.sh/ruff/rules/ | ||||
| [lint] | ||||
| extend-select = [ | ||||
|   "W",     # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w | ||||
|   "I",     # https://docs.astral.sh/ruff/rules/#isort-i | ||||
|   "UP",    # https://docs.astral.sh/ruff/rules/#pyupgrade-up | ||||
|   "COM",   # https://docs.astral.sh/ruff/rules/#flake8-commas-com | ||||
|   "DJ",    # https://docs.astral.sh/ruff/rules/#flake8-django-dj | ||||
|   "EXE",   # https://docs.astral.sh/ruff/rules/#flake8-executable-exe | ||||
|   "ISC",   # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc | ||||
|   "ICN",   # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn | ||||
|   "G201",  # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g | ||||
|   "INP",   # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp | ||||
|   "PIE",   # https://docs.astral.sh/ruff/rules/#flake8-pie-pie | ||||
|   "Q",     # https://docs.astral.sh/ruff/rules/#flake8-quotes-q | ||||
|   "RSE",   # https://docs.astral.sh/ruff/rules/#flake8-raise-rse | ||||
|   "T20",   # https://docs.astral.sh/ruff/rules/#flake8-print-t20 | ||||
|   "SIM",   # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim | ||||
|   "TID",   # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid | ||||
|   "TCH",   # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tch | ||||
|   "PLC",   # https://docs.astral.sh/ruff/rules/#pylint-pl | ||||
|   "PLE",   # https://docs.astral.sh/ruff/rules/#pylint-pl | ||||
|   "RUF",   # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf | ||||
|   "FLY",   # https://docs.astral.sh/ruff/rules/#flynt-fly | ||||
|   "PTH",   # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth | ||||
|   "FBT",   # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt | ||||
| ] | ||||
| ignore = ["DJ001", "SIM105", "RUF012"] | ||||
|  | ||||
| [lint.per-file-ignores] | ||||
| ".github/scripts/*.py" = ["E501", "INP001", "SIM117"] | ||||
| "docker/wait-for-redis.py" = ["INP001", "T201"] | ||||
| "src/documents/file_handling.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/management/commands/document_consumer.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/management/commands/document_exporter.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/migrations/0012_auto_20160305_0040.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/migrations/0014_document_checksum.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/migrations/1003_mime_types.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/migrations/1012_fix_archive_files.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/models.py" = ["SIM115", "PTH"]  # TODO PTH Enable & remove | ||||
| "src/documents/parsers.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/signals/handlers.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/tasks.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/tests/test_api_app_config.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/tests/test_classifier.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/tests/test_consumer.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/tests/test_file_handling.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/tests/test_management.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/tests/test_management_consumer.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/tests/test_management_exporter.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/tests/test_management_thumbnails.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/tests/test_migration_archive_files.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/tests/test_migration_document_pages_count.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/tests/test_migration_mime_type.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/tests/test_sanity_check.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/tests/test_tasks.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/tests/test_views.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/documents/views.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/paperless/checks.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/paperless/settings.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/paperless/tests/test_checks.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/paperless/urls.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/paperless/views.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/paperless_mail/mail.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/paperless_mail/preprocessor.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/paperless_tesseract/parsers.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/paperless_tesseract/tests/test_parser.py" = ["RUF001", "PTH"]  # TODO PTH Enable & remove | ||||
| "src/paperless_tika/tests/test_live_tika.py" = ["PTH"]  # TODO Enable & remove | ||||
| "src/paperless_tika/tests/test_tika_parser.py" = ["PTH"]  # TODO Enable & remove | ||||
| # Testing | ||||
| "*/tests/*.py" = ["E501", "SIM117"] | ||||
| # Migrations | ||||
| "*/migrations/*.py" = ["E501", "SIM", "T201"] | ||||
| # Docker specific | ||||
| "docker/rootfs/usr/local/bin/wait-for-redis.py" = ["INP001", "T201"] | ||||
|  | ||||
| [lint.isort] | ||||
| force-single-line = true | ||||
| @@ -5,5 +5,6 @@ | ||||
| /src-ui/ @paperless-ngx/frontend | ||||
|  | ||||
| /src/ @paperless-ngx/backend | ||||
| Pipfile* @paperless-ngx/backend | ||||
| pyproject.toml @paperless-ngx/backend | ||||
| uv.lock @paperless-ngx/backend | ||||
| *.py @paperless-ngx/backend | ||||
|   | ||||
							
								
								
									
										50
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										50
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -26,28 +26,11 @@ esac | ||||
| RUN set -eux \ | ||||
|   && ./node_modules/.bin/ng build --configuration production | ||||
|  | ||||
| # Stage: pipenv-base | ||||
| # Purpose: Generates a requirements.txt file for building | ||||
| # Comments: | ||||
| #  - pipenv dependencies are not left in the final image | ||||
| #  - pipenv can't touch the final image somehow | ||||
| FROM --platform=$BUILDPLATFORM docker.io/python:3.12-alpine AS pipenv-base | ||||
|  | ||||
| WORKDIR /usr/src/pipenv | ||||
|  | ||||
| COPY Pipfile* ./ | ||||
|  | ||||
| RUN set -eux \ | ||||
|   && echo "Installing pipenv" \ | ||||
|     && python3 -m pip install --no-cache-dir --upgrade pipenv==2024.4.1 \ | ||||
|   && echo "Generating requirement.txt" \ | ||||
|     && pipenv requirements > requirements.txt | ||||
|  | ||||
| # Stage: s6-overlay-base | ||||
| # Purpose: Installs s6-overlay and rootfs | ||||
| # Comments: | ||||
| #  - Don't leave anything extra in here either | ||||
| FROM docker.io/python:3.12-slim-bookworm AS s6-overlay-base | ||||
| FROM ghcr.io/astral-sh/uv:0.6.3-python3.12-bookworm-slim AS s6-overlay-base | ||||
|  | ||||
| WORKDIR /usr/src/s6 | ||||
|  | ||||
| @@ -123,9 +106,12 @@ ARG GS_VERSION=10.03.1 | ||||
| # Set Python environment variables | ||||
| ENV PYTHONDONTWRITEBYTECODE=1 \ | ||||
|     PYTHONUNBUFFERED=1 \ | ||||
|     # Ignore warning from Whitenoise | ||||
|     # Ignore warning from Whitenoise about async iterators | ||||
|     PYTHONWARNINGS="ignore:::django.http.response:517" \ | ||||
|     PNGX_CONTAINERIZED=1 | ||||
|     PNGX_CONTAINERIZED=1 \ | ||||
|     # https://docs.astral.sh/uv/reference/settings/#link-mode | ||||
|     UV_LINK_MODE=copy \ | ||||
|     UV_CACHE_DIR=/cache/uv/ | ||||
|  | ||||
| # | ||||
| # Begin installation and configuration | ||||
| @@ -204,46 +190,34 @@ RUN set -eux \ | ||||
|         && rm --force --verbose *.deb \ | ||||
|     && rm --recursive --force --verbose /var/lib/apt/lists/* | ||||
|  | ||||
| # Copy gunicorn config | ||||
| # Copy webserver config | ||||
| # Changes very infrequently | ||||
| WORKDIR /usr/src/paperless/ | ||||
|  | ||||
| COPY --chown=1000:1000 gunicorn.conf.py /usr/src/paperless/gunicorn.conf.py | ||||
| COPY --chown=1000:1000 webserver.py /usr/src/paperless/webserver.py | ||||
|  | ||||
| WORKDIR /usr/src/paperless/src/ | ||||
|  | ||||
| # Python dependencies | ||||
| # Change pretty frequently | ||||
| COPY --chown=1000:1000 --from=pipenv-base /usr/src/pipenv/requirements.txt ./ | ||||
| COPY --chown=1000:1000 ["pyproject.toml", "uv.lock", "/usr/src/paperless/src/"] | ||||
|  | ||||
| # Packages needed only for building a few quick Python | ||||
| # dependencies | ||||
| ARG BUILD_PACKAGES="\ | ||||
|   build-essential \ | ||||
|   git \ | ||||
|   # https://www.psycopg.org/docs/install.html#prerequisites | ||||
|   libpq-dev \ | ||||
|   # https://github.com/PyMySQL/mysqlclient#linux | ||||
|   default-libmysqlclient-dev \ | ||||
|   pkg-config" | ||||
|  | ||||
| ARG ZXING_VERSION=2.3.0 | ||||
| ARG PSYCOPG_VERSION=3.2.4 | ||||
|  | ||||
| # hadolint ignore=DL3042 | ||||
| RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \ | ||||
| RUN --mount=type=cache,target=${UV_CACHE_DIR},id=python-cache \ | ||||
|   set -eux \ | ||||
|   && echo "Installing build system packages" \ | ||||
|     && apt-get update \ | ||||
|     && apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \ | ||||
|     && python3 -m pip install --upgrade wheel \ | ||||
|   && echo "Installing Python requirements" \ | ||||
|     && curl --fail --silent --no-progress-meter --show-error --location --remote-name-all --parallel --parallel-max 4 \ | ||||
|       https://github.com/paperless-ngx/builder/releases/download/psycopg-${PSYCOPG_VERSION}/psycopg_c-${PSYCOPG_VERSION}-cp312-cp312-linux_x86_64.whl \ | ||||
|       https://github.com/paperless-ngx/builder/releases/download/psycopg-${PSYCOPG_VERSION}/psycopg_c-${PSYCOPG_VERSION}-cp312-cp312-linux_aarch64.whl \ | ||||
|       https://github.com/paperless-ngx/builder/releases/download/zxing-${ZXING_VERSION}/zxing_cpp-${ZXING_VERSION}-cp312-cp312-linux_aarch64.whl \ | ||||
|       https://github.com/paperless-ngx/builder/releases/download/zxing-${ZXING_VERSION}/zxing_cpp-${ZXING_VERSION}-cp312-cp312-linux_x86_64.whl \ | ||||
|     && python3 -m pip install --default-timeout=1000 --find-links . --requirement requirements.txt \ | ||||
|     && uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt \ | ||||
|     && uv pip install --system --no-python-downloads --python-preference system --requirements requirements.txt \ | ||||
|   && echo "Installing NLTK data" \ | ||||
|     && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \ | ||||
|     && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \ | ||||
|   | ||||
							
								
								
									
										102
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										102
									
								
								Pipfile
									
									
									
									
									
								
							| @@ -1,102 +0,0 @@ | ||||
| [[source]] | ||||
| url = "https://pypi.python.org/simple" | ||||
| verify_ssl = true | ||||
| name = "pypi" | ||||
|  | ||||
| [packages] | ||||
| dateparser = "~=1.2" | ||||
| # WARNING: django does not use semver. | ||||
| #          Only patch versions are guaranteed to not introduce breaking changes. | ||||
| django = "~=5.1.5" | ||||
| django-allauth = {extras = ["mfa", "socialaccount"], version = "*"} | ||||
| django-auditlog = "*" | ||||
| django-celery-results = "*" | ||||
| django-compression-middleware = "*" | ||||
| django-cors-headers = "*" | ||||
| django-extensions = "*" | ||||
| django-filter = "~=25.1" | ||||
| django-guardian = "*" | ||||
| django-multiselectfield = "*" | ||||
| django-soft-delete = "*" | ||||
| djangorestframework = "~=3.15.2" | ||||
| djangorestframework-guardian = "*" | ||||
| drf-spectacular = "*" | ||||
| drf-spectacular-sidecar = "*" | ||||
| drf-writable-nested = "*" | ||||
| bleach = "*" | ||||
| celery = {extras = ["redis"], version = "*"} | ||||
| channels = "~=4.2" | ||||
| channels-redis = "*" | ||||
| concurrent-log-handler = "*" | ||||
| filelock = "*" | ||||
| flower = "*" | ||||
| gotenberg-client = "*" | ||||
| gunicorn = "*" | ||||
| httpx-oauth = "*" | ||||
| imap-tools = "*" | ||||
| inotifyrecursive = "~=0.3" | ||||
| jinja2 = "~=3.1" | ||||
| langdetect = "*" | ||||
| mysqlclient = "*" | ||||
| nltk = "*" | ||||
| ocrmypdf = "~=16.9" | ||||
| pathvalidate = "*" | ||||
| pdf2image = "*" | ||||
| psycopg = {version = "*", extras = ["c"]} | ||||
| python-dateutil = "*" | ||||
| python-dotenv = "*" | ||||
| python-gnupg = "*" | ||||
| python-ipware = "*" | ||||
| python-magic = "*" | ||||
| pyzbar = "*" | ||||
| rapidfuzz = "*" | ||||
| redis = {extras = ["hiredis"], version = "*"} | ||||
| scikit-learn = "~=1.6" | ||||
| setproctitle = "*" | ||||
| tika-client = "*" | ||||
| tqdm = "*" | ||||
| # See https://github.com/paperless-ngx/paperless-ngx/issues/5494 | ||||
| uvicorn = {extras = ["standard"], version = "==0.25.0"} | ||||
| watchdog = "~=6.0" | ||||
| whitenoise = "~=6.9" | ||||
| whoosh = "~=2.7" | ||||
| zxing-cpp = "*" | ||||
|  | ||||
|  | ||||
| [dev-packages] | ||||
| # Linting | ||||
| pre-commit = "*" | ||||
| ruff = "*" | ||||
| factory-boy = "*" | ||||
| # Testing | ||||
| pytest = "*" | ||||
| pytest-cov = "*" | ||||
| pytest-django = "*" | ||||
| pytest-httpx = "*" | ||||
| pytest-env = "*" | ||||
| pytest-sugar = "*" | ||||
| pytest-xdist = "*" | ||||
| pytest-mock = "*" | ||||
| pytest-rerunfailures = "*" | ||||
| imagehash = "*" | ||||
| daphne = "*" | ||||
| # Documentation | ||||
| mkdocs-material = "*" | ||||
| mkdocs-glightbox = "*" | ||||
|  | ||||
| [typing-dev] | ||||
| mypy = "*" | ||||
| types-Pillow = "*" | ||||
| django-filter-stubs = "*" | ||||
| types-python-dateutil = "*" | ||||
| djangorestframework-stubs = {extras= ["compatible-mypy"], version="*"} | ||||
| celery-types = "*" | ||||
| django-stubs = {extras= ["compatible-mypy"], version="*"} | ||||
| types-dateparser = "*" | ||||
| types-bleach = "*" | ||||
| types-redis = "*" | ||||
| types-tqdm = "*" | ||||
| types-Markdown = "*" | ||||
| types-Pygments = "*" | ||||
| types-colorama = "*" | ||||
| types-setuptools = "*" | ||||
							
								
								
									
										4978
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4978
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -5,7 +5,7 @@ | ||||
|  | ||||
| services: | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:8.7 | ||||
|     image: docker.io/gotenberg/gotenberg:8.17 | ||||
|     hostname: gotenberg | ||||
|     container_name: gotenberg | ||||
|     network_mode: host | ||||
|   | ||||
| @@ -77,7 +77,7 @@ services: | ||||
|       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 | ||||
|  | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:8.7 | ||||
|     image: docker.io/gotenberg/gotenberg:8.17 | ||||
|     restart: unless-stopped | ||||
|     # The gotenberg chromium route is used to convert .eml files. We do not | ||||
|     # want to allow external content like tracking pixels or even javascript. | ||||
|   | ||||
| @@ -71,7 +71,7 @@ services: | ||||
|       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 | ||||
|  | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:8.7 | ||||
|     image: docker.io/gotenberg/gotenberg:8.17 | ||||
|     restart: unless-stopped | ||||
|  | ||||
|     # The gotenberg chromium route is used to convert .eml files. We do not | ||||
|   | ||||
| @@ -59,7 +59,7 @@ services: | ||||
|       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 | ||||
|  | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:8.7 | ||||
|     image: docker.io/gotenberg/gotenberg:8.17 | ||||
|     restart: unless-stopped | ||||
|  | ||||
|     # The gotenberg chromium route is used to convert .eml files. We do not | ||||
|   | ||||
| @@ -1,10 +1,18 @@ | ||||
| #!/command/with-contenv /usr/bin/bash | ||||
| # shellcheck shell=bash | ||||
|  | ||||
| cd ${PAPERLESS_SRC_DIR} | ||||
|  | ||||
| if [[ -n "${USER_IS_NON_ROOT}" ]]; then | ||||
| 	exec python3 manage.py document_consumer | ||||
| if [[ -n "${PAPERLESS_CONSUMER_DISABLE}" ]]; then | ||||
| 	echo "[svc-consumer] Consumer is disabled, exiting" | ||||
| 	# https://skarnet.org/software/s6/s6-svc.html | ||||
| 	s6-svc -Od . | ||||
|  | ||||
| else | ||||
| 	exec s6-setuidgid paperless python3 manage.py document_consumer | ||||
| 	cd ${PAPERLESS_SRC_DIR} | ||||
|  | ||||
| 	if [[ -n "${USER_IS_NON_ROOT}" ]]; then | ||||
| 		exec python3 manage.py document_consumer | ||||
| 	else | ||||
| 		exec s6-setuidgid paperless python3 manage.py document_consumer | ||||
| 	fi | ||||
| fi | ||||
|   | ||||
| @@ -4,7 +4,7 @@ | ||||
| cd ${PAPERLESS_SRC_DIR} | ||||
|  | ||||
| if [[ -n "${USER_IS_NON_ROOT}" ]]; then | ||||
| 	exec /usr/local/bin/gunicorn -c /usr/src/paperless/gunicorn.conf.py paperless.asgi:application | ||||
| 	exec python3 /usr/src/paperless/webserver.py | ||||
| else | ||||
| 	exec s6-setuidgid paperless /usr/local/bin/gunicorn -c /usr/src/paperless/gunicorn.conf.py paperless.asgi:application | ||||
| 	exec s6-setuidgid paperless python3 /usr/src/paperless/webserver.py | ||||
| fi | ||||
|   | ||||
| @@ -509,6 +509,12 @@ Invoice_{{ custom_fields|get_cf_value("Select Field") }}_{{ custom_fields|get_cf | ||||
|  | ||||
| This will create a path like `invoices/2022/01/01/Invoice_OptionTwo_20220101.pdf` if the custom field "Date Field" is set to January 1, 2022 and "Select Field" is set to `OptionTwo`. | ||||
|  | ||||
| You can also use a custom `slugify` filter to slufigy text: | ||||
|  | ||||
| ```jinja | ||||
| {{ title | slugify }} | ||||
| ``` | ||||
|  | ||||
| ## Automatic recovery of invalid PDFs {#pdf-recovery} | ||||
|  | ||||
| Paperless will attempt to "clean" certain invalid PDFs with `qpdf` before processing if, for example, the mime_type | ||||
|   | ||||
| @@ -557,6 +557,20 @@ This is for use with self-signed certificates against local IMAP servers. | ||||
|     Settings this value has security implications for the security of your email. | ||||
|     Understand what it does and be sure you need to before setting. | ||||
|  | ||||
| ### Authentication & SSO {#authentication} | ||||
|  | ||||
| #### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS} | ||||
|  | ||||
| : Allow users to signup for a new Paperless-ngx account. | ||||
|  | ||||
|     Defaults to False | ||||
|  | ||||
| #### [`PAPERLESS_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_ACCOUNT_DEFAULT_GROUPS} | ||||
|  | ||||
| : A list of group names that users will be added to when they sign up for a new account. Groups listed here must already exist. | ||||
|  | ||||
|     Defaults to None | ||||
|  | ||||
| #### [`PAPERLESS_SOCIALACCOUNT_PROVIDERS=<json>`](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) {#PAPERLESS_SOCIALACCOUNT_PROVIDERS} | ||||
|  | ||||
| : This variable is used to setup login and signup via social account providers which are compatible with django-allauth. | ||||
| @@ -580,12 +594,25 @@ system. See the corresponding | ||||
|  | ||||
|     Defaults to True | ||||
|  | ||||
| #### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS} | ||||
| #### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS=<bool>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS} | ||||
|  | ||||
| : Allow users to signup for a new Paperless-ngx account. | ||||
| : Sync groups from the third party authentication system (e.g. OIDC) to Paperless-ngx. When enabled, users will be added or removed from groups based on their group membership in the third party authentication system. Groups must already exist in Paperless-ngx and have the same name as in the third party authentication system. Groups are updated upon logging in via the third party authentication system, see the corresponding [django-allauth documentation](https://docs.allauth.org/en/dev/socialaccount/signals.html). | ||||
|  | ||||
| : In order to pass groups from the authentication system you will need to update your [PAPERLESS_SOCIALACCOUNT_PROVIDERS](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) setting by adding a top-level "SCOPES" setting which includes "groups", e.g.: | ||||
|  | ||||
|     ```json | ||||
|     {"openid_connect":{"SCOPE": ["openid","profile","email","groups"]... | ||||
|     ``` | ||||
|  | ||||
|     Defaults to False | ||||
|  | ||||
| #### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS} | ||||
|  | ||||
| : A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist. | ||||
| If both the [PAPERLESS_ACCOUNT_DEFAULT_GROUPS](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS) setting and this setting are used, the user will be added to both sets of groups. | ||||
|  | ||||
|     Defaults to None | ||||
|  | ||||
| #### [`PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL=<string>`](#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL) {#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL} | ||||
|  | ||||
| : The protocol used when generating URLs, e.g. login callback URLs. See the corresponding | ||||
| @@ -1030,6 +1057,11 @@ be used with caution! | ||||
|  | ||||
| ## Document Consumption {#consume_config} | ||||
|  | ||||
| #### [`PAPERLESS_CONSUMER_DISABLE=<bool>`](#PAPERLESS_CONSUMER_DISABLE) {#PAPERLESS_CONSUMER_DISABLE} | ||||
|  | ||||
| : Completely disable the directory-based consumer in docker. If you don't plan to consume documents | ||||
| via the consumption directory, you can disable the consumer to save resources. | ||||
|  | ||||
| #### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES} | ||||
|  | ||||
| : When the consumer detects a duplicate document, it will not touch | ||||
| @@ -1506,13 +1538,23 @@ increase RAM usage. | ||||
|  | ||||
|     Defaults to 1. | ||||
|  | ||||
|     !!! note | ||||
|  | ||||
|          This option may also be set with `GRANIAN_WORKERS` and | ||||
|          this option may be removed in the future | ||||
|  | ||||
| #### [`PAPERLESS_BIND_ADDR=<ip address>`](#PAPERLESS_BIND_ADDR) {#PAPERLESS_BIND_ADDR} | ||||
|  | ||||
| : The IP address the webserver will listen on inside the container. | ||||
| There are special setups where you may need to configure this value | ||||
| to restrict the Ip address or interface the webserver listens on. | ||||
|  | ||||
|     Defaults to `[::]`, meaning all interfaces, including IPv6. | ||||
|     Defaults to `::`, meaning all interfaces, including IPv6. | ||||
|  | ||||
|     !!! note | ||||
|  | ||||
|          This option may also be set with `GRANIAN_HOST` and | ||||
|          this option may be removed in the future | ||||
|  | ||||
| #### [`PAPERLESS_PORT=<port>`](#PAPERLESS_PORT) {#PAPERLESS_PORT} | ||||
|  | ||||
| @@ -1527,6 +1569,11 @@ one pod). | ||||
|  | ||||
|     Defaults to 8000. | ||||
|  | ||||
|     !!! note | ||||
|  | ||||
|          This option may also be set with `GRANIAN_PORT` and | ||||
|          this option may be removed in the future | ||||
|  | ||||
| #### [`USERMAP_UID=<uid>`](#USERMAP_UID) {#USERMAP_UID} | ||||
|  | ||||
| : The ID of the paperless user in the container. Set this to your | ||||
|   | ||||
| @@ -60,7 +60,7 @@ first-time setup. | ||||
|  | ||||
|       Every command is executed directly from the root folder of the project unless specified otherwise. | ||||
|  | ||||
| 1.  Install prerequisites + pipenv as mentioned in | ||||
| 1.  Install prerequisites + [uv](https://github.com/astral-sh/uv) as mentioned in | ||||
|     [Bare metal route](setup.md#bare_metal). | ||||
|  | ||||
| 2.  Copy `paperless.conf.example` to `paperless.conf` and enable debug | ||||
| @@ -75,17 +75,13 @@ first-time setup. | ||||
| 4.  Install the Python dependencies: | ||||
|  | ||||
|     ```bash | ||||
|     pipenv install --dev | ||||
|     $ uv sync --group dev | ||||
|     ``` | ||||
|  | ||||
|     !!! note | ||||
|  | ||||
|         Using a virtual environment is highly recommended. You can spawn one via `pipenv shell`. | ||||
|  | ||||
| 5.  Install pre-commit hooks: | ||||
|  | ||||
|     ```bash | ||||
|     pre-commit install | ||||
|     $ uv run pre-commit install | ||||
|     ``` | ||||
|  | ||||
| 6.  Apply migrations and create a superuser for your development instance: | ||||
| @@ -93,8 +89,8 @@ first-time setup. | ||||
|     ```bash | ||||
|     # src/ | ||||
|  | ||||
|     python3 manage.py migrate | ||||
|     python3 manage.py createsuperuser | ||||
|     $ uv run manage.py migrate | ||||
|     $ uv run manage.py createsuperuser | ||||
|     ``` | ||||
|  | ||||
| 7.  You can now either ... | ||||
| @@ -164,6 +160,19 @@ $ ng build --configuration production | ||||
|       complicated IF cases. Append `# noqa: E501` to disable this check | ||||
|       for certain lines. | ||||
|  | ||||
| ### Package Management | ||||
|  | ||||
| Paperless uses `uv` to manage packages and virtual environments for both development and production. | ||||
| To accomplish some common tasks using `uv`, follow the shortcuts below: | ||||
|  | ||||
| To upgrade all locked packages to the latest allowed versions: `uv lock --upgrade` | ||||
|  | ||||
| To upgrade a single locked package: `uv lock --upgrade-package <package>` | ||||
|  | ||||
| To add a new package: `uv add <package>` | ||||
|  | ||||
| To add a new development package `uv add --dev <package>` | ||||
|  | ||||
| ## Front end development | ||||
|  | ||||
| The front end is built using AngularJS. In order to get started, you need Node.js (version 14.15+) and | ||||
| @@ -332,27 +341,21 @@ LANGUAGES = [ | ||||
| The documentation is built using material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/). | ||||
| If you want to build the documentation locally, this is how you do it: | ||||
|  | ||||
| 1.  Have an active pipenv shell (`pipenv shell`) and install Python dependencies: | ||||
| 1.  Build the documentation | ||||
|  | ||||
|     ```bash | ||||
|     pipenv install --dev | ||||
|     ``` | ||||
|  | ||||
| 2.  Build the documentation | ||||
|  | ||||
|     ```bash | ||||
|     mkdocs build --config-file mkdocs.yml | ||||
|     $ uv run mkdocs build --config-file mkdocs.yml | ||||
|     ``` | ||||
|  | ||||
|     _alternatively..._ | ||||
|  | ||||
| 3.  Serve the documentation. This will spin up a | ||||
| 2.  Serve the documentation. This will spin up a | ||||
|     copy of the documentation at http://127.0.0.1:8000 | ||||
|     that will automatically refresh every time you change | ||||
|     something. | ||||
|  | ||||
|     ```bash | ||||
|     mkdocs serve | ||||
|     $ uv run mkdocs serve | ||||
|     ``` | ||||
|  | ||||
| ## Building the Docker image | ||||
|   | ||||
| @@ -133,6 +133,9 @@ Multiple options for ASGI servers exist: | ||||
|     implementation for ASGI. | ||||
| -   `uvicorn` as a standalone server | ||||
|  | ||||
| You may also find the [Django documentation](https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/) on ASGI | ||||
| useful to review. | ||||
|  | ||||
| ## _What about the Redis licensing change and using one of the open source forks_? | ||||
|  | ||||
| Currently (October 2024), forks of Redis such as Valkey or Redirect are not officially supported by our upstream | ||||
|   | ||||
| @@ -380,6 +380,12 @@ are released, dependency support is confirmed, etc. | ||||
|         dependencies.  This is an alternative to the above and may require adjusting | ||||
|         the example scripts to utilize the virtual environment paths | ||||
|  | ||||
|     !!! tip | ||||
|  | ||||
|         If you use modern Python tooling, such as `uv`, installation will not include | ||||
|         dependencies for Postgres or Mariadb.  You can select those extras with `--extra <EXTRA>` | ||||
|         or all with `--all-extras` | ||||
|  | ||||
| 9.  Go to `/opt/paperless/src`, and execute the following commands: | ||||
|  | ||||
|     ```bash | ||||
| @@ -426,31 +432,20 @@ are released, dependency support is confirmed, etc. | ||||
|  | ||||
|     !!! note | ||||
|  | ||||
|         The `socket` script enables `gunicorn` to run on port 80 without | ||||
|         The `socket` script enables `granian` to run on port 80 without | ||||
|         root privileges. For this you need to uncomment the | ||||
|         `Require=paperless-webserver.socket` in the `webserver` script | ||||
|         and configure `gunicorn` to listen on port 80 (see | ||||
|         `paperless/gunicorn.conf.py`). | ||||
|  | ||||
|     You may need to adjust the path to the `gunicorn` executable. This | ||||
|     will be installed as part of the python dependencies, and is either | ||||
|     located in the `bin` folder of your virtual environment, or in | ||||
|     `~/.local/bin/` if no virtual environment is used. | ||||
|         and configure `granian` to listen on port 80 (set `GRANIAN_PORT`). | ||||
|  | ||||
|     These services rely on redis and optionally the database server, but | ||||
|     don't need to be started in any particular order. The example files | ||||
|     depend on redis being started. If you use a database server, you | ||||
|     should add additional dependencies. | ||||
|  | ||||
|     !!! warning | ||||
|     !!! note | ||||
|  | ||||
|         The included scripts run a `gunicorn` standalone server, which is | ||||
|         fine for running paperless. It does support SSL, however, the | ||||
|         documentation of GUnicorn states that you should use a proxy server | ||||
|         in front of gunicorn instead. | ||||
|  | ||||
|         For instructions on how to use nginx for that, | ||||
|         [see the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#nginx). | ||||
|         For instructions on using a reverse proxy, | ||||
|         [see the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#). | ||||
|  | ||||
|     !!! warning | ||||
|  | ||||
| @@ -714,6 +709,8 @@ the Pi and configuring some options in paperless can help improve | ||||
| performance immensely: | ||||
|  | ||||
| -   Stick with SQLite to save some resources. | ||||
| -   If you do not need the filesystem-based consumer, consider disabling it | ||||
|     entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`. | ||||
| -   Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that paperless will | ||||
|     only OCR the first page of your documents. In most cases, this page | ||||
|     contains enough information to be able to find it. | ||||
|   | ||||
| @@ -195,34 +195,6 @@ This might have multiple reasons. | ||||
|     is not, you need to compile the front end yourself or download the | ||||
|     release archive instead of cloning the repository. | ||||
|  | ||||
| 2.  Check the output of the web server. You might see errors like this: | ||||
|  | ||||
|     ``` | ||||
|     [2021-01-25 10:08:04 +0000] [40] [ERROR] Socket error processing request. | ||||
|     Traceback (most recent call last): | ||||
|     File "/usr/local/lib/python3.7/site-packages/gunicorn/workers/sync.py", line 134, in handle | ||||
|         self.handle_request(listener, req, client, addr) | ||||
|     File "/usr/local/lib/python3.7/site-packages/gunicorn/workers/sync.py", line 190, in handle_request | ||||
|         util.reraise(*sys.exc_info()) | ||||
|     File "/usr/local/lib/python3.7/site-packages/gunicorn/util.py", line 625, in reraise | ||||
|         raise value | ||||
|     File "/usr/local/lib/python3.7/site-packages/gunicorn/workers/sync.py", line 178, in handle_request | ||||
|         resp.write_file(respiter) | ||||
|     File "/usr/local/lib/python3.7/site-packages/gunicorn/http/wsgi.py", line 396, in write_file | ||||
|         if not self.sendfile(respiter): | ||||
|     File "/usr/local/lib/python3.7/site-packages/gunicorn/http/wsgi.py", line 386, in sendfile | ||||
|         sent += os.sendfile(sockno, fileno, offset + sent, count) | ||||
|     OSError: [Errno 22] Invalid argument | ||||
|     ``` | ||||
|  | ||||
|     To fix this issue, add | ||||
|  | ||||
|     ``` | ||||
|     SENDFILE=0 | ||||
|     ``` | ||||
|  | ||||
|     to your `docker-compose.env` file. | ||||
|  | ||||
| ## Error while reading metadata | ||||
|  | ||||
| You might find messages like these in your log files: | ||||
| @@ -322,12 +294,12 @@ many documents at once often. Otherwise, try tweaking the | ||||
| [`PAPERLESS_DB_TIMEOUT`](configuration.md#PAPERLESS_DB_TIMEOUT) setting to allow more time for the database to | ||||
| unlock. This may have minor performance implications. | ||||
|  | ||||
| ## gunicorn fails to start with "is not a valid port number" | ||||
| ## granian fails to start with "is not a valid port number" | ||||
|  | ||||
| You are likely running using Kubernetes, which automatically creates an | ||||
| environment variable named `${serviceName}_PORT`. This is | ||||
| the same environment variable which is used by Paperless to optionally | ||||
| change the port gunicorn listens on. | ||||
| change the port granian listens on. | ||||
|  | ||||
| To fix this, set [`PAPERLESS_PORT`](configuration.md#PAPERLESS_PORT) again to your desired port, or the | ||||
| default of 8000. | ||||
|   | ||||
| @@ -837,7 +837,7 @@ Paperless-ngx consists of the following components: | ||||
|  | ||||
|     ```shell-session | ||||
|     cd /path/to/paperless/src/ | ||||
|     gunicorn -c ../gunicorn.conf.py paperless.wsgi | ||||
|     python3 webserver.py | ||||
|     ``` | ||||
|  | ||||
|     or by any other means such as Apache `mod_wsgi`. | ||||
|   | ||||
| @@ -1,49 +0,0 @@ | ||||
| import os | ||||
|  | ||||
| # See https://docs.gunicorn.org/en/stable/settings.html for | ||||
| # explanations of settings | ||||
|  | ||||
| bind = f"{os.getenv('PAPERLESS_BIND_ADDR', '[::]')}:{os.getenv('PAPERLESS_PORT', 8000)}" | ||||
|  | ||||
| workers = int(os.getenv("PAPERLESS_WEBSERVER_WORKERS", 1)) | ||||
| worker_class = "paperless.workers.ConfigurableWorker" | ||||
| timeout = 120 | ||||
| preload_app = True | ||||
|  | ||||
| # https://docs.gunicorn.org/en/stable/faq.html#blocking-os-fchmod | ||||
| worker_tmp_dir = "/dev/shm" | ||||
|  | ||||
|  | ||||
| def pre_fork(server, worker): | ||||
|     pass | ||||
|  | ||||
|  | ||||
| def pre_exec(server): | ||||
|     server.log.info("Forked child, re-executing.") | ||||
|  | ||||
|  | ||||
| def when_ready(server): | ||||
|     server.log.info("Server is ready. Spawning workers") | ||||
|  | ||||
|  | ||||
| def worker_int(worker): | ||||
|     worker.log.info("worker received INT or QUIT signal") | ||||
|  | ||||
|     ## get traceback info | ||||
|     import sys | ||||
|     import threading | ||||
|     import traceback | ||||
|  | ||||
|     id2name = {th.ident: th.name for th in threading.enumerate()} | ||||
|     code = [] | ||||
|     for threadId, stack in sys._current_frames().items(): | ||||
|         code.append(f"\n# Thread: {id2name.get(threadId, '')}({threadId})") | ||||
|         for filename, lineno, name, line in traceback.extract_stack(stack): | ||||
|             code.append(f'File: "{filename}", line {lineno}, in {name}') | ||||
|             if line: | ||||
|                 code.append(f"  {line.strip()}") | ||||
|     worker.log.debug("\n".join(code)) | ||||
|  | ||||
|  | ||||
| def worker_abort(worker): | ||||
|     worker.log.info("worker received SIGABRT signal") | ||||
							
								
								
									
										355
									
								
								pyproject.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										355
									
								
								pyproject.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,355 @@ | ||||
| [project] | ||||
| name = "paperless-ngx" | ||||
| version = "2.14.7" | ||||
| description = "A community-supported supercharged version of paperless: scan, index and archive all your physical documents" | ||||
| readme = "README.md" | ||||
| requires-python = ">=3.10" | ||||
| classifiers = [ | ||||
|   "Programming Language :: Python :: 3 :: Only", | ||||
|   "Programming Language :: Python :: 3.10", | ||||
|   "Programming Language :: Python :: 3.11", | ||||
|   "Programming Language :: Python :: 3.12", | ||||
|   "Programming Language :: Python :: 3.13", | ||||
| ] | ||||
| # TODO: Move certain things to groups and then utilize that further | ||||
| # This will allow testing to not install a webserver, mysql, etc | ||||
|  | ||||
| dependencies = [ | ||||
|   "bleach~=6.2.0", | ||||
|   "celery[redis]~=5.4.0", | ||||
|   "channels~=4.2", | ||||
|   "channels-redis~=4.2", | ||||
|   "concurrent-log-handler~=0.9.25", | ||||
|   "dateparser~=1.2", | ||||
|   # WARNING: django does not use semver. | ||||
|   #          Only patch versions are guaranteed to not introduce breaking changes. | ||||
|   "django~=5.1.6", | ||||
|   "django-allauth[socialaccount,mfa]~=65.4.0", | ||||
|   "django-auditlog~=3.0.0", | ||||
|   "django-celery-results~=2.5.1", | ||||
|   "django-compression-middleware~=0.5.0", | ||||
|   "django-cors-headers~=4.7.0", | ||||
|   "django-extensions~=3.2.3", | ||||
|   "django-filter~=25.1", | ||||
|   "django-guardian~=2.4.0", | ||||
|   "django-multiselectfield~=0.1.13", | ||||
|   "django-soft-delete~=1.0.18", | ||||
|   "djangorestframework~=3.15", | ||||
|   "djangorestframework-guardian~=0.3.0", | ||||
|   "drf-spectacular~=0.28", | ||||
|   "drf-spectacular-sidecar~=2025.2.1", | ||||
|   "drf-writable-nested~=0.7.1", | ||||
|   "filelock~=3.17.0", | ||||
|   "flower~=2.0.1", | ||||
|   "gotenberg-client~=0.9.0", | ||||
|   "httpx-oauth~=0.16", | ||||
|   "imap-tools~=1.10.0", | ||||
|   "inotifyrecursive~=0.3", | ||||
|   "jinja2~=3.1.5", | ||||
|   "langdetect~=1.0.9", | ||||
|   "nltk~=3.9.1", | ||||
|   "ocrmypdf~=16.9.0", | ||||
|   "pathvalidate~=3.2.3", | ||||
|   "pdf2image~=1.17.0", | ||||
|   "python-dateutil~=2.9.0", | ||||
|   "python-dotenv~=1.0.1", | ||||
|   "python-gnupg~=0.5.4", | ||||
|   "python-ipware~=3.0.0", | ||||
|   "python-magic~=0.4.27", | ||||
|   "pyzbar~=0.1.9", | ||||
|   "rapidfuzz~=3.12.1", | ||||
|   "redis[hiredis]~=5.2.1", | ||||
|   "scikit-learn~=1.6.1", | ||||
|   "setproctitle~=1.3.4", | ||||
|   "tika-client~=0.9.0", | ||||
|   "tqdm~=4.67.1", | ||||
|   "watchdog~=6.0", | ||||
|   "whitenoise~=6.9", | ||||
|   "whoosh~=2.7", | ||||
|   "zxing-cpp~=2.3.0", | ||||
| ] | ||||
|  | ||||
| optional-dependencies.mariadb = [ | ||||
|   "mysqlclient~=2.2.7", | ||||
| ] | ||||
| optional-dependencies.postgres = [ | ||||
|   "psycopg[c]==3.2.4", | ||||
|   # Direct dependency for proper resolution of the pre-built wheels | ||||
|   "psycopg-c==3.2.4", | ||||
| ] | ||||
| optional-dependencies.webserver = [ | ||||
|   "granian~=1.7.6", | ||||
| ] | ||||
|  | ||||
| [dependency-groups] | ||||
|  | ||||
| dev = [ | ||||
|   { "include-group" = "docs" }, | ||||
|   { "include-group" = "testing" }, | ||||
|   { "include-group" = "lint" }, | ||||
| ] | ||||
|  | ||||
| docs = [ | ||||
|   "mkdocs-glightbox~=0.4.0", | ||||
|   "mkdocs-material~=9.6.4", | ||||
| ] | ||||
|  | ||||
| testing = [ | ||||
|   "daphne", | ||||
|   "factory-boy~=3.3.1", | ||||
|   "imagehash", | ||||
|   "pytest~=8.3.3", | ||||
|   "pytest-cov~=6.0.0", | ||||
|   "pytest-django~=4.10.0", | ||||
|   "pytest-env", | ||||
|   "pytest-httpx", | ||||
|   "pytest-mock", | ||||
|   "pytest-rerunfailures", | ||||
|   "pytest-sugar", | ||||
|   "pytest-xdist", | ||||
| ] | ||||
|  | ||||
| lint = [ | ||||
|   "pre-commit~=4.1.0", | ||||
|   "pre-commit-uv~=4.1.3", | ||||
|   "ruff~=0.9.9", | ||||
| ] | ||||
|  | ||||
| typing = [ | ||||
|   "celery-types", | ||||
|   "django-filter-stubs", | ||||
|   "django-stubs[compatible-mypy]", | ||||
|   "djangorestframework-stubs[compatible-mypy]", | ||||
|   "mypy", | ||||
|   "types-bleach", | ||||
|   "types-colorama", | ||||
|   "types-dateparser", | ||||
|   "types-markdown", | ||||
|   "types-pygments", | ||||
|   "types-python-dateutil", | ||||
|   "types-redis", | ||||
|   "types-setuptools", | ||||
|   "types-tqdm", | ||||
| ] | ||||
|  | ||||
| [tool.ruff] | ||||
| target-version = "py310" | ||||
| line-length = 88 | ||||
| src = [ | ||||
|   "src", | ||||
| ] | ||||
| respect-gitignore = true | ||||
| # https://docs.astral.sh/ruff/settings/ | ||||
| fix = true | ||||
| show-fixes = true | ||||
|  | ||||
| output-format = "grouped" | ||||
| # https://docs.astral.sh/ruff/rules/ | ||||
| lint.extend-select = [ | ||||
|   "COM",  # https://docs.astral.sh/ruff/rules/#flake8-commas-com | ||||
|   "DJ",   # https://docs.astral.sh/ruff/rules/#flake8-django-dj | ||||
|   "EXE",  # https://docs.astral.sh/ruff/rules/#flake8-executable-exe | ||||
|   "FBT",  # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt | ||||
|   "FLY",  # https://docs.astral.sh/ruff/rules/#flynt-fly | ||||
|   "G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g | ||||
|   "I",    # https://docs.astral.sh/ruff/rules/#isort-i | ||||
|   "ICN",  # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn | ||||
|   "INP",  # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp | ||||
|   "ISC",  # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc | ||||
|   "PIE",  # https://docs.astral.sh/ruff/rules/#flake8-pie-pie | ||||
|   "PLC",  # https://docs.astral.sh/ruff/rules/#pylint-pl | ||||
|   "PLE",  # https://docs.astral.sh/ruff/rules/#pylint-pl | ||||
|   "PTH",  # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth | ||||
|   "Q",    # https://docs.astral.sh/ruff/rules/#flake8-quotes-q | ||||
|   "RSE",  # https://docs.astral.sh/ruff/rules/#flake8-raise-rse | ||||
|   "RUF",  # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf | ||||
|   "SIM",  # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim | ||||
|   "T20",  # https://docs.astral.sh/ruff/rules/#flake8-print-t20 | ||||
|   "TC",   # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc | ||||
|   "TID",  # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid | ||||
|   "UP",   # https://docs.astral.sh/ruff/rules/#pyupgrade-up | ||||
|   "W",    # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w | ||||
| ] | ||||
| lint.ignore = [ | ||||
|   "DJ001", | ||||
|   "RUF012", | ||||
|   "SIM105", | ||||
| ] | ||||
| # Migrations | ||||
| lint.per-file-ignores."*/migrations/*.py" = [ | ||||
|   "E501", | ||||
|   "SIM", | ||||
|   "T201", | ||||
| ] | ||||
| # Testing | ||||
| lint.per-file-ignores."*/tests/*.py" = [ | ||||
|   "E501", | ||||
|   "SIM117", | ||||
| ] | ||||
| lint.per-file-ignores.".github/scripts/*.py" = [ | ||||
|   "E501", | ||||
|   "INP001", | ||||
|   "SIM117", | ||||
| ] | ||||
| # Docker specific | ||||
| lint.per-file-ignores."docker/rootfs/usr/local/bin/wait-for-redis.py" = [ | ||||
|   "INP001", | ||||
|   "T201", | ||||
| ] | ||||
| lint.per-file-ignores."docker/wait-for-redis.py" = [ | ||||
|   "INP001", | ||||
|   "T201", | ||||
| ] | ||||
| lint.per-file-ignores."src/documents/file_handling.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/documents/management/commands/document_consumer.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/documents/management/commands/document_exporter.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/documents/migrations/1012_fix_archive_files.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/documents/models.py" = [ | ||||
|   "SIM115", | ||||
| ] | ||||
| lint.per-file-ignores."src/documents/parsers.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/documents/signals/handlers.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/documents/tests/test_consumer.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/documents/tests/test_file_handling.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/documents/tests/test_management.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/documents/tests/test_management_consumer.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/documents/tests/test_management_exporter.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/documents/tests/test_migration_archive_files.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/documents/tests/test_migration_document_pages_count.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/documents/tests/test_migration_mime_type.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/documents/tests/test_sanity_check.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/documents/views.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/paperless/checks.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/paperless/settings.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/paperless/views.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/paperless_mail/mail.py" = [ | ||||
|   "PTH", | ||||
| ] # TODO Enable & remove | ||||
| lint.per-file-ignores."src/paperless_tesseract/tests/test_parser.py" = [ | ||||
|   "PTH", | ||||
|   "RUF001", | ||||
| ] # TODO PTH Enable & remove | ||||
| lint.isort.force-single-line = true | ||||
|  | ||||
| [tool.pytest.ini_options] | ||||
| minversion = "8.0" | ||||
| pythonpath = [ | ||||
|   "src", | ||||
| ] | ||||
| testpaths = [ | ||||
|   "src/documents/tests/", | ||||
|   "src/paperless/tests/", | ||||
|   "src/paperless_mail/tests/", | ||||
|   "src/paperless_tesseract/tests/", | ||||
|   "src/paperless_tika/tests", | ||||
| ] | ||||
| addopts = [ | ||||
|   "--pythonwarnings=all", | ||||
|   "--cov", | ||||
|   "--cov-report=html", | ||||
|   "--cov-report=xml", | ||||
|   "--numprocesses=auto", | ||||
|   "--maxprocesses=16", | ||||
|   "--quiet", | ||||
|   "--durations=50", | ||||
|   "--junitxml=junit.xml", | ||||
|   "-o junit_family=legacy", | ||||
| ] | ||||
| norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ] | ||||
|  | ||||
| DJANGO_SETTINGS_MODULE = "paperless.settings" | ||||
|  | ||||
| [tool.pytest_env] | ||||
| PAPERLESS_DISABLE_DBHANDLER = "true" | ||||
| PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache" | ||||
|  | ||||
| [tool.coverage.run] | ||||
| source = [ | ||||
|   "src/", | ||||
| ] | ||||
| omit = [ | ||||
|   "*/tests/*", | ||||
|   "manage.py", | ||||
|   "paperless/wsgi.py", | ||||
|   "paperless/auth.py", | ||||
| ] | ||||
|  | ||||
| [tool.coverage.report] | ||||
| exclude_also = [ | ||||
|   "if settings.AUDIT_LOG_ENABLED:", | ||||
|   "if AUDIT_LOG_ENABLED:", | ||||
|   "if TYPE_CHECKING:", | ||||
| ] | ||||
|  | ||||
| [tool.mypy] | ||||
| plugins = [ | ||||
|   "mypy_django_plugin.main", | ||||
|   "mypy_drf_plugin.main", | ||||
|   "numpy.typing.mypy_plugin", | ||||
| ] | ||||
| check_untyped_defs = true | ||||
| disallow_any_generics = true | ||||
| disallow_incomplete_defs = true | ||||
| disallow_untyped_defs = true | ||||
| warn_redundant_casts = true | ||||
| warn_unused_ignores = true | ||||
|  | ||||
| [tool.uv] | ||||
| required-version = ">=0.5.14" | ||||
| package = false | ||||
| environments = [ | ||||
|   "sys_platform == 'darwin'", | ||||
|   "sys_platform == 'linux'", | ||||
| ] | ||||
|  | ||||
| [tool.uv.sources] | ||||
| # Markers are chosen to select these almost exclusively when building the Docker image | ||||
| psycopg-c = [ | ||||
|   { url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.4/psycopg_c-3.2.4-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" }, | ||||
|   { url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.4/psycopg_c-3.2.4-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" }, | ||||
| ] | ||||
| zxing-cpp = [ | ||||
|   { url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" }, | ||||
|   { url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" }, | ||||
| ] | ||||
|  | ||||
| [tool.django-stubs] | ||||
| django_settings_module = "paperless.settings" | ||||
| @@ -9,7 +9,7 @@ Requires=redis.service | ||||
| User=paperless | ||||
| Group=paperless | ||||
| WorkingDirectory=/opt/paperless/src | ||||
| ExecStart=/opt/paperless/.local/bin/gunicorn -c /opt/paperless/gunicorn.conf.py paperless.asgi:application | ||||
| ExecStart=python3 webserver.py | ||||
|  | ||||
| [Install] | ||||
| WantedBy=multi-user.target | ||||
|   | ||||
| @@ -83,10 +83,17 @@ test('date filtering', async ({ page }) => { | ||||
|   await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' }) | ||||
|   await page.goto('/documents') | ||||
|   await page.getByRole('button', { name: 'Dates' }).click() | ||||
|   await page.getByRole('menuitem', { name: 'Within 3 months' }).first().click() | ||||
|   await page.locator('.ng-arrow-wrapper').first().click() | ||||
|   await page.getByRole('option', { name: 'Within 3 months' }).click() | ||||
|   await expect(page.locator('pngx-document-list')).toHaveText(/one document/i) | ||||
|   await page.getByRole('menuitem', { name: 'Within 3 months' }).first().click() | ||||
|   await page.getByLabel('Datesselected').getByRole('button').first().click() | ||||
|   await page | ||||
|     .getByRole('menuitem', { name: 'Relative dates' }) | ||||
|     .locator('span') | ||||
|     .first() | ||||
|     .click() | ||||
|   await page.getByRole('option', { name: 'Within 3 months' }).click() | ||||
|   await page.getByLabel('Dates selected').locator('button').first().click() | ||||
|   await page.getByLabel('Dates selected').locator('button').first().click() | ||||
|   await page.getByRole('combobox', { name: 'Select month' }).selectOption('12') | ||||
|   await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022') | ||||
|   await page.getByText('11', { exact: true }).click() | ||||
|   | ||||
| @@ -12,4 +12,13 @@ module.exports = { | ||||
|     '^src/(.*)': '<rootDir>/src/$1', | ||||
|   }, | ||||
|   workerIdleMemoryLimit: '512MB', | ||||
|   reporters: [ | ||||
|     'default', | ||||
|     [ | ||||
|       'jest-junit', | ||||
|       { | ||||
|         classNameTemplate: '{filepath}/{classname}: {title}', | ||||
|       }, | ||||
|     ], | ||||
|   ], | ||||
| } | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										2425
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2425
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -11,17 +11,17 @@ | ||||
|   }, | ||||
|   "private": true, | ||||
|   "dependencies": { | ||||
|     "@angular/cdk": "^19.1.2", | ||||
|     "@angular/common": "~19.1.4", | ||||
|     "@angular/compiler": "~19.1.4", | ||||
|     "@angular/core": "~19.1.4", | ||||
|     "@angular/forms": "~19.1.4", | ||||
|     "@angular/localize": "~19.1.4", | ||||
|     "@angular/platform-browser": "~19.1.4", | ||||
|     "@angular/platform-browser-dynamic": "~19.1.4", | ||||
|     "@angular/router": "~19.1.4", | ||||
|     "@angular/cdk": "^19.2.1", | ||||
|     "@angular/common": "~19.2.0", | ||||
|     "@angular/compiler": "~19.2.0", | ||||
|     "@angular/core": "~19.2.0", | ||||
|     "@angular/forms": "~19.2.0", | ||||
|     "@angular/localize": "~19.2.0", | ||||
|     "@angular/platform-browser": "~19.2.0", | ||||
|     "@angular/platform-browser-dynamic": "~19.2.0", | ||||
|     "@angular/router": "~19.2.0", | ||||
|     "@ng-bootstrap/ng-bootstrap": "^18.0.0", | ||||
|     "@ng-select/ng-select": "^14.2.0", | ||||
|     "@ng-select/ng-select": "^14.2.2", | ||||
|     "@ngneat/dirty-check-forms": "^3.0.3", | ||||
|     "@popperjs/core": "^2.11.8", | ||||
|     "bootstrap": "^5.3.3", | ||||
| @@ -29,41 +29,42 @@ | ||||
|     "mime-names": "^1.0.0", | ||||
|     "ng2-pdf-viewer": "^10.4.0", | ||||
|     "ngx-bootstrap-icons": "^1.9.3", | ||||
|     "ngx-color": "^9.0.0", | ||||
|     "ngx-cookie-service": "^19.1.0", | ||||
|     "ngx-color": "^10.0.0", | ||||
|     "ngx-cookie-service": "^19.1.2", | ||||
|     "ngx-device-detector": "^9.0.0", | ||||
|     "ngx-file-drop": "^16.0.0", | ||||
|     "ngx-ui-tour-ng-bootstrap": "^16.0.0", | ||||
|     "rxjs": "^7.8.1", | ||||
|     "rxjs": "^7.8.2", | ||||
|     "tslib": "^2.8.1", | ||||
|     "utif": "^3.1.0", | ||||
|     "uuid": "^11.0.5", | ||||
|     "uuid": "^11.1.0", | ||||
|     "zone.js": "^0.15.0" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@angular-builders/custom-webpack": "^19.0.0", | ||||
|     "@angular-builders/jest": "^19.0.0", | ||||
|     "@angular-devkit/build-angular": "^19.0.4", | ||||
|     "@angular-devkit/core": "^19.1.5", | ||||
|     "@angular-devkit/schematics": "^19.1.5", | ||||
|     "@angular-eslint/builder": "19.0.2", | ||||
|     "@angular-eslint/eslint-plugin": "19.0.2", | ||||
|     "@angular-eslint/eslint-plugin-template": "19.0.2", | ||||
|     "@angular-eslint/schematics": "19.0.2", | ||||
|     "@angular-eslint/template-parser": "19.0.2", | ||||
|     "@angular/cli": "~19.1.5", | ||||
|     "@angular/compiler-cli": "~19.1.4", | ||||
|     "@codecov/webpack-plugin": "^1.8.0", | ||||
|     "@angular-devkit/core": "^19.2.0", | ||||
|     "@angular-devkit/schematics": "^19.2.0", | ||||
|     "@angular-eslint/builder": "19.2.0", | ||||
|     "@angular-eslint/eslint-plugin": "19.2.0", | ||||
|     "@angular-eslint/eslint-plugin-template": "19.2.0", | ||||
|     "@angular-eslint/schematics": "19.2.0", | ||||
|     "@angular-eslint/template-parser": "19.2.0", | ||||
|     "@angular/cli": "~19.2.0", | ||||
|     "@angular/compiler-cli": "~19.2.0", | ||||
|     "@codecov/webpack-plugin": "^1.9.0", | ||||
|     "@playwright/test": "^1.50.1", | ||||
|     "@types/jest": "^29.5.14", | ||||
|     "@types/node": "^22.13.0", | ||||
|     "@typescript-eslint/eslint-plugin": "^8.22.0", | ||||
|     "@typescript-eslint/parser": "^8.22.0", | ||||
|     "@types/node": "^22.13.9", | ||||
|     "@typescript-eslint/eslint-plugin": "^8.26.0", | ||||
|     "@typescript-eslint/parser": "^8.26.0", | ||||
|     "@typescript-eslint/utils": "^8.0.0", | ||||
|     "eslint": "^9.19.0", | ||||
|     "eslint": "^9.21.0", | ||||
|     "jest": "29.7.0", | ||||
|     "jest-environment-jsdom": "^29.7.0", | ||||
|     "jest-preset-angular": "^14.4.2", | ||||
|     "jest-junit": "^16.0.0", | ||||
|     "jest-preset-angular": "^14.5.3", | ||||
|     "jest-websocket-mock": "^2.5.0", | ||||
|     "patch-package": "^8.0.0", | ||||
|     "prettier-plugin-organize-imports": "^4.1.0", | ||||
|   | ||||
| @@ -118,7 +118,7 @@ | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="row mb-3"> | ||||
|             <div class="row"> | ||||
|               <div class="col-md-3 col-form-label pt-0"> | ||||
|                 <span i18n>Sidebar</span> | ||||
|               </div> | ||||
| @@ -129,7 +129,7 @@ | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="row mb-3"> | ||||
|             <div class="row"> | ||||
|               <div class="col-md-3 col-form-label pt-0"> | ||||
|                 <span i18n>Dark mode</span> | ||||
|               </div> | ||||
| @@ -165,7 +165,7 @@ | ||||
|                   <p i18n> | ||||
|                     Update checking works by pinging the public GitHub API for the latest release to determine whether a new version is available. Actual updating of the app must still be performed manually. | ||||
|                   </p> | ||||
|                   <p> | ||||
|                   <p class="mb-0"> | ||||
|                     <em i18n>No tracking data is collected by the app in any way.</em> | ||||
|                   </p> | ||||
|                 </ng-template> | ||||
| @@ -173,7 +173,7 @@ | ||||
|             </div> | ||||
|  | ||||
|             <h5 class="mt-3" i18n>Saved Views</h5> | ||||
|             <div class="row mb-3"> | ||||
|             <div class="row"> | ||||
|               <div class="col"> | ||||
|                 <pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check> | ||||
|               </div> | ||||
| @@ -183,15 +183,15 @@ | ||||
|           <div class="col-xl-6 ps-xl-5"> | ||||
|             <h5 class="mt-3 mt-md-0" i18n>Document editing</h5> | ||||
|  | ||||
|             <div class="row mb-3"> | ||||
|             <div class="row"> | ||||
|               <div class="col"> | ||||
|                 <pngx-input-check i18n-title title="Use PDF viewer provided by the browser" i18n-hint hint="This is usually faster for displaying large PDF documents, but it might not work on some browsers." formControlName="useNativePdfViewer"></pngx-input-check> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="row mb-3"> | ||||
|               <div class="col-2"> | ||||
|                 <span i18n>Default zoom:</span> | ||||
|             <div class="row"> | ||||
|               <div class="col-md-3 col-form-label pt-0"> | ||||
|                 <span i18n>Default zoom</span> | ||||
|               </div> | ||||
|               <div class="col"> | ||||
|                 <select class="form-select" formControlName="pdfViewerDefaultZoom"> | ||||
| @@ -202,7 +202,7 @@ | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="row mb-3"> | ||||
|             <div class="row"> | ||||
|               <div class="col"> | ||||
|                 <pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check> | ||||
|               </div> | ||||
| @@ -214,10 +214,22 @@ | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <h5 class="mt-3" i18n>Notes</h5> | ||||
|             <div class="row mb-3"> | ||||
|             <h5 class="mt-3" i18n>Global search</h5> | ||||
|             <div class="row"> | ||||
|               <div class="col"> | ||||
|                 <pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check> | ||||
|                 <pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="row mb-3"> | ||||
|               <div class="col-md-3 col-form-label pt-0"> | ||||
|                 <span i18n>Full search links to</span> | ||||
|               </div> | ||||
|               <div class="col mb-3"> | ||||
|                 <select class="form-select" formControlName="searchLink"> | ||||
|                   <option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option> | ||||
|                   <option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option> | ||||
|                 </select> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
| @@ -229,26 +241,10 @@ | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <h5 class="mt-3" i18n>Global search</h5> | ||||
|             <h5 class="mt-3" i18n>Notes</h5> | ||||
|             <div class="row mb-3"> | ||||
|               <div class="col"> | ||||
|                 <pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="row mb-3"> | ||||
|               <div class="col"> | ||||
|                 <div class="row"> | ||||
|                   <div class="col-md-3 col-form-label pt-0"> | ||||
|                     <span i18n>Full search links to</span> | ||||
|                   </div> | ||||
|                   <div class="col"> | ||||
|                     <select class="form-select" formControlName="searchLink"> | ||||
|                       <option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option> | ||||
|                       <option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option> | ||||
|                     </select> | ||||
|                   </div> | ||||
|                 </div> | ||||
|                 <pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
| @@ -267,8 +263,8 @@ | ||||
|         <div class="row mb-3"> | ||||
|           <div class="col"> | ||||
|             <p i18n> | ||||
|             Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI | ||||
|           </p> | ||||
|               Settings apply to this user account for objects (Tags, Mail Rules, etc. but not documents) created via the web UI. | ||||
|             </p> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="row mb-3"> | ||||
| @@ -307,7 +303,7 @@ | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="row mb-3"> | ||||
|         <div class="row"> | ||||
|           <div class="col-md-3 col-form-label pt-0"> | ||||
|             <span i18n>Default Edit Permissions</span> | ||||
|           </div> | ||||
| @@ -346,7 +342,7 @@ | ||||
|  | ||||
|         <h5 i18n>Document processing</h5> | ||||
|  | ||||
|         <div class="row mb-3"> | ||||
|         <div class="row"> | ||||
|           <div class="col"> | ||||
|             <pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check> | ||||
|             <pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check> | ||||
|   | ||||
| @@ -325,6 +325,8 @@ describe('SettingsComponent', () => { | ||||
|     component['systemStatus'].database.status = SystemStatusItemStatus.OK | ||||
|     component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK | ||||
|     component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK | ||||
|     component['systemStatus'].tasks.sanity_check_status = | ||||
|       SystemStatusItemStatus.OK | ||||
|     expect(component.systemStatusHasErrors).toBeFalsy() | ||||
|   }) | ||||
|  | ||||
|   | ||||
| @@ -164,7 +164,10 @@ export class SettingsComponent | ||||
|       this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR || | ||||
|       this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR || | ||||
|       this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR || | ||||
|       this.systemStatus.tasks.classifier_status === SystemStatusItemStatus.ERROR | ||||
|       this.systemStatus.tasks.classifier_status === | ||||
|         SystemStatusItemStatus.ERROR || | ||||
|       this.systemStatus.tasks.sanity_check_status === | ||||
|         SystemStatusItemStatus.ERROR | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -21,7 +21,7 @@ | ||||
|     } | ||||
|     <div class="scroll-list"> | ||||
|       @for (toast of toasts; track toast.id) { | ||||
|         <pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (close)="toastService.closeToast(toast)"></pngx-toast> | ||||
|         <pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast> | ||||
|       } | ||||
|       </div> | ||||
|   </div> | ||||
|   | ||||
| @@ -28,10 +28,16 @@ | ||||
|         </select> | ||||
|     </div> | ||||
|     <div class="form-check form-switch mt-4"> | ||||
|       <input class="form-check-input" type="checkbox" role="switch" id="archiveFallbackSwitch" [(ngModel)]="archiveFallback"> | ||||
|       <label class="form-check-label" for="archiveFallbackSwitch" i18n>Try to include archive version in merge for non-PDF files</label> | ||||
|     </div> | ||||
|     <div class="form-check form-switch mt-2"> | ||||
|       <input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalsSwitch" [(ngModel)]="deleteOriginals" [disabled]="!userOwnsAllDocuments"> | ||||
|       <label class="form-check-label" for="deleteOriginalsSwitch" i18n>Delete original documents after successful merge</label> | ||||
|     </div> | ||||
|     <p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p> | ||||
|     @if (!archiveFallback) { | ||||
|       <p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p> | ||||
|     } | ||||
| </div> | ||||
| <div class="modal-footer"> | ||||
|     <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled"> | ||||
|   | ||||
| @@ -29,6 +29,7 @@ export class MergeConfirmDialogComponent | ||||
|   implements OnInit | ||||
| { | ||||
|   public documentIDs: number[] = [] | ||||
|   public archiveFallback: boolean = false | ||||
|   public deleteOriginals: boolean = false | ||||
|   private _documents: Document[] = [] | ||||
|   get documents(): Document[] { | ||||
|   | ||||
| @@ -84,7 +84,7 @@ export class SplitConfirmDialogComponent | ||||
|   addSplit() { | ||||
|     if (this.page === this.totalPages) return | ||||
|     this.pages.add(this.page) | ||||
|     this.pages = new Set(Array.from(this.pages).sort()) | ||||
|     this.pages = new Set(Array.from(this.pages).sort((a, b) => a - b)) | ||||
|     this.confirmButtonEnabled = this.pages.size > 0 | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -34,7 +34,7 @@ import { | ||||
|   CustomFieldQueryElement, | ||||
|   CustomFieldQueryExpression, | ||||
| } from 'src/app/utils/custom-field-query-element' | ||||
| import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options' | ||||
| import { pngxPopperOptions } from 'src/app/utils/popper-options' | ||||
| import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' | ||||
| import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' | ||||
| import { DocumentLinkComponent } from '../input/document-link/document-link.component' | ||||
| @@ -183,7 +183,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm | ||||
|   public CustomFieldDataType = CustomFieldDataType | ||||
|   public CUSTOM_FIELD_QUERY_MAX_DEPTH = CUSTOM_FIELD_QUERY_MAX_DEPTH | ||||
|   public CUSTOM_FIELD_QUERY_MAX_ATOMS = CUSTOM_FIELD_QUERY_MAX_ATOMS | ||||
|   public popperOptions = popperOptionsReenablePreventOverflow | ||||
|   public popperOptions = pngxPopperOptions | ||||
|  | ||||
|   @Input() | ||||
|   title: string | ||||
|   | ||||
| @@ -1,161 +1,158 @@ | ||||
| <div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions"> | ||||
| <div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions" [placement]="placement"> | ||||
|   <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> | ||||
|     <i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs> | ||||
|     <div class="d-none d-sm-inline"> {{title}}</div> | ||||
|     <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span> | ||||
|   </button> | ||||
|   <div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}"> | ||||
|     <div class="row d-flex"> | ||||
|       <div class="col border-end"> | ||||
|         <div class="list-group list-group-flush"> | ||||
|           <h6 class="dropdown-header border-bottom" i18n>Created</h6> | ||||
|           @for (rd of relativeDates; track rd) { | ||||
|             <button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setCreatedRelativeDate(rd.id)"> | ||||
|               <div class="selected-icon"> | ||||
|                 @if (createdRelativeDate === rd.id) { | ||||
|                   <i-bs width="1em" height="1em" name="check"></i-bs> | ||||
|                 } | ||||
|               </div> | ||||
|               <div class="d-flex justify-content-between w-100 align-items-center ps-2"> | ||||
|                 <div class="pe-4"> | ||||
|                   {{rd.name}} | ||||
|                 </div> | ||||
|                 <div class="text-muted small pe-2"> | ||||
|                   <span class="small"> | ||||
|                     {{ rd.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container> | ||||
|                   </span> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </button> | ||||
|     <h6 class="dropdown-header border-bottom" i18n>Created</h6> | ||||
|     <div class="list-group list-group-flush"> | ||||
|       <div class="list-group-item d-flex p-2 select-item" role="menuitem"> | ||||
|         <div class="selected-icon"> | ||||
|           @if (createdRelativeDate) { | ||||
|             <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()"> | ||||
|               <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|               <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|             </a> | ||||
|           } | ||||
|           <div class="list-group-item d-flex p-2" role="menuitem"> | ||||
|  | ||||
|             <div class="selected-icon"> | ||||
|               @if (createdDateFrom) { | ||||
|                 <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()"> | ||||
|                   <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|                   <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|                 </a> | ||||
|               } | ||||
|             </div> | ||||
|             <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|               <span class="input-group-text w-25 small text-muted" i18n>From</span> | ||||
|               <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" | ||||
|                 maxlength="10" [(ngModel)]="createdDateFrom" ngbDatepicker #createdDateFromPicker="ngbDatepicker" [footerTemplate]="createdFromFooterTemplate"> | ||||
|               <button class="btn btn-outline-secondary" (click)="createdDateFromPicker.toggle()" type="button"> | ||||
|                 <i-bs width="1em" height="1em" name="calendar"></i-bs> | ||||
|               </button> | ||||
|               <ng-template #createdFromFooterTemplate> | ||||
|                 <div class="btn-group-xs border-top p-2 d-flex"> | ||||
|                   <button class="btn btn-primary" (click)="createdDateFrom = today; onChangeDebounce()" i18n>Today</button> | ||||
|                   <button class="btn btn-secondary ms-auto" (click)="createdDateFromPicker.close()" i18n>Close</button> | ||||
|                 </div> | ||||
|               </ng-template> | ||||
|             </div> | ||||
|  | ||||
|           </div> | ||||
|           <div class="list-group-item d-flex p-2" role="menuitem"> | ||||
|  | ||||
|             <div class="selected-icon"> | ||||
|               @if (createdDateTo) { | ||||
|                 <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()"> | ||||
|                   <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|                   <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|                 </a> | ||||
|               } | ||||
|             </div> | ||||
|             <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|               <span class="input-group-text w-25 small text-muted" i18n>To</span> | ||||
|               <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" | ||||
|                 maxlength="10" [(ngModel)]="createdDateTo" ngbDatepicker #createdDateToPicker="ngbDatepicker" [footerTemplate]="createdToFooterTemplate"> | ||||
|               <button class="btn btn-outline-secondary" (click)="createdDateToPicker.toggle()" type="button"> | ||||
|                 <i-bs width="1em" height="1em" name="calendar"></i-bs> | ||||
|               </button> | ||||
|               <ng-template #createdToFooterTemplate> | ||||
|                 <div class="btn-group-xs border-top p-2 d-flex"> | ||||
|                   <button class="btn btn-primary" (click)="createdDateTo = today; onChangeDebounce()" i18n>Today</button> | ||||
|                   <button class="btn btn-secondary ms-auto" (click)="createdDateToPicker.close()" i18n>Close</button> | ||||
|                 </div> | ||||
|               </ng-template> | ||||
|             </div> | ||||
|  | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|           <ng-select class="w-100" name="createdRelativeDate" | ||||
|           [items]="relativeDates" [(ngModel)]="createdRelativeDate" | ||||
|           bindValue="id" | ||||
|           bindLabel="name" | ||||
|           clearable="false" | ||||
|           placeholder="Relative dates" | ||||
|           i18n-placeholder | ||||
|           (change)="onSetCreatedRelativeDate($event)"> | ||||
|           <ng-template ng-option-tmp let-item="item"> | ||||
|             <div class="d-flex">{{ item.name }}<span class="ms-auto text-muted small">{{ item.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container></span></div> | ||||
|           </ng-template> | ||||
|           </ng-select> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="col"> | ||||
|         <h6 class="dropdown-header border-bottom" i18n>Added</h6> | ||||
|         <div class="list-group list-group-flush"> | ||||
|           @for (rd of relativeDates; track rd) { | ||||
|             <button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setAddedRelativeDate(rd.id)"> | ||||
|               <div class="selected-icon"> | ||||
|                 @if (addedRelativeDate === rd.id) { | ||||
|                   <i-bs width="1em" height="1em" name="check"></i-bs> | ||||
|                 } | ||||
|               </div> | ||||
|               <div class="d-flex justify-content-between w-100 align-items-center ps-2"> | ||||
|                 <div class="pe-4"> | ||||
|                   {{rd.name}} | ||||
|                 </div> | ||||
|                 <div class="text-muted small pe-2"> | ||||
|                   <span class="small"> | ||||
|                     {{ rd.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container> | ||||
|                   </span> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </button> | ||||
|       <div class="list-group-item d-flex p-2" role="menuitem"> | ||||
|         <div class="selected-icon"> | ||||
|           @if (createdDateFrom) { | ||||
|             <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()"> | ||||
|               <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 class="list-group-item d-flex p-2" role="menuitem"> | ||||
|  | ||||
|             <div class="selected-icon"> | ||||
|               @if (addedDateFrom) { | ||||
|                 <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()"> | ||||
|                   <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|                   <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|                 </a> | ||||
|               } | ||||
|         </div> | ||||
|         <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|           <span class="input-group-text w-25 small text-muted" i18n>From</span> | ||||
|           <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" | ||||
|             maxlength="10" [(ngModel)]="createdDateFrom" ngbDatepicker #createdDateFromPicker="ngbDatepicker" [footerTemplate]="createdFromFooterTemplate"> | ||||
|           <button class="btn btn-outline-secondary" (click)="createdDateFromPicker.toggle()" type="button"> | ||||
|             <i-bs width="1em" height="1em" name="calendar"></i-bs> | ||||
|           </button> | ||||
|           <ng-template #createdFromFooterTemplate> | ||||
|             <div class="btn-group-xs border-top p-2 d-flex"> | ||||
|               <button class="btn btn-primary" (click)="createdDateFrom = today; onChangeDebounce()" i18n>Today</button> | ||||
|               <button class="btn btn-secondary ms-auto" (click)="createdDateFromPicker.close()" i18n>Close</button> | ||||
|             </div> | ||||
|             <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|               <span class="input-group-text w-25 small text-muted" i18n>From</span> | ||||
|               <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" | ||||
|                 maxlength="10" [(ngModel)]="addedDateFrom" ngbDatepicker #addedDateFromPicker="ngbDatepicker" [footerTemplate]="addedFromFooterTemplate"> | ||||
|               <button class="btn btn-outline-secondary" (click)="addedDateFromPicker.toggle()" type="button"> | ||||
|                 <i-bs width="1em" height="1em" name="calendar"></i-bs> | ||||
|               </button> | ||||
|               <ng-template #addedFromFooterTemplate> | ||||
|                 <div class="btn-group-xs border-top p-2 d-flex"> | ||||
|                   <button class="btn btn-primary" (click)="addedDateFrom = today; onChangeDebounce()" i18n>Today</button> | ||||
|                   <button class="btn btn-secondary ms-auto" (click)="addedDateFromPicker.close()" i18n>Close</button> | ||||
|                 </div> | ||||
|               </ng-template> | ||||
|           </ng-template> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="list-group-item d-flex p-2" role="menuitem"> | ||||
|         <div class="selected-icon"> | ||||
|           @if (createdDateTo) { | ||||
|             <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()"> | ||||
|               <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|               <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|             </a> | ||||
|           } | ||||
|         </div> | ||||
|         <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|           <span class="input-group-text w-25 small text-muted" i18n>To</span> | ||||
|           <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" | ||||
|             maxlength="10" [(ngModel)]="createdDateTo" ngbDatepicker #createdDateToPicker="ngbDatepicker" [footerTemplate]="createdToFooterTemplate"> | ||||
|           <button class="btn btn-outline-secondary" (click)="createdDateToPicker.toggle()" type="button"> | ||||
|             <i-bs width="1em" height="1em" name="calendar"></i-bs> | ||||
|           </button> | ||||
|           <ng-template #createdToFooterTemplate> | ||||
|             <div class="btn-group-xs border-top p-2 d-flex"> | ||||
|               <button class="btn btn-primary" (click)="createdDateTo = today; onChangeDebounce()" i18n>Today</button> | ||||
|               <button class="btn btn-secondary ms-auto" (click)="createdDateToPicker.close()" i18n>Close</button> | ||||
|             </div> | ||||
|           </ng-template> | ||||
|         </div> | ||||
|  | ||||
|           </div> | ||||
|           <div class="list-group-item d-flex p-2" role="menuitem"> | ||||
|  | ||||
|             <div class="selected-icon"> | ||||
|               @if (addedDateTo) { | ||||
|                 <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()"> | ||||
|                   <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> | ||||
|     <h6 class="dropdown-header border-bottom" i18n>Added</h6> | ||||
|     <div class="list-group list-group-flush"> | ||||
|       <div class="list-group-item d-flex p-2 select-item" role="menuitem"> | ||||
|         <div class="selected-icon"> | ||||
|           @if (addedRelativeDate) { | ||||
|             <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedRelativeDate()"> | ||||
|               <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|               <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|             </a> | ||||
|           } | ||||
|         </div> | ||||
|         <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|           <ng-select class="w-100" name="addedRelativeDate" | ||||
|             [items]="relativeDates" [(ngModel)]="addedRelativeDate" | ||||
|             bindValue="id" | ||||
|             bindLabel="name" | ||||
|             clearable="false" | ||||
|             placeholder="Relative dates" | ||||
|             i18n-placeholder | ||||
|             (change)="onSetAddedRelativeDate($event)"> | ||||
|             <ng-template ng-option-tmp let-item="item"> | ||||
|               <div class="d-flex">{{ item.name }}<span class="ms-auto text-muted small">{{ item.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container></span></div> | ||||
|             </ng-template> | ||||
|           </ng-select> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="list-group-item d-flex p-2" role="menuitem"> | ||||
|         <div class="selected-icon"> | ||||
|           @if (addedDateFrom) { | ||||
|             <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()"> | ||||
|               <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|               <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|             </a> | ||||
|           } | ||||
|         </div> | ||||
|         <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|           <span class="input-group-text w-25 small text-muted" i18n>From</span> | ||||
|           <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" | ||||
|             maxlength="10" [(ngModel)]="addedDateFrom" ngbDatepicker #addedDateFromPicker="ngbDatepicker" [footerTemplate]="addedFromFooterTemplate"> | ||||
|           <button class="btn btn-outline-secondary" (click)="addedDateFromPicker.toggle()" type="button"> | ||||
|             <i-bs width="1em" height="1em" name="calendar"></i-bs> | ||||
|           </button> | ||||
|           <ng-template #addedFromFooterTemplate> | ||||
|             <div class="btn-group-xs border-top p-2 d-flex"> | ||||
|               <button class="btn btn-primary" (click)="addedDateFrom = today; onChangeDebounce()" i18n>Today</button> | ||||
|               <button class="btn btn-secondary ms-auto" (click)="addedDateFromPicker.close()" i18n>Close</button> | ||||
|             </div> | ||||
|             <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|               <span class="input-group-text w-25 small text-muted" i18n>To</span> | ||||
|               <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" | ||||
|                 maxlength="10" [(ngModel)]="addedDateTo" ngbDatepicker #addedDateToPicker="ngbDatepicker" [footerTemplate]="addedToFooterTemplate"> | ||||
|               <button class="btn btn-outline-secondary" (click)="addedDateToPicker.toggle()" type="button"> | ||||
|                 <i-bs width="1em" height="1em" name="calendar"></i-bs> | ||||
|               </button> | ||||
|               <ng-template #addedToFooterTemplate> | ||||
|                 <div class="btn-group-xs border-top p-2 d-flex"> | ||||
|                   <button class="btn btn-primary" (click)="addedDateTo = today; onChangeDebounce()" i18n>Today</button> | ||||
|                   <button class="btn btn-secondary ms-auto" (click)="addedDateToPicker.close()" i18n>Close</button> | ||||
|                 </div> | ||||
|               </ng-template> | ||||
|           </ng-template> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="list-group-item d-flex p-2" role="menuitem"> | ||||
|         <div class="selected-icon"> | ||||
|           @if (addedDateTo) { | ||||
|             <a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()"> | ||||
|               <i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs> | ||||
|               <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|             </a> | ||||
|           } | ||||
|         </div> | ||||
|         <div class="input-group input-group-sm small ps-1 pe-2"> | ||||
|           <span class="input-group-text w-25 small text-muted" i18n>To</span> | ||||
|           <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)" | ||||
|             maxlength="10" [(ngModel)]="addedDateTo" ngbDatepicker #addedDateToPicker="ngbDatepicker" [footerTemplate]="addedToFooterTemplate"> | ||||
|           <button class="btn btn-outline-secondary" (click)="addedDateToPicker.toggle()" type="button"> | ||||
|             <i-bs width="1em" height="1em" name="calendar"></i-bs> | ||||
|           </button> | ||||
|           <ng-template #addedToFooterTemplate> | ||||
|             <div class="btn-group-xs border-top p-2 d-flex"> | ||||
|               <button class="btn btn-primary" (click)="addedDateTo = today; onChangeDebounce()" i18n>Today</button> | ||||
|               <button class="btn btn-secondary ms-auto" (click)="addedDateToPicker.close()" i18n>Close</button> | ||||
|             </div> | ||||
|  | ||||
|           </div> | ||||
|           </ng-template> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   | ||||
| @@ -1,16 +1,7 @@ | ||||
| .date-dropdown { | ||||
|   --bs-dropdown-min-width: 22rem; | ||||
|   white-space: nowrap; | ||||
|  | ||||
|   @media(min-width: 768px) { | ||||
|     --bs-dropdown-min-width: 40rem; | ||||
|   } | ||||
|  | ||||
|   @media screen and (max-width: 767px) { | ||||
|     .border-end { | ||||
|       border: none !important; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .btn-link { | ||||
|     line-height: 1; | ||||
|   } | ||||
| @@ -21,6 +12,10 @@ | ||||
|   min-height: 1em; | ||||
| } | ||||
|  | ||||
| .select-item .selected-icon { | ||||
|   line-height: 2em; | ||||
| } | ||||
|  | ||||
| .input-group-sm { | ||||
|   .form-control { | ||||
|     font-size: 0.875rem; | ||||
|   | ||||
| @@ -82,10 +82,12 @@ describe('DatesDropdownComponent', () => { | ||||
|   it('should support relative dates', fakeAsync(() => { | ||||
|     let result: DateSelection | ||||
|     component.datesSet.subscribe((date) => (result = date)) | ||||
|     component.setCreatedRelativeDate(null) | ||||
|     component.setCreatedRelativeDate(RelativeDate.WITHIN_1_WEEK) | ||||
|     component.setAddedRelativeDate(null) | ||||
|     component.setAddedRelativeDate(RelativeDate.WITHIN_1_WEEK) | ||||
|     component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown | ||||
|     component.onSetCreatedRelativeDate({ | ||||
|       id: RelativeDate.WITHIN_1_WEEK, | ||||
|     } as any) | ||||
|     component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown | ||||
|     component.onSetAddedRelativeDate({ id: RelativeDate.WITHIN_1_WEEK } as any) | ||||
|     tick(500) | ||||
|     expect(result).toEqual({ | ||||
|       createdFrom: null, | ||||
| @@ -147,8 +149,19 @@ describe('DatesDropdownComponent', () => { | ||||
|     expect(component.addedDateTo).toBeNull() | ||||
|   }) | ||||
|  | ||||
|   it('should support clearRelativeDate', () => { | ||||
|     component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK | ||||
|     component.clearCreatedRelativeDate() | ||||
|     expect(component.createdRelativeDate).toBeNull() | ||||
|  | ||||
|     component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK | ||||
|     component.clearAddedRelativeDate() | ||||
|     expect(component.addedRelativeDate).toBeNull() | ||||
|   }) | ||||
|  | ||||
|   it('should limit keyboard events', () => { | ||||
|     const input: HTMLInputElement = fixture.nativeElement.querySelector('input') | ||||
|     const input: HTMLInputElement = | ||||
|       fixture.nativeElement.querySelector('input.form-control') | ||||
|     let event: KeyboardEvent = new KeyboardEvent('keypress', { | ||||
|       key: '9', | ||||
|     }) | ||||
| @@ -163,4 +176,19 @@ describe('DatesDropdownComponent', () => { | ||||
|     input.dispatchEvent(event) | ||||
|     expect(eventSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should support debounce', fakeAsync(() => { | ||||
|     let result: DateSelection | ||||
|     component.datesSet.subscribe((date) => (result = date)) | ||||
|     component.onChangeDebounce() | ||||
|     tick(500) | ||||
|     expect(result).toEqual({ | ||||
|       createdFrom: null, | ||||
|       createdTo: null, | ||||
|       createdRelativeDateID: null, | ||||
|       addedFrom: null, | ||||
|       addedTo: null, | ||||
|       addedRelativeDateID: null, | ||||
|     }) | ||||
|   })) | ||||
| }) | ||||
|   | ||||
| @@ -13,13 +13,14 @@ import { | ||||
|   NgbDatepickerModule, | ||||
|   NgbDropdownModule, | ||||
| } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgSelectModule } from '@ng-select/ng-select' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { Subject, Subscription } from 'rxjs' | ||||
| import { debounceTime } from 'rxjs/operators' | ||||
| import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' | ||||
| import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options' | ||||
| import { pngxPopperOptions } from 'src/app/utils/popper-options' | ||||
| import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' | ||||
|  | ||||
| export interface DateSelection { | ||||
| @@ -32,10 +33,14 @@ export interface DateSelection { | ||||
| } | ||||
|  | ||||
| export enum RelativeDate { | ||||
|   WITHIN_1_WEEK = 0, | ||||
|   WITHIN_1_MONTH = 1, | ||||
|   WITHIN_3_MONTHS = 2, | ||||
|   WITHIN_1_YEAR = 3, | ||||
|   WITHIN_1_WEEK = 1, | ||||
|   WITHIN_1_MONTH = 2, | ||||
|   WITHIN_3_MONTHS = 3, | ||||
|   WITHIN_1_YEAR = 4, | ||||
|   THIS_YEAR = 5, | ||||
|   THIS_MONTH = 6, | ||||
|   TODAY = 7, | ||||
|   YESTERDAY = 8, | ||||
| } | ||||
|  | ||||
| @Component({ | ||||
| @@ -49,13 +54,14 @@ export enum RelativeDate { | ||||
|     NgxBootstrapIconsModule, | ||||
|     NgbDatepickerModule, | ||||
|     NgbDropdownModule, | ||||
|     NgSelectModule, | ||||
|     FormsModule, | ||||
|     ReactiveFormsModule, | ||||
|     NgClass, | ||||
|   ], | ||||
| }) | ||||
| export class DatesDropdownComponent implements OnInit, OnDestroy { | ||||
|   public popperOptions = popperOptionsReenablePreventOverflow | ||||
|   public popperOptions = pngxPopperOptions | ||||
|  | ||||
|   constructor(settings: SettingsService) { | ||||
|     this.datePlaceHolder = settings.getLocalizedDateInputFormat() | ||||
| @@ -82,44 +88,64 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { | ||||
|       name: $localize`Within 1 year`, | ||||
|       date: new Date().setFullYear(new Date().getFullYear() - 1), | ||||
|     }, | ||||
|     { | ||||
|       id: RelativeDate.THIS_YEAR, | ||||
|       name: $localize`This year`, | ||||
|       date: new Date('1/1/' + new Date().getFullYear()), | ||||
|     }, | ||||
|     { | ||||
|       id: RelativeDate.THIS_MONTH, | ||||
|       name: $localize`This month`, | ||||
|       date: new Date().setDate(1), | ||||
|     }, | ||||
|     { | ||||
|       id: RelativeDate.TODAY, | ||||
|       name: $localize`Today`, | ||||
|       date: new Date().setHours(0, 0, 0, 0), | ||||
|     }, | ||||
|     { | ||||
|       id: RelativeDate.YESTERDAY, | ||||
|       name: $localize`Yesterday`, | ||||
|       date: new Date().setDate(new Date().getDate() - 1), | ||||
|     }, | ||||
|   ] | ||||
|  | ||||
|   datePlaceHolder: string | ||||
|  | ||||
|   // created | ||||
|   @Input() | ||||
|   createdDateTo: string | ||||
|   createdDateTo: string = null | ||||
|  | ||||
|   @Output() | ||||
|   createdDateToChange = new EventEmitter<string>() | ||||
|  | ||||
|   @Input() | ||||
|   createdDateFrom: string | ||||
|   createdDateFrom: string = null | ||||
|  | ||||
|   @Output() | ||||
|   createdDateFromChange = new EventEmitter<string>() | ||||
|  | ||||
|   @Input() | ||||
|   createdRelativeDate: RelativeDate | ||||
|   createdRelativeDate: RelativeDate = null | ||||
|  | ||||
|   @Output() | ||||
|   createdRelativeDateChange = new EventEmitter<number>() | ||||
|  | ||||
|   // added | ||||
|   @Input() | ||||
|   addedDateTo: string | ||||
|   addedDateTo: string = null | ||||
|  | ||||
|   @Output() | ||||
|   addedDateToChange = new EventEmitter<string>() | ||||
|  | ||||
|   @Input() | ||||
|   addedDateFrom: string | ||||
|   addedDateFrom: string = null | ||||
|  | ||||
|   @Output() | ||||
|   addedDateFromChange = new EventEmitter<string>() | ||||
|  | ||||
|   @Input() | ||||
|   addedRelativeDate: RelativeDate | ||||
|   addedRelativeDate: RelativeDate = null | ||||
|  | ||||
|   @Output() | ||||
|   addedRelativeDateChange = new EventEmitter<number>() | ||||
| @@ -133,6 +159,9 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { | ||||
|   @Input() | ||||
|   disabled: boolean = false | ||||
|  | ||||
|   @Input() | ||||
|   placement: string = 'bottom-start' | ||||
|  | ||||
|   public readonly today: string = new Date().toISOString().split('T')[0] | ||||
|  | ||||
|   get isActive(): boolean { | ||||
| @@ -172,17 +201,17 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { | ||||
|     this.onChange() | ||||
|   } | ||||
|  | ||||
|   setCreatedRelativeDate(rd: RelativeDate) { | ||||
|   onSetCreatedRelativeDate(rd: { id: number; name: string; date: number }) { | ||||
|     // createdRelativeDate is set by ngModel | ||||
|     this.createdDateTo = null | ||||
|     this.createdDateFrom = null | ||||
|     this.createdRelativeDate = this.createdRelativeDate == rd ? null : rd | ||||
|     this.onChange() | ||||
|   } | ||||
|  | ||||
|   setAddedRelativeDate(rd: RelativeDate) { | ||||
|   onSetAddedRelativeDate(rd: { id: number; name: string; date: number }) { | ||||
|     // addedRelativeDate is set by ngModel | ||||
|     this.addedDateTo = null | ||||
|     this.addedDateFrom = null | ||||
|     this.addedRelativeDate = this.addedRelativeDate == rd ? null : rd | ||||
|     this.onChange() | ||||
|   } | ||||
|  | ||||
| @@ -224,6 +253,11 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { | ||||
|     this.onChange() | ||||
|   } | ||||
|  | ||||
|   clearCreatedRelativeDate() { | ||||
|     this.createdRelativeDate = null | ||||
|     this.onChange() | ||||
|   } | ||||
|  | ||||
|   clearAddedTo() { | ||||
|     this.addedDateTo = null | ||||
|     this.onChange() | ||||
| @@ -234,6 +268,11 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { | ||||
|     this.onChange() | ||||
|   } | ||||
|  | ||||
|   clearAddedRelativeDate() { | ||||
|     this.addedRelativeDate = null | ||||
|     this.onChange() | ||||
|   } | ||||
|  | ||||
|   // prevent chars other than numbers and separators | ||||
|   onKeyPress(event: KeyboardEvent) { | ||||
|     if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) { | ||||
|   | ||||
| @@ -189,6 +189,7 @@ | ||||
|             <pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select> | ||||
|             <pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select> | ||||
|             <pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select> | ||||
|             <pngx-input-custom-fields-values formControlName="assign_custom_fields_values" [selectedFields]="formGroup.get('assign_custom_fields').value" (removeSelectedField)="removeSelectedCustomField($event, formGroup)"></pngx-input-custom-fields-values> | ||||
|           </div> | ||||
|           <div class="col"> | ||||
|             <pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select> | ||||
|   | ||||
| @@ -2,7 +2,12 @@ import { CdkDragDrop } from '@angular/cdk/drag-drop' | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { | ||||
|   FormControl, | ||||
|   FormGroup, | ||||
|   FormsModule, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgSelectModule } from '@ng-select/ng-select' | ||||
| import { of } from 'rxjs' | ||||
| @@ -369,4 +374,19 @@ describe('WorkflowEditDialogComponent', () => { | ||||
|     expect(component.objectForm.get('actions').value[0].email).toBeNull() | ||||
|     expect(component.objectForm.get('actions').value[0].webhook).toBeNull() | ||||
|   }) | ||||
|  | ||||
|   it('should remove selected custom field from the form group', () => { | ||||
|     const formGroup = new FormGroup({ | ||||
|       assign_custom_fields: new FormControl([1, 2, 3]), | ||||
|     }) | ||||
|  | ||||
|     component.removeSelectedCustomField(2, formGroup) | ||||
|     expect(formGroup.get('assign_custom_fields').value).toEqual([1, 3]) | ||||
|  | ||||
|     component.removeSelectedCustomField(1, formGroup) | ||||
|     expect(formGroup.get('assign_custom_fields').value).toEqual([3]) | ||||
|  | ||||
|     component.removeSelectedCustomField(3, formGroup) | ||||
|     expect(formGroup.get('assign_custom_fields').value).toEqual([]) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -47,6 +47,7 @@ import { WorkflowService } from 'src/app/services/rest/workflow.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' | ||||
| import { CheckComponent } from '../../input/check/check.component' | ||||
| import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component' | ||||
| import { EntriesComponent } from '../../input/entries/entries.component' | ||||
| import { NumberComponent } from '../../input/number/number.component' | ||||
| import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component' | ||||
| @@ -151,6 +152,7 @@ const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter( | ||||
|     SelectComponent, | ||||
|     TextAreaComponent, | ||||
|     TagsComponent, | ||||
|     CustomFieldsValuesComponent, | ||||
|     PermissionsGroupComponent, | ||||
|     PermissionsUserComponent, | ||||
|     ConfirmButtonComponent, | ||||
| @@ -439,6 +441,9 @@ export class WorkflowEditDialogComponent | ||||
|         assign_change_users: new FormControl(action.assign_change_users), | ||||
|         assign_change_groups: new FormControl(action.assign_change_groups), | ||||
|         assign_custom_fields: new FormControl(action.assign_custom_fields), | ||||
|         assign_custom_fields_values: new FormControl( | ||||
|           action.assign_custom_fields_values | ||||
|         ), | ||||
|         remove_tags: new FormControl(action.remove_tags), | ||||
|         remove_all_tags: new FormControl(action.remove_all_tags), | ||||
|         remove_document_types: new FormControl(action.remove_document_types), | ||||
| @@ -565,6 +570,7 @@ export class WorkflowEditDialogComponent | ||||
|       assign_change_users: [], | ||||
|       assign_change_groups: [], | ||||
|       assign_custom_fields: [], | ||||
|       assign_custom_fields_values: {}, | ||||
|       remove_tags: [], | ||||
|       remove_all_tags: false, | ||||
|       remove_document_types: [], | ||||
| @@ -643,4 +649,12 @@ export class WorkflowEditDialogComponent | ||||
|       }) | ||||
|     super.save() | ||||
|   } | ||||
|  | ||||
|   public removeSelectedCustomField(fieldId: number, group: FormGroup) { | ||||
|     group | ||||
|       .get('assign_custom_fields') | ||||
|       .setValue( | ||||
|         group.get('assign_custom_fields').value.filter((id) => id !== fieldId) | ||||
|       ) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,32 @@ | ||||
| <div class="modal-header"> | ||||
|     <h4 class="modal-title" id="modal-basic-title">{{title}}</h4> | ||||
|     <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button> | ||||
| </div> | ||||
| <div class="modal-body"> | ||||
|     <div class="mb-1"> | ||||
|         <label for="email" class="form-label" i18n>Email address(es)</label> | ||||
|         <input type="email" class="form-control" id="email" [(ngModel)]="emailAddress"> | ||||
|     </div> | ||||
|     <div class="mb-1"> | ||||
|         <label for="email" class="form-label" i18n>Subject</label> | ||||
|         <input type="email" class="form-control" id="subject" [(ngModel)]="emailSubject"> | ||||
|     </div> | ||||
|     <div> | ||||
|         <label for="message" class="form-label" i18n>Message</label> | ||||
|         <textarea class="form-control" id="message" rows="3" [(ngModel)]="emailMessage"></textarea> | ||||
|     </div> | ||||
| </div> | ||||
| <div class="modal-footer"> | ||||
|     <div class="input-group"> | ||||
|         <div class="input-group-text flex-grow-1"> | ||||
|             <input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion"> | ||||
|             <label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label> | ||||
|         </div> | ||||
|         <button type="submit" class="btn btn-outline-primary" (click)="emailDocument()" [disabled]="loading || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0"> | ||||
|             @if (loading) { | ||||
|                 <div class="spinner-border spinner-border-sm me-2" role="status"></div> | ||||
|             } | ||||
|             <ng-container i18n>Send email</ng-container> | ||||
|         </button> | ||||
|     </div> | ||||
| </div> | ||||
| @@ -0,0 +1,72 @@ | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
|  | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { of, throwError } from 'rxjs' | ||||
| import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { EmailDocumentDialogComponent } from './email-document-dialog.component' | ||||
|  | ||||
| describe('EmailDocumentDialogComponent', () => { | ||||
|   let component: EmailDocumentDialogComponent | ||||
|   let fixture: ComponentFixture<EmailDocumentDialogComponent> | ||||
|   let documentService: DocumentService | ||||
|   let permissionsService: PermissionsService | ||||
|   let toastService: ToastService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       imports: [ | ||||
|         EmailDocumentDialogComponent, | ||||
|         IfPermissionsDirective, | ||||
|         NgxBootstrapIconsModule.pick(allIcons), | ||||
|       ], | ||||
|       providers: [ | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|         NgbActiveModal, | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|  | ||||
|     fixture = TestBed.createComponent(EmailDocumentDialogComponent) | ||||
|     documentService = TestBed.inject(DocumentService) | ||||
|     toastService = TestBed.inject(ToastService) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should set hasArchiveVersion and useArchiveVersion', () => { | ||||
|     expect(component.hasArchiveVersion).toBeTruthy() | ||||
|     component.hasArchiveVersion = false | ||||
|     expect(component.hasArchiveVersion).toBeFalsy() | ||||
|     expect(component.useArchiveVersion).toBeFalsy() | ||||
|   }) | ||||
|  | ||||
|   it('should support sending document via email, showing error if needed', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     component.emailAddress = 'hello@paperless-ngx.com' | ||||
|     component.emailSubject = 'Hello' | ||||
|     component.emailMessage = 'World' | ||||
|     jest | ||||
|       .spyOn(documentService, 'emailDocument') | ||||
|       .mockReturnValue(throwError(() => new Error('Unable to email document'))) | ||||
|     component.emailDocument() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|  | ||||
|     jest.spyOn(documentService, 'emailDocument').mockReturnValue(of(true)) | ||||
|     component.emailDocument() | ||||
|     expect(toastSuccessSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should close the dialog', () => { | ||||
|     const activeModal = TestBed.inject(NgbActiveModal) | ||||
|     const closeSpy = jest.spyOn(activeModal, 'close') | ||||
|     component.close() | ||||
|     expect(closeSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
| @@ -0,0 +1,77 @@ | ||||
| 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.toastService.showInfo($localize`Email sent`) | ||||
|         }, | ||||
|         error: (e) => { | ||||
|           this.loading = false | ||||
|           this.toastService.showError($localize`Error emailing document`, e) | ||||
|         }, | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   public close() { | ||||
|     this.activeModal.close() | ||||
|   } | ||||
| } | ||||
| @@ -17,7 +17,7 @@ import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' | ||||
| import { FilterPipe } from 'src/app/pipes/filter.pipe' | ||||
| import { HotKeyService } from 'src/app/services/hot-key.service' | ||||
| import { SelectionDataItem } from 'src/app/services/rest/document.service' | ||||
| import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options' | ||||
| import { pngxPopperOptions } from 'src/app/utils/popper-options' | ||||
| import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' | ||||
| import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' | ||||
| import { | ||||
| @@ -380,7 +380,7 @@ export class FilterableDropdownComponent | ||||
|   @ViewChild('dropdown') dropdown: NgbDropdown | ||||
|   @ViewChild('buttonItems') buttonItems: ElementRef | ||||
|  | ||||
|   public popperOptions = popperOptionsReenablePreventOverflow | ||||
|   public popperOptions = pngxPopperOptions | ||||
|  | ||||
|   filterText: string | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,77 @@ | ||||
| <div class="list-group mt-3 selected-fields"> | ||||
|   @for (fieldId of selectedFields; track fieldId) { | ||||
|     <div class="list-group-item | ||||
|       d-flex | ||||
|       justify-content-between | ||||
|       align-items-center"> | ||||
|       @switch (getCustomField(fieldId)?.data_type) { | ||||
|         @case (CustomFieldDataType.String) { | ||||
|           <pngx-input-text [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [horizontal]="true"></pngx-input-text> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Date) { | ||||
|           <pngx-input-date [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [horizontal]="true"></pngx-input-date> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Integer) { | ||||
|           <pngx-input-number [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [horizontal]="true" | ||||
|           [showAdd]="false"></pngx-input-number> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Float) { | ||||
|           <pngx-input-number [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [horizontal]="true" | ||||
|           [showAdd]="false" | ||||
|           [step]=".1"></pngx-input-number> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Monetary) { | ||||
|           <pngx-input-monetary [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [defaultCurrency]="getCustomField(fieldId)?.extra_data?.default_currency" | ||||
|           class="flex-grow-1" | ||||
|           [horizontal]="true"></pngx-input-monetary> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Boolean) { | ||||
|           <pngx-input-check [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [horizontal]="true"></pngx-input-check> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Url) { | ||||
|           <pngx-input-url [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [horizontal]="true"></pngx-input-url> | ||||
|         } | ||||
|         @case (CustomFieldDataType.DocumentLink) { | ||||
|           <pngx-input-document-link [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [horizontal]="true"></pngx-input-document-link> | ||||
|         } | ||||
|         @case (CustomFieldDataType.Select) { | ||||
|           <pngx-input-select [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1" | ||||
|           [items]="getCustomField(fieldId)?.extra_data.select_options" | ||||
|           class="flex-grow-1" | ||||
|           bindLabel="label" | ||||
|           [allowNull]="true" | ||||
|           [horizontal]="true"></pngx-input-select> | ||||
|         } | ||||
|       } | ||||
|       <button type="button" class="btn btn-link text-danger" (click)="removeSelectedField.next(fieldId)"> | ||||
|         <i-bs name="trash"></i-bs> | ||||
|       </button> | ||||
|     </div> | ||||
|   } | ||||
| </div> | ||||
| @@ -0,0 +1,3 @@ | ||||
| :host ::ng-deep .list-group-item .mb-3 { | ||||
|   margin-bottom: 0 !important; | ||||
| } | ||||
| @@ -0,0 +1,69 @@ | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { | ||||
|   FormsModule, | ||||
|   NG_VALUE_ACCESSOR, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { of } from 'rxjs' | ||||
| import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' | ||||
| import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' | ||||
| import { CustomFieldsValuesComponent } from './custom-fields-values.component' | ||||
|  | ||||
| describe('CustomFieldsValuesComponent', () => { | ||||
|   let component: CustomFieldsValuesComponent | ||||
|   let fixture: ComponentFixture<CustomFieldsValuesComponent> | ||||
|   let customFieldsService: CustomFieldsService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     TestBed.configureTestingModule({ | ||||
|       imports: [FormsModule, ReactiveFormsModule, CustomFieldsValuesComponent], | ||||
|       providers: [ | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|  | ||||
|     fixture = TestBed.createComponent(CustomFieldsValuesComponent) | ||||
|     fixture.debugElement.injector.get(NG_VALUE_ACCESSOR) | ||||
|     component = fixture.componentInstance | ||||
|     customFieldsService = TestBed.inject(CustomFieldsService) | ||||
|     jest.spyOn(customFieldsService, 'listAll').mockReturnValue( | ||||
|       of({ | ||||
|         all: [1], | ||||
|         count: 1, | ||||
|         results: [ | ||||
|           { | ||||
|             id: 1, | ||||
|             name: 'Field 1', | ||||
|             data_type: CustomFieldDataType.String, | ||||
|           } as CustomField, | ||||
|         ], | ||||
|       }) | ||||
|     ) | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   beforeEach(() => { | ||||
|     fixture = TestBed.createComponent(CustomFieldsValuesComponent) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should set selectedFields and map values correctly', () => { | ||||
|     component.value = { 1: 'value1' } | ||||
|     component.selectedFields = [1, 2] | ||||
|     expect(component.selectedFields).toEqual([1, 2]) | ||||
|     expect(component.value).toEqual({ 1: 'value1', 2: null }) | ||||
|   }) | ||||
|  | ||||
|   it('should return the correct custom field by id', () => { | ||||
|     const field = component.getCustomField(1) | ||||
|     expect(field).toEqual({ | ||||
|       id: 1, | ||||
|       name: 'Field 1', | ||||
|       data_type: CustomFieldDataType.String, | ||||
|     } as CustomField) | ||||
|   }) | ||||
| }) | ||||
| @@ -0,0 +1,90 @@ | ||||
| import { | ||||
|   Component, | ||||
|   EventEmitter, | ||||
|   forwardRef, | ||||
|   Input, | ||||
|   Output, | ||||
| } from '@angular/core' | ||||
| import { | ||||
|   FormsModule, | ||||
|   NG_VALUE_ACCESSOR, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { RouterModule } from '@angular/router' | ||||
| import { NgSelectModule } from '@ng-select/ng-select' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' | ||||
| import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' | ||||
| import { AbstractInputComponent } from '../abstract-input' | ||||
| import { CheckComponent } from '../check/check.component' | ||||
| import { DateComponent } from '../date/date.component' | ||||
| import { DocumentLinkComponent } from '../document-link/document-link.component' | ||||
| import { MonetaryComponent } from '../monetary/monetary.component' | ||||
| import { NumberComponent } from '../number/number.component' | ||||
| import { SelectComponent } from '../select/select.component' | ||||
| import { TextComponent } from '../text/text.component' | ||||
| import { UrlComponent } from '../url/url.component' | ||||
|  | ||||
| @Component({ | ||||
|   providers: [ | ||||
|     { | ||||
|       provide: NG_VALUE_ACCESSOR, | ||||
|       useExisting: forwardRef(() => CustomFieldsValuesComponent), | ||||
|       multi: true, | ||||
|     }, | ||||
|   ], | ||||
|   selector: 'pngx-input-custom-fields-values', | ||||
|   templateUrl: './custom-fields-values.component.html', | ||||
|   styleUrl: './custom-fields-values.component.scss', | ||||
|   imports: [ | ||||
|     TextComponent, | ||||
|     DateComponent, | ||||
|     NumberComponent, | ||||
|     DocumentLinkComponent, | ||||
|     UrlComponent, | ||||
|     SelectComponent, | ||||
|     MonetaryComponent, | ||||
|     CheckComponent, | ||||
|     NgSelectModule, | ||||
|     FormsModule, | ||||
|     ReactiveFormsModule, | ||||
|     RouterModule, | ||||
|     NgxBootstrapIconsModule, | ||||
|   ], | ||||
| }) | ||||
| export class CustomFieldsValuesComponent extends AbstractInputComponent<Object> { | ||||
|   public CustomFieldDataType = CustomFieldDataType | ||||
|  | ||||
|   constructor(customFieldsService: CustomFieldsService) { | ||||
|     super() | ||||
|     customFieldsService.listAll().subscribe((items) => { | ||||
|       this.fields = items.results | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   private fields: CustomField[] | ||||
|  | ||||
|   private _selectedFields: number[] | ||||
|  | ||||
|   @Input() | ||||
|   set selectedFields(newFields: number[]) { | ||||
|     this._selectedFields = newFields | ||||
|     // map the selected fields to an object with field_id as key and value as value | ||||
|     this.value = newFields.reduce((acc, fieldId) => { | ||||
|       acc[fieldId] = this.value?.[fieldId] || null | ||||
|       return acc | ||||
|     }, {}) | ||||
|     this.onChange(this.value) | ||||
|   } | ||||
|  | ||||
|   get selectedFields(): number[] { | ||||
|     return this._selectedFields | ||||
|   } | ||||
|  | ||||
|   @Output() | ||||
|   public removeSelectedField: EventEmitter<number> = new EventEmitter<number>() | ||||
|  | ||||
|   public getCustomField(id: number): CustomField { | ||||
|     return this.fields.find((field) => field.id === id) | ||||
|   } | ||||
| } | ||||
| @@ -1,14 +0,0 @@ | ||||
| <div class="modal-header"> | ||||
|   <h4 class="modal-title" id="modal-basic-title">{{title}}</h4> | ||||
|   <button type="button" class="btn-close" aria-label="Close" (click)="cancelClicked()"> | ||||
|   </button> | ||||
| </div> | ||||
| <div class="modal-body"> | ||||
|  | ||||
|   <pngx-input-select [items]="objects" [title]="message" [(ngModel)]="selected"></pngx-input-select> | ||||
|  | ||||
| </div> | ||||
| <div class="modal-footer"> | ||||
|   <button type="button" class="btn btn-outline-dark" (click)="cancelClicked()" i18n>Cancel</button> | ||||
|   <button type="button" class="btn btn-primary" (click)="selectClicked.emit(selected)" i18n>Select</button> | ||||
| </div> | ||||
| @@ -1,36 +0,0 @@ | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgSelectModule } from '@ng-select/ng-select' | ||||
| import { SelectComponent } from '../input/select/select.component' | ||||
| import { SelectDialogComponent } from './select-dialog.component' | ||||
|  | ||||
| describe('SelectDialogComponent', () => { | ||||
|   let component: SelectDialogComponent | ||||
|   let fixture: ComponentFixture<SelectDialogComponent> | ||||
|   let modal: NgbActiveModal | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     TestBed.configureTestingModule({ | ||||
|       providers: [NgbActiveModal], | ||||
|       imports: [ | ||||
|         NgSelectModule, | ||||
|         FormsModule, | ||||
|         ReactiveFormsModule, | ||||
|         SelectDialogComponent, | ||||
|         SelectComponent, | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|  | ||||
|     modal = TestBed.inject(NgbActiveModal) | ||||
|     fixture = TestBed.createComponent(SelectDialogComponent) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should close modal on cancel', () => { | ||||
|     const closeSpy = jest.spyOn(modal, 'close') | ||||
|     component.cancelClicked() | ||||
|     expect(closeSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
| @@ -1,33 +0,0 @@ | ||||
| import { Component, EventEmitter, Input, Output } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { ObjectWithId } from 'src/app/data/object-with-id' | ||||
| import { SelectComponent } from '../input/select/select.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-select-dialog', | ||||
|   templateUrl: './select-dialog.component.html', | ||||
|   styleUrls: ['./select-dialog.component.scss'], | ||||
|   imports: [SelectComponent, FormsModule, ReactiveFormsModule], | ||||
| }) | ||||
| export class SelectDialogComponent { | ||||
|   constructor(public activeModal: NgbActiveModal) {} | ||||
|  | ||||
|   @Output() | ||||
|   public selectClicked = new EventEmitter() | ||||
|  | ||||
|   @Input() | ||||
|   title = $localize`Select` | ||||
|  | ||||
|   @Input() | ||||
|   message = $localize`Please select an object` | ||||
|  | ||||
|   @Input() | ||||
|   objects: ObjectWithId[] = [] | ||||
|  | ||||
|   selected: number | ||||
|  | ||||
|   cancelClicked() { | ||||
|     this.activeModal.close() | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,68 @@ | ||||
| <div class="modal-header"> | ||||
|   <h4 class="modal-title" id="modal-basic-title">{{title}}</h4> | ||||
|   <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button> | ||||
| </div> | ||||
| <div class="modal-body p-0"> | ||||
|   <ul class="list-group list-group-flush"> | ||||
|     @if (!shareLinks || shareLinks.length === 0) { | ||||
|       <li class="list-group-item fst-italic small text-center text-secondary" i18n> | ||||
|         No existing links | ||||
|       </li> | ||||
|     } | ||||
|     @for (link of shareLinks; track link) { | ||||
|       <li class="list-group-item"> | ||||
|         <div class="input-group w-100"> | ||||
|           <input type="text" class="form-control" aria-label="Share link" [value]="getShareUrl(link)" readonly> | ||||
|           @if (link.expiration) { | ||||
|             <span class="input-group-text"> | ||||
|               {{ getDaysRemaining(link) }} | ||||
|             </span> | ||||
|           } | ||||
|           <button type="button" class="btn btn-outline-primary" (click)="copy(link)"> | ||||
|               @if (copied !== link.id) { | ||||
|                 <i-bs width="1.2em" height="1.2em" name="clipboard-fill"></i-bs> | ||||
|               } | ||||
|               @if (copied === link.id) { | ||||
|                 <i-bs width="1.2em" height="1.2em" name="clipboard-check-fill"></i-bs> | ||||
|               } | ||||
|               <span class="visually-hidden" i18n>Copy</span> | ||||
|           </button> | ||||
|           @if (canShare(link)) { | ||||
|             <button type="button" class="btn btn-outline-primary" (click)="share(link)"> | ||||
|               <i-bs width="1.2em" height="1.2em" name="box-arrow-up"></i-bs><span class="visually-hidden" i18n>Share</span> | ||||
|             </button> | ||||
|           } | ||||
|           <button type="button" class="btn btn-outline-danger" (click)="delete(link)"> | ||||
|             <i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="visually-hidden" i18n>Delete</span> | ||||
|           </button> | ||||
|         </div> | ||||
|         <span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied === link.id" i18n>Copied!</span> | ||||
|       </li> | ||||
|     } | ||||
|   </ul> | ||||
| </div> | ||||
| <div class="modal-footer"> | ||||
|   <div class="input-group w-100"> | ||||
|     <div class="form-check form-switch ms-auto"> | ||||
|       <input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion"> | ||||
|       <label class="form-check-label" for="versionSwitch" i18n>Share archive version</label> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="input-group w-100 mt-2"> | ||||
|     <label class="input-group-text" for="addLink"><ng-container i18n>Expires</ng-container>:</label> | ||||
|     <select class="form-select fs-6" [(ngModel)]="expirationDays"> | ||||
|       @for (option of EXPIRATION_OPTIONS; track option) { | ||||
|         <option [ngValue]="option.value">{{ option.label }}</option> | ||||
|       } | ||||
|     </select> | ||||
|     <button class="btn btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading"> | ||||
|       @if (loading) { | ||||
|         <div class="spinner-border spinner-border-sm me-2" role="status"></div> | ||||
|       } | ||||
|       @if (!loading) { | ||||
|         <i-bs name="plus"></i-bs> | ||||
|       } | ||||
|       <ng-container i18n>Create</ng-container> | ||||
|     </button> | ||||
|   </div> | ||||
| </div> | ||||
| @@ -0,0 +1,3 @@ | ||||
| .copied-badge { | ||||
|     right: 15em; | ||||
| } | ||||
| @@ -11,17 +11,18 @@ import { | ||||
|   tick, | ||||
| } from '@angular/core/testing' | ||||
| import { By } from '@angular/platform-browser' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { of, throwError } from 'rxjs' | ||||
| import { FileVersion, ShareLink } from 'src/app/data/share-link' | ||||
| import { ShareLinkService } from 'src/app/services/rest/share-link.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { environment } from 'src/environments/environment' | ||||
| import { ShareLinksDropdownComponent } from './share-links-dropdown.component' | ||||
| import { ShareLinksDialogComponent } from './share-links-dialog.component' | ||||
| 
 | ||||
| describe('ShareLinksDropdownComponent', () => { | ||||
|   let component: ShareLinksDropdownComponent | ||||
|   let fixture: ComponentFixture<ShareLinksDropdownComponent> | ||||
| describe('ShareLinksDialogComponent', () => { | ||||
|   let component: ShareLinksDialogComponent | ||||
|   let fixture: ComponentFixture<ShareLinksDialogComponent> | ||||
|   let shareLinkService: ShareLinkService | ||||
|   let toastService: ToastService | ||||
|   let httpController: HttpTestingController | ||||
| @@ -30,16 +31,17 @@ describe('ShareLinksDropdownComponent', () => { | ||||
|   beforeEach(() => { | ||||
|     TestBed.configureTestingModule({ | ||||
|       imports: [ | ||||
|         ShareLinksDropdownComponent, | ||||
|         ShareLinksDialogComponent, | ||||
|         NgxBootstrapIconsModule.pick(allIcons), | ||||
|       ], | ||||
|       providers: [ | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|         NgbActiveModal, | ||||
|       ], | ||||
|     }) | ||||
| 
 | ||||
|     fixture = TestBed.createComponent(ShareLinksDropdownComponent) | ||||
|     fixture = TestBed.createComponent(ShareLinksDialogComponent) | ||||
|     shareLinkService = TestBed.inject(ShareLinkService) | ||||
|     toastService = TestBed.inject(ToastService) | ||||
|     httpController = TestBed.inject(HttpTestingController) | ||||
| @@ -232,4 +234,11 @@ describe('ShareLinksDropdownComponent', () => { | ||||
|       ] | ||||
|     ).toBeTruthy() | ||||
|   }) | ||||
| 
 | ||||
|   it('should support close', () => { | ||||
|     const activeModal = TestBed.inject(NgbActiveModal) | ||||
|     const closeSpy = jest.spyOn(activeModal, 'close') | ||||
|     component.close() | ||||
|     expect(closeSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { Clipboard } from '@angular/cdk/clipboard' | ||||
| import { Component, Input, OnInit } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { first } from 'rxjs' | ||||
| import { FileVersion, ShareLink } from 'src/app/data/share-link' | ||||
| @@ -10,17 +10,12 @@ import { ToastService } from 'src/app/services/toast.service' | ||||
| import { environment } from 'src/environments/environment' | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'pngx-share-links-dropdown', | ||||
|   templateUrl: './share-links-dropdown.component.html', | ||||
|   styleUrls: ['./share-links-dropdown.component.scss'], | ||||
|   imports: [ | ||||
|     FormsModule, | ||||
|     ReactiveFormsModule, | ||||
|     NgbDropdownModule, | ||||
|     NgxBootstrapIconsModule, | ||||
|   ], | ||||
|   selector: 'pngx-share-links-dialog', | ||||
|   templateUrl: './share-links-dialog.component.html', | ||||
|   styleUrls: ['./share-links-dialog.component.scss'], | ||||
|   imports: [FormsModule, ReactiveFormsModule, NgxBootstrapIconsModule], | ||||
| }) | ||||
| export class ShareLinksDropdownComponent implements OnInit { | ||||
| export class ShareLinksDialogComponent implements OnInit { | ||||
|   EXPIRATION_OPTIONS = [ | ||||
|     { label: $localize`1 day`, value: 1 }, | ||||
|     { label: $localize`7 days`, value: 7 }, | ||||
| @@ -41,9 +36,6 @@ export class ShareLinksDropdownComponent implements OnInit { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @Input() | ||||
|   disabled: boolean = false | ||||
| 
 | ||||
|   private _hasArchiveVersion: boolean = true | ||||
| 
 | ||||
|   @Input() | ||||
| @@ -67,6 +59,7 @@ export class ShareLinksDropdownComponent implements OnInit { | ||||
|   useArchiveVersion: boolean = true | ||||
| 
 | ||||
|   constructor( | ||||
|     private activeModal: NgbActiveModal, | ||||
|     private shareLinkService: ShareLinkService, | ||||
|     private toastService: ToastService, | ||||
|     private clipboard: Clipboard | ||||
| @@ -169,4 +162,8 @@ export class ShareLinksDropdownComponent implements OnInit { | ||||
|         }, | ||||
|       }) | ||||
|   } | ||||
| 
 | ||||
|   close() { | ||||
|     this.activeModal.close() | ||||
|   } | ||||
| } | ||||
| @@ -1,70 +0,0 @@ | ||||
| <div ngbDropdown> | ||||
|   <button class="btn btn-sm btn-outline-primary" id="shareLinksDropdown" [disabled]="disabled" ngbDropdownToggle> | ||||
|     <i-bs name="link"></i-bs> | ||||
|     <div class="d-none d-sm-inline"> <ng-container i18n>Share Links</ng-container></div> | ||||
|   </button> | ||||
|   <div ngbDropdownMenu aria-labelledby="shareLinksDropdown" class="shadow share-links-dropdown"> | ||||
|     <ul class="list-group list-group-flush"> | ||||
|       @if (!shareLinks || shareLinks.length === 0) { | ||||
|         <li class="list-group-item fst-italic small text-center text-secondary" i18n> | ||||
|           No existing links | ||||
|         </li> | ||||
|       } | ||||
|       @for (link of shareLinks; track link) { | ||||
|         <li class="list-group-item"> | ||||
|           <div class="input-group input-group-sm w-100"> | ||||
|             <input type="text" class="form-control" aria-label="Share link" [value]="getShareUrl(link)" readonly> | ||||
|             @if (link.expiration) { | ||||
|               <span class="input-group-text"> | ||||
|                 {{ getDaysRemaining(link) }} | ||||
|               </span> | ||||
|             } | ||||
|             <button type="button" class="btn btn-sm btn-outline-primary" (click)="copy(link)"> | ||||
|                 @if (copied !== link.id) { | ||||
|                   <i-bs width="1.2em" height="1.2em" name="clipboard-fill"></i-bs> | ||||
|                 } | ||||
|                 @if (copied === link.id) { | ||||
|                   <i-bs width="1.2em" height="1.2em" name="clipboard-check-fill"></i-bs> | ||||
|                 } | ||||
|                 <span class="visually-hidden" i18n>Copy</span> | ||||
|               </button> | ||||
|               @if (canShare(link)) { | ||||
|                 <button type="button" class="btn btn-sm btn-outline-primary" (click)="share(link)"> | ||||
|                   <i-bs width="1.2em" height="1.2em" name="box-arrow-up"></i-bs><span class="visually-hidden" i18n>Share</span> | ||||
|                   </button> | ||||
|                 } | ||||
|                 <button type="button" class="btn btn-sm btn-outline-danger" (click)="delete(link)"> | ||||
|                   <i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="visually-hidden" i18n>Delete</span> | ||||
|                   </button> | ||||
|                 </div> | ||||
|                 <span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied === link.id" i18n>Copied!</span> | ||||
|               </li> | ||||
|             } | ||||
|             <li class="list-group-item pt-3 pb-2"> | ||||
|               <div class="input-group input-group-sm w-100"> | ||||
|                 <div class="form-check form-switch ms-auto small"> | ||||
|                   <input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion"> | ||||
|                   <label class="form-check-label" for="versionSwitch" i18n>Share archive version</label> | ||||
|                 </div> | ||||
|               </div> | ||||
|               <div class="input-group input-group-sm w-100 mt-2"> | ||||
|                 <label class="input-group-text" for="addLink"><ng-container i18n>Expires</ng-container>:</label> | ||||
|                 <select class="form-select form-select-sm" [(ngModel)]="expirationDays"> | ||||
|                   @for (option of EXPIRATION_OPTIONS; track option) { | ||||
|                     <option [ngValue]="option.value">{{ option.label }}</option> | ||||
|                   } | ||||
|                 </select> | ||||
|                 <button class="btn btn-sm btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading"> | ||||
|                   @if (loading) { | ||||
|                     <div class="spinner-border spinner-border-sm me-2" role="status"></div> | ||||
|                   } | ||||
|                   @if (!loading) { | ||||
|                     <i-bs name="plus"></i-bs> | ||||
|                   } | ||||
|                   <ng-container i18n>Create</ng-container> | ||||
|                 </button> | ||||
|               </div> | ||||
|             </li> | ||||
|           </ul> | ||||
|         </div> | ||||
|       </div> | ||||
| @@ -1,14 +0,0 @@ | ||||
| .share-links-dropdown { | ||||
|     min-width: 350px; | ||||
|  | ||||
|     // correct position on mobile | ||||
|     @media (max-width: 575.98px) { | ||||
|         &.show { | ||||
|             margin-left: -175px !important; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| .copied-badge { | ||||
|     right: 7.5em; | ||||
| } | ||||
| @@ -12,7 +12,7 @@ | ||||
|     </div> | ||||
|   } @else { | ||||
|     <div class="row row-cols-1 row-cols-md-4 g-3"> | ||||
|       <div class="col-4"> | ||||
|       <div class="col"> | ||||
|         <div class="card bg-light h-100"> | ||||
|           <div class="card-header"> | ||||
|             <h6 class="card-title mb-0" i18n>Environment</h6> | ||||
| @@ -46,14 +46,14 @@ | ||||
|               <dd>{{status.database.type}}</dd> | ||||
|               <dt i18n>Status</dt> | ||||
|               <dd> | ||||
|                 <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="databaseStatus" triggers="mouseenter:mouseleave"> | ||||
|                 <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="databaseStatus" triggers="click mouseenter:mouseleave"> | ||||
|                   {{status.database.status}} | ||||
|                   @if (status.database.status === 'OK') { | ||||
|                     <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs> | ||||
|                   } @else { | ||||
|                     <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs> | ||||
|                   } | ||||
|                 </div> | ||||
|                 </button> | ||||
|                 <ng-template #databaseStatus> | ||||
|                   @if (status.database.status === 'OK') { | ||||
|                     {{status.database.url}} | ||||
| @@ -64,7 +64,7 @@ | ||||
|               </dd> | ||||
|               <dt i18n>Migration Status</dt> | ||||
|               <dd> | ||||
|                 <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"> | ||||
|                 <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="migrationStatus" triggers="click mouseenter:mouseleave"> | ||||
|                   @if (status.database.migration_status.unapplied_migrations.length === 0) { | ||||
|                     <ng-container i18n>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs> | ||||
|                   } @else { | ||||
| @@ -81,7 +81,7 @@ | ||||
|                       </ul> | ||||
|                     } | ||||
|                   </ng-template> | ||||
|                 </div> | ||||
|                 </button> | ||||
|               </dd> | ||||
|             </dl> | ||||
|           </div> | ||||
| @@ -97,14 +97,14 @@ | ||||
|             <dl class="card-text"> | ||||
|               <dt i18n>Redis Status</dt> | ||||
|               <dd> | ||||
|                 <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="redisStatus" triggers="mouseenter:mouseleave"> | ||||
|                 <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="redisStatus" triggers="click mouseenter:mouseleave"> | ||||
|                   {{status.tasks.redis_status}} | ||||
|                   @if (status.tasks.redis_status === 'OK') { | ||||
|                     <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs> | ||||
|                   } @else { | ||||
|                     <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs> | ||||
|                   } | ||||
|                 </div> | ||||
|                 </button> | ||||
|                 <ng-template #redisStatus> | ||||
|                   @if (status.tasks.redis_status === 'OK') { | ||||
|                     {{status.tasks.redis_url}} | ||||
| @@ -115,14 +115,14 @@ | ||||
|               </dd> | ||||
|               <dt i18n>Celery Status</dt> | ||||
|               <dd> | ||||
|                 <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="celeryStatus" triggers="mouseenter:mouseleave"> | ||||
|                 <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="celeryStatus" triggers="click mouseenter:mouseleave"> | ||||
|                   {{status.tasks.celery_status}} | ||||
|                   @if (status.tasks.celery_status === 'OK') { | ||||
|                     <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs> | ||||
|                   } @else { | ||||
|                     <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs> | ||||
|                   } | ||||
|                 </div> | ||||
|                 </button> | ||||
|                 <ng-template #celeryStatus> | ||||
|                   @if (status.tasks.celery_status === 'OK') { | ||||
|                     {{status.tasks.celery_url}} | ||||
| @@ -144,8 +144,8 @@ | ||||
|           <div class="card-body"> | ||||
|             <dl class="card-text"> | ||||
|               <dt i18n>Search Index</dt> | ||||
|               <dd> | ||||
|                 <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"> | ||||
|               <dd class="d-flex align-items-center"> | ||||
|                 <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="indexStatus" triggers="click mouseenter:mouseleave"> | ||||
|                   {{status.tasks.index_status}} | ||||
|                   @if (status.tasks.index_status === 'OK') { | ||||
|                     @if (isStale(status.tasks.index_last_modified)) { | ||||
| @@ -156,7 +156,17 @@ | ||||
|                   } @else { | ||||
|                     <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs> | ||||
|                   } | ||||
|                 </div> | ||||
|                 </button> | ||||
|                 @if (currentUserIsSuperUser) { | ||||
|                   @if (isRunning(PaperlessTaskName.IndexOptimize)) { | ||||
|                     <div class="spinner-border spinner-border-sm ms-2" role="status"></div> | ||||
|                   } @else { | ||||
|                     <button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.IndexOptimize)"> | ||||
|                       <i-bs name="play-fill"></i-bs>  | ||||
|                       <ng-container i18n>Run Task</ng-container> | ||||
|                     </button> | ||||
|                   } | ||||
|                 } | ||||
|               </dd> | ||||
|               <ng-template #indexStatus> | ||||
|                 @if (status.tasks.index_status === 'OK') { | ||||
| @@ -166,8 +176,8 @@ | ||||
|                 } | ||||
|               </ng-template> | ||||
|               <dt i18n>Classifier</dt> | ||||
|               <dd> | ||||
|                 <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave"> | ||||
|               <dd class="d-flex align-items-center"> | ||||
|                 <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="classifierStatus" triggers="click mouseenter:mouseleave"> | ||||
|                   {{status.tasks.classifier_status}} | ||||
|                   @if (status.tasks.classifier_status === 'OK') { | ||||
|                     @if (isStale(status.tasks.classifier_last_trained)) { | ||||
| @@ -180,7 +190,17 @@ | ||||
|                     [class.text-danger]="status.tasks.classifier_status === SystemStatusItemStatus.ERROR" | ||||
|                     [class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING"></i-bs> | ||||
|                   } | ||||
|                 </div> | ||||
|                 </button> | ||||
|                 @if (currentUserIsSuperUser) { | ||||
|                   @if (isRunning(PaperlessTaskName.TrainClassifier)) { | ||||
|                     <div class="spinner-border spinner-border-sm ms-2" role="status"></div> | ||||
|                   } @else { | ||||
|                     <button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.TrainClassifier)"> | ||||
|                       <i-bs name="play-fill"></i-bs>  | ||||
|                       <ng-container i18n>Run Task</ng-container> | ||||
|                     </button> | ||||
|                   } | ||||
|                 } | ||||
|               </dd> | ||||
|               <ng-template #classifierStatus> | ||||
|                 @if (status.tasks.classifier_status === 'OK') { | ||||
| @@ -190,8 +210,8 @@ | ||||
|                 } | ||||
|               </ng-template> | ||||
|               <dt i18n>Sanity Checker</dt> | ||||
|               <dd> | ||||
|                 <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="sanityCheckerStatus" triggers="mouseenter:mouseleave"> | ||||
|               <dd class="d-flex align-items-center"> | ||||
|                 <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="sanityCheckerStatus" triggers="click mouseenter:mouseleave"> | ||||
|                   {{status.tasks.sanity_check_status}} | ||||
|                   @if (status.tasks.sanity_check_status === 'OK') { | ||||
|                     @if (isStale(status.tasks.sanity_check_last_run)) { | ||||
| @@ -204,7 +224,17 @@ | ||||
|                     [class.text-danger]="status.tasks.sanity_check_status === SystemStatusItemStatus.ERROR" | ||||
|                     [class.text-warning]="status.tasks.sanity_check_status === SystemStatusItemStatus.WARNING"></i-bs> | ||||
|                   } | ||||
|                 </div> | ||||
|                 </button> | ||||
|                 @if (currentUserIsSuperUser) { | ||||
|                   @if (isRunning(PaperlessTaskName.SanityCheck)) { | ||||
|                     <div class="spinner-border spinner-border-sm ms-2" role="status"></div> | ||||
|                   } @else { | ||||
|                     <button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.SanityCheck)"> | ||||
|                       <i-bs name="play-fill"></i-bs>  | ||||
|                       <ng-container i18n>Run Task</ng-container> | ||||
|                     </button> | ||||
|                   } | ||||
|                 } | ||||
|               </dd> | ||||
|               <ng-template #sanityCheckerStatus> | ||||
|                 @if (status.tasks.sanity_check_status === 'OK') { | ||||
| @@ -221,7 +251,7 @@ | ||||
|   } | ||||
| </div> | ||||
| <div class="modal-footer"> | ||||
|   <button class="btn btn-sm btn-outline-secondary" (click)="copy()"> | ||||
|   <button class="btn btn-sm d-flex align-items-center btn-dark btn btn-sm d-flex align-items-center btn-dark btn-outline-secondary" (click)="copy()"> | ||||
|     @if (!copied) { | ||||
|       <i-bs name="clipboard-fill"></i-bs>  | ||||
|     } | ||||
|   | ||||
| @@ -1,3 +1,3 @@ | ||||
| .border-primary { | ||||
|   --bs-border-color: var(--bs-primary); | ||||
| .btn.small { | ||||
|   font-size: 0.75rem; | ||||
| } | ||||
|   | ||||
| @@ -9,11 +9,16 @@ import { | ||||
| } from '@angular/core/testing' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { of, throwError } from 'rxjs' | ||||
| import { PaperlessTaskName } from 'src/app/data/paperless-task' | ||||
| import { | ||||
|   InstallType, | ||||
|   SystemStatus, | ||||
|   SystemStatusItemStatus, | ||||
| } from 'src/app/data/system-status' | ||||
| import { SystemStatusService } from 'src/app/services/system-status.service' | ||||
| import { TasksService } from 'src/app/services/tasks.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { SystemStatusDialogComponent } from './system-status-dialog.component' | ||||
|  | ||||
| const status: SystemStatus = { | ||||
| @@ -54,6 +59,9 @@ describe('SystemStatusDialogComponent', () => { | ||||
|   let component: SystemStatusDialogComponent | ||||
|   let fixture: ComponentFixture<SystemStatusDialogComponent> | ||||
|   let clipboard: Clipboard | ||||
|   let tasksService: TasksService | ||||
|   let systemStatusService: SystemStatusService | ||||
|   let toastService: ToastService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
| @@ -72,6 +80,9 @@ describe('SystemStatusDialogComponent', () => { | ||||
|     component = fixture.componentInstance | ||||
|     component.status = status | ||||
|     clipboard = TestBed.inject(Clipboard) | ||||
|     tasksService = TestBed.inject(TasksService) | ||||
|     systemStatusService = TestBed.inject(SystemStatusService) | ||||
|     toastService = TestBed.inject(ToastService) | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
| @@ -98,4 +109,37 @@ describe('SystemStatusDialogComponent', () => { | ||||
|     expect(component.isStale(date.toISOString())).toBeTruthy() | ||||
|     expect(component.isStale(date.toISOString(), 26)).toBeFalsy() | ||||
|   }) | ||||
|  | ||||
|   it('should check if task is running', () => { | ||||
|     component.runTask(PaperlessTaskName.IndexOptimize) | ||||
|     expect(component.isRunning(PaperlessTaskName.IndexOptimize)).toBeTruthy() | ||||
|     expect(component.isRunning(PaperlessTaskName.SanityCheck)).toBeFalsy() | ||||
|   }) | ||||
|  | ||||
|   it('should support running tasks, refresh status and show toasts', () => { | ||||
|     const toastSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const getStatusSpy = jest.spyOn(systemStatusService, 'get') | ||||
|     const runSpy = jest.spyOn(tasksService, 'run') | ||||
|  | ||||
|     // fail first | ||||
|     runSpy.mockReturnValue(throwError(() => new Error('error'))) | ||||
|     component.runTask(PaperlessTaskName.IndexOptimize) | ||||
|     expect(runSpy).toHaveBeenCalledWith(PaperlessTaskName.IndexOptimize) | ||||
|     expect(toastErrorSpy).toHaveBeenCalledWith( | ||||
|       `Failed to start task ${PaperlessTaskName.IndexOptimize}, see the logs for more details`, | ||||
|       expect.any(Error) | ||||
|     ) | ||||
|  | ||||
|     // succeed | ||||
|     runSpy.mockReturnValue(of({})) | ||||
|     getStatusSpy.mockReturnValue(of(status)) | ||||
|     component.runTask(PaperlessTaskName.IndexOptimize) | ||||
|     expect(runSpy).toHaveBeenCalledWith(PaperlessTaskName.IndexOptimize) | ||||
|  | ||||
|     expect(getStatusSpy).toHaveBeenCalled() | ||||
|     expect(toastSpy).toHaveBeenCalledWith( | ||||
|       `Task ${PaperlessTaskName.IndexOptimize} started` | ||||
|     ) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -7,12 +7,17 @@ import { | ||||
|   NgbProgressbarModule, | ||||
| } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { PaperlessTaskName } from 'src/app/data/paperless-task' | ||||
| import { | ||||
|   SystemStatus, | ||||
|   SystemStatusItemStatus, | ||||
| } from 'src/app/data/system-status' | ||||
| import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' | ||||
| import { FileSizePipe } from 'src/app/pipes/file-size.pipe' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { SystemStatusService } from 'src/app/services/system-status.service' | ||||
| import { TasksService } from 'src/app/services/tasks.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-system-status-dialog', | ||||
| @@ -30,13 +35,24 @@ import { FileSizePipe } from 'src/app/pipes/file-size.pipe' | ||||
| }) | ||||
| export class SystemStatusDialogComponent { | ||||
|   public SystemStatusItemStatus = SystemStatusItemStatus | ||||
|   public PaperlessTaskName = PaperlessTaskName | ||||
|   public status: SystemStatus | ||||
|  | ||||
|   public copied: boolean = false | ||||
|  | ||||
|   private runningTasks: Set<PaperlessTaskName> = new Set() | ||||
|  | ||||
|   get currentUserIsSuperUser(): boolean { | ||||
|     return this.permissionsService.isSuperUser() | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     public activeModal: NgbActiveModal, | ||||
|     private clipboard: Clipboard | ||||
|     private clipboard: Clipboard, | ||||
|     private systemStatusService: SystemStatusService, | ||||
|     private tasksService: TasksService, | ||||
|     private toastService: ToastService, | ||||
|     private permissionsService: PermissionsService | ||||
|   ) {} | ||||
|  | ||||
|   public close() { | ||||
| @@ -56,4 +72,30 @@ export class SystemStatusDialogComponent { | ||||
|     const now = new Date() | ||||
|     return now.getTime() - date.getTime() > hours * 60 * 60 * 1000 | ||||
|   } | ||||
|  | ||||
|   public isRunning(taskName: PaperlessTaskName): boolean { | ||||
|     return this.runningTasks.has(taskName) | ||||
|   } | ||||
|  | ||||
|   public runTask(taskName: PaperlessTaskName) { | ||||
|     this.runningTasks.add(taskName) | ||||
|     this.toastService.showInfo(`Task ${taskName} started`) | ||||
|     this.tasksService.run(taskName).subscribe({ | ||||
|       next: () => { | ||||
|         this.runningTasks.delete(taskName) | ||||
|         this.systemStatusService.get().subscribe({ | ||||
|           next: (status) => { | ||||
|             this.status = status | ||||
|           }, | ||||
|         }) | ||||
|       }, | ||||
|       error: (err) => { | ||||
|         this.runningTasks.delete(taskName) | ||||
|         this.toastService.showError( | ||||
|           `Failed to start task ${taskName}, see the logs for more details`, | ||||
|           err | ||||
|         ) | ||||
|       }, | ||||
|     }) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -33,7 +33,7 @@ | ||||
|                 } | ||||
|                 <div class="row"> | ||||
|                     <div class="col offset-sm-3"> | ||||
|                     <button class="btn btn-sm btn-outline-dark" (click)="copyError(toast.error)"> | ||||
|                     <button class="btn btn-sm btn-outline-secondary" (click)="copyError(toast.error)"> | ||||
|                         @if (!copied) { | ||||
|                         <i-bs name="clipboard"></i-bs>  | ||||
|                         } | ||||
| @@ -48,9 +48,9 @@ | ||||
|             </details> | ||||
|             } | ||||
|             @if (toast.action) { | ||||
|             <p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="close.emit(toast); toast.action()">{{toast.actionName}}</button></p> | ||||
|             <p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="closed.emit(toast); toast.action()">{{toast.actionName}}</button></p> | ||||
|             } | ||||
|         </div> | ||||
|         <button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="close.emit(toast);"></button> | ||||
|         <button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="closed.emit(toast);"></button> | ||||
|     </div> | ||||
| </ngb-toast> | ||||
|   | ||||
| @@ -27,7 +27,7 @@ export class ToastComponent { | ||||
|  | ||||
|   @Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>() | ||||
|  | ||||
|   @Output() close: EventEmitter<Toast> = new EventEmitter<Toast>() | ||||
|   @Output() closed: EventEmitter<Toast> = new EventEmitter<Toast>() | ||||
|  | ||||
|   public copied: boolean = false | ||||
|  | ||||
|   | ||||
| @@ -1,3 +1,3 @@ | ||||
| @for (toast of toasts; track toast.id) { | ||||
|   <pngx-toast [toast]="toast" [autohide]="true" (close)="closeToast()"></pngx-toast> | ||||
|   <pngx-toast [toast]="toast" [autohide]="true" (closed)="closeToast()"></pngx-toast> | ||||
| } | ||||
|   | ||||
| @@ -49,12 +49,8 @@ | ||||
|   </div> | ||||
|   <div class="col-12 col-lg-4 col-xl-3 col-sidebar"> | ||||
|     <div class="row row-cols-1 g-4 mb-4 sticky-lg-top z-0"> | ||||
|       <div class="col"> | ||||
|         <pngx-statistics-widget *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }"></pngx-statistics-widget> | ||||
|       </div> | ||||
|       <div class="col"> | ||||
|         <pngx-upload-file-widget></pngx-upload-file-widget> | ||||
|       </div> | ||||
|       <pngx-upload-file-widget></pngx-upload-file-widget> | ||||
|       <pngx-statistics-widget *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }"></pngx-statistics-widget> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
|   | ||||
| @@ -1,12 +1,15 @@ | ||||
| <pngx-widget-frame title="Upload new documents" i18n-title *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }"> | ||||
| <pngx-widget-frame *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }" [cardless]="true"> | ||||
|   <div content tourAnchor="tour.upload-widget"> | ||||
|     <form class="justify-content-center d-flex flex-column align-items-center py-3 px-2"> | ||||
|       <span class="text-muted" i18n>Drop documents anywhere or</span> | ||||
|       <button type="button" class="btn btn-sm btn-outline-primary mt-3" (click)="fileUpload.click()" i18n>Browse files</button> | ||||
|     <form class="justify-content-center d-flex flex-column align-items-center"> | ||||
|       <button type="button" class="btn btn-outline-dark bg-light shadow-sm w-100 h-100 pt-3 pb-3" (click)="fileUpload.click()"> | ||||
|         <i-bs class="text-primary" name="plus-circle"></i-bs>  | ||||
|         <span class="text-primary" i18n>Upload documents</span> | ||||
|         <div class="text-muted smaller fst-italic" i18n>or drop files anywhere</div> | ||||
|       </button> | ||||
|       <input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload> | ||||
|     </form> | ||||
|     @if (getStatus().length > 0) { | ||||
|     <div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none max-vh100-40" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'"> | ||||
|     <div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'"> | ||||
|       <div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll"> | ||||
|         <div class="card shadow-sm consumer-status-card"> | ||||
|           <div class="card-body"> | ||||
| @@ -30,24 +33,6 @@ | ||||
|                     <ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container> | ||||
|                   </div> | ||||
|                 } | ||||
|                 @if (getStatusHidden().length) { | ||||
|                   <div class="alerts-hidden"> | ||||
|                     @if (!alertsExpanded) { | ||||
|                       <p class="mt-3 mb-0 text-center"> | ||||
|                         <span i18n="This is shown as a summary line when there are more than 5 document in the processing pipeline.">{getStatusHidden().length, plural, =1 {One more document} other {{{getStatusHidden().length}} more documents}}</span> | ||||
|                          •  | ||||
|                         <a [routerLink]="[]" (click)="alertsExpanded = !alertsExpanded" aria-controls="hiddenAlerts" [attr.aria-expanded]="alertsExpanded" i18n>Show all</a> | ||||
|                       </p> | ||||
|                     } | ||||
|                     <div #hiddenAlerts="ngbCollapse" [ngbCollapse]="!alertsExpanded" (ngbCollapseChange)="alertsExpanded = $event"> | ||||
|                       @for (status of getStatusHidden(); track status) { | ||||
|                         <div> | ||||
|                           <ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container> | ||||
|                         </div> | ||||
|                       } | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 } | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|   | ||||
| @@ -1,5 +1,13 @@ | ||||
| form { | ||||
|   position: relative; | ||||
| :host ::ng-deep i-bs svg { | ||||
|   margin-top: -3px; | ||||
| } | ||||
|  | ||||
| .btn-outline-dark { | ||||
|   --bs-btn-border-color: var(--bs-border-color-translucent); | ||||
| } | ||||
|  | ||||
| .smaller { | ||||
|   font-size: 0.75rem; | ||||
| } | ||||
|  | ||||
| .alert-heading { | ||||
| @@ -40,6 +48,10 @@ form { | ||||
|   background-color: rgba(var(--bs-body-bg-rgb), .95) !important; | ||||
| } | ||||
|  | ||||
| .max-vh100-40 { | ||||
|   max-height: calc(100vh - 40px); | ||||
| .consumer-status-list { | ||||
|   max-height: calc(100vh - 312px); // e.g. below the upload button, mobile | ||||
|  | ||||
|   @media screen and (min-width: 768px) { | ||||
|     max-height: calc(100vh - 208px); // e.g. below the upload button | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -8,7 +8,6 @@ import { | ||||
| } from '@angular/core/testing' | ||||
| import { By } from '@angular/platform-browser' | ||||
| import { RouterTestingModule } from '@angular/router/testing' | ||||
| import { NgbAlert, NgbCollapse } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { routes } from 'src/app/app-routing.module' | ||||
| import { PermissionsGuard } from 'src/app/guards/permissions.guard' | ||||
| @@ -116,20 +115,6 @@ describe('UploadFileWidgetComponent', () => { | ||||
|     expect(component.getStatusColor(successStatus)).toEqual('success') | ||||
|   }) | ||||
|  | ||||
|   it('should enforce a maximum number of alerts', () => { | ||||
|     mockConsumerStatuses(websocketStatusService) | ||||
|     fixture.detectChanges() | ||||
|     // 5 total, 1 hidden | ||||
|     expect(fixture.debugElement.queryAll(By.directive(NgbAlert))).toHaveLength( | ||||
|       6 | ||||
|     ) | ||||
|     expect( | ||||
|       fixture.debugElement | ||||
|         .query(By.directive(NgbCollapse)) | ||||
|         .queryAll(By.directive(NgbAlert)) | ||||
|     ).toHaveLength(1) | ||||
|   }) | ||||
|  | ||||
|   it('should allow dismissing an alert', () => { | ||||
|     const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss') | ||||
|     component.dismiss(new FileStatus()) | ||||
| @@ -138,7 +123,6 @@ describe('UploadFileWidgetComponent', () => { | ||||
|  | ||||
|   it('should allow dismissing completed alerts', fakeAsync(() => { | ||||
|     mockConsumerStatuses(websocketStatusService) | ||||
|     component.alertsExpanded = true | ||||
|     fixture.detectChanges() | ||||
|     jest | ||||
|       .spyOn(component, 'getStatusCompleted') | ||||
|   | ||||
| @@ -4,7 +4,6 @@ import { RouterModule } from '@angular/router' | ||||
| import { | ||||
|   NgbAlert, | ||||
|   NgbAlertModule, | ||||
|   NgbCollapseModule, | ||||
|   NgbProgressbarModule, | ||||
| } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| @@ -21,8 +20,6 @@ import { | ||||
| } from 'src/app/services/websocket-status.service' | ||||
| import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' | ||||
|  | ||||
| const MAX_ALERTS = 5 | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-upload-file-widget', | ||||
|   templateUrl: './upload-file-widget.component.html', | ||||
| @@ -34,15 +31,12 @@ const MAX_ALERTS = 5 | ||||
|     NgTemplateOutlet, | ||||
|     RouterModule, | ||||
|     NgbAlertModule, | ||||
|     NgbCollapseModule, | ||||
|     NgbProgressbarModule, | ||||
|     NgxBootstrapIconsModule, | ||||
|     TourNgBootstrapModule, | ||||
|   ], | ||||
| }) | ||||
| export class UploadFileWidgetComponent extends ComponentWithPermissions { | ||||
|   alertsExpanded = false | ||||
|  | ||||
|   @ViewChildren(NgbAlert) alerts: QueryList<NgbAlert> | ||||
|  | ||||
|   constructor( | ||||
| @@ -54,7 +48,7 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions { | ||||
|   } | ||||
|  | ||||
|   getStatus() { | ||||
|     return this.websocketStatusService.getConsumerStatus().slice(0, MAX_ALERTS) | ||||
|     return this.websocketStatusService.getConsumerStatus() | ||||
|   } | ||||
|  | ||||
|   getStatusSummary() { | ||||
| @@ -77,13 +71,6 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions { | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   getStatusHidden() { | ||||
|     if (this.websocketStatusService.getConsumerStatus().length < MAX_ALERTS) | ||||
|       return [] | ||||
|     else | ||||
|       return this.websocketStatusService.getConsumerStatus().slice(MAX_ALERTS) | ||||
|   } | ||||
|  | ||||
|   getStatusUploading() { | ||||
|     return this.websocketStatusService.getConsumerStatus( | ||||
|       FileStatusPhase.UPLOADING | ||||
|   | ||||
| @@ -1,23 +1,32 @@ | ||||
| <div class="card shadow-sm bg-light fade" [class.show]="show" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent"> | ||||
|   <div class="card-header"> | ||||
|     <div class="d-flex justify-content-between align-items-center"> | ||||
|       <div class="d-flex"> | ||||
|         @if (draggable) { | ||||
|           <div class="ms-n2 me-1" cdkDragHandle> | ||||
|             <i-bs name="grip-vertical"></i-bs> | ||||
|           </div> | ||||
| @if (!cardless) { | ||||
|   <div class="card shadow-sm bg-light fade" [class.show]="show" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent"> | ||||
|     <div class="card-header"> | ||||
|       <div class="d-flex justify-content-between align-items-center"> | ||||
|         <div class="d-flex"> | ||||
|           @if (draggable) { | ||||
|             <div class="ms-n2 me-1" cdkDragHandle> | ||||
|               <i-bs name="grip-vertical"></i-bs> | ||||
|             </div> | ||||
|           } | ||||
|           <h6 class="card-title mb-0">{{title}}</h6> | ||||
|         </div> | ||||
|         @if (loading) { | ||||
|           <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> | ||||
|           <div class="visually-hidden" i18n>Loading...</div> | ||||
|         } | ||||
|         <h6 class="card-title mb-0">{{title}}</h6> | ||||
|         <ng-content select="[header-buttons]"></ng-content> | ||||
|       </div> | ||||
|       @if (loading) { | ||||
|         <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> | ||||
|         <div class="visually-hidden" i18n>Loading...</div> | ||||
|       } | ||||
|       <ng-content select="[header-buttons]"></ng-content> | ||||
|     </div> | ||||
|     <div class="card-body text-dark"> | ||||
|       <ng-container [ngTemplateOutlet]="content"></ng-container> | ||||
|     </div> | ||||
|   </div> | ||||
| } @else { | ||||
|   <div class="fade" [class.show]="show"> | ||||
|     <ng-container [ngTemplateOutlet]="content"></ng-container> | ||||
|   </div> | ||||
| } | ||||
|  | ||||
|   </div> | ||||
|   <div class="card-body text-dark"> | ||||
|     <ng-content select="[content]"></ng-content> | ||||
|   </div> | ||||
| </div> | ||||
| <ng-template #content> | ||||
|   <ng-content select="[content]"></ng-content> | ||||
| </ng-template> | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { DragDropModule } from '@angular/cdk/drag-drop' | ||||
| import { NgTemplateOutlet } from '@angular/common' | ||||
| import { AfterViewInit, Component, Input } from '@angular/core' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component' | ||||
| @@ -7,7 +8,7 @@ import { LoadingComponentWithPermissions } from 'src/app/components/loading-comp | ||||
|   selector: 'pngx-widget-frame', | ||||
|   templateUrl: './widget-frame.component.html', | ||||
|   styleUrls: ['./widget-frame.component.scss'], | ||||
|   imports: [DragDropModule, NgxBootstrapIconsModule], | ||||
|   imports: [DragDropModule, NgxBootstrapIconsModule, NgTemplateOutlet], | ||||
| }) | ||||
| export class WidgetFrameComponent | ||||
|   extends LoadingComponentWithPermissions | ||||
| @@ -26,6 +27,9 @@ export class WidgetFrameComponent | ||||
|   @Input() | ||||
|   draggable: any | ||||
|  | ||||
|   @Input() | ||||
|   cardless: boolean = false | ||||
|  | ||||
|   ngAfterViewInit(): void { | ||||
|     setTimeout(() => { | ||||
|       this.show = true | ||||
|   | ||||
| @@ -81,7 +81,24 @@ | ||||
|     (added)="addField($event)"> | ||||
|   </pngx-custom-fields-dropdown> | ||||
|  | ||||
|   <pngx-share-links-dropdown [documentId]="documentId" [hasArchiveVersion]="!!document?.archived_file_name" [disabled]="!userCanEdit && !userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"></pngx-share-links-dropdown> | ||||
|  | ||||
|   <div class="ms-auto" ngbDropdown> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle> | ||||
|       <i-bs name="send"></i-bs> | ||||
|       <div class="d-none d-sm-inline"> <ng-container i18n>Send</ng-container></div> | ||||
|     </button> | ||||
|     <div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow"> | ||||
|       <button ngbDropdownItem (click)="openShareLinks()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"> | ||||
|         <i-bs name="link"></i-bs> <span i18n>Share Links</span> | ||||
|       </button> | ||||
|       @if (emailEnabled) { | ||||
|         <button ngbDropdownItem (click)="openEmailDocument()"> | ||||
|           <i-bs name="envelope"></i-bs> <span i18n>Email</span> | ||||
|         </button> | ||||
|       } | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
| </pngx-page-header> | ||||
|  | ||||
| <div class="row"> | ||||
|   | ||||
| @@ -1330,4 +1330,18 @@ describe('DocumentDetailComponent', () => { | ||||
|     expect(createSpy).toHaveBeenCalledWith('a') | ||||
|     expect(urlRevokeSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should get email enabled status from settings', () => { | ||||
|     jest.spyOn(settingsService, 'get').mockReturnValue(true) | ||||
|     expect(component.emailEnabled).toBeTruthy() | ||||
|   }) | ||||
|  | ||||
|   it('should support open share links and email modals', () => { | ||||
|     const modalSpy = jest.spyOn(modalService, 'open') | ||||
|     initNormally() | ||||
|     component.openShareLinks() | ||||
|     expect(modalSpy).toHaveBeenCalled() | ||||
|     component.openEmailDocument() | ||||
|     expect(modalSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -88,6 +88,7 @@ import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspo | ||||
| import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' | ||||
| import { EditDialogMode } from '../common/edit-dialog/edit-dialog.component' | ||||
| import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' | ||||
| import { EmailDocumentDialogComponent } from '../common/email-document-dialog/email-document-dialog.component' | ||||
| import { CheckComponent } from '../common/input/check/check.component' | ||||
| import { DateComponent } from '../common/input/date/date.component' | ||||
| import { DocumentLinkComponent } from '../common/input/document-link/document-link.component' | ||||
| @@ -99,7 +100,7 @@ import { TagsComponent } from '../common/input/tags/tags.component' | ||||
| import { TextComponent } from '../common/input/text/text.component' | ||||
| import { UrlComponent } from '../common/input/url/url.component' | ||||
| import { PageHeaderComponent } from '../common/page-header/page-header.component' | ||||
| import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component' | ||||
| import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component' | ||||
| import { DocumentHistoryComponent } from '../document-history/document-history.component' | ||||
| import { DocumentNotesComponent } from '../document-notes/document-notes.component' | ||||
| import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' | ||||
| @@ -145,7 +146,6 @@ export enum ZoomSetting { | ||||
|     CustomFieldsDropdownComponent, | ||||
|     DocumentNotesComponent, | ||||
|     DocumentHistoryComponent, | ||||
|     ShareLinksDropdownComponent, | ||||
|     CheckComponent, | ||||
|     DateComponent, | ||||
|     DocumentLinkComponent, | ||||
| @@ -1426,6 +1426,26 @@ export class DocumentDetailComponent | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   public openShareLinks() { | ||||
|     const modal = this.modalService.open(ShareLinksDialogComponent) | ||||
|     modal.componentInstance.documentId = this.document.id | ||||
|     modal.componentInstance.hasArchiveVersion = | ||||
|       !!this.document?.archived_file_name | ||||
|   } | ||||
|  | ||||
|   get emailEnabled(): boolean { | ||||
|     return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED) | ||||
|   } | ||||
|  | ||||
|   public openEmailDocument() { | ||||
|     const modal = this.modalService.open(EmailDocumentDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|     }) | ||||
|     modal.componentInstance.documentId = this.document.id | ||||
|     modal.componentInstance.hasArchiveVersion = | ||||
|       !!this.document?.archived_file_name | ||||
|   } | ||||
|  | ||||
|   private tryRenderTiff() { | ||||
|     this.http.get(this.previewUrl, { responseType: 'arraybuffer' }).subscribe({ | ||||
|       next: (res) => { | ||||
|   | ||||
| @@ -1040,6 +1040,27 @@ describe('BulkEditorComponent', () => { | ||||
|       `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` | ||||
|     ) // listAllFilteredIds | ||||
|     expect(documentListViewService.selected.size).toEqual(0) | ||||
|  | ||||
|     // Test with archiveFallback enabled | ||||
|     modal.componentInstance.deleteOriginals = false | ||||
|     modal.componentInstance.archiveFallback = true | ||||
|     modal.componentInstance.confirm() | ||||
|     req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}documents/bulk_edit/` | ||||
|     ) | ||||
|     req.flush(true) | ||||
|     expect(req.request.body).toEqual({ | ||||
|       documents: [3, 4], | ||||
|       method: 'merge', | ||||
|       parameters: { metadata_document_id: 3, archive_fallback: true }, | ||||
|     }) | ||||
|     httpTestingController.match( | ||||
|       `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` | ||||
|     ) // list reload | ||||
|     httpTestingController.match( | ||||
|       `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` | ||||
|     ) // listAllFilteredIds | ||||
|     expect(documentListViewService.selected.size).toEqual(0) | ||||
|   }) | ||||
|  | ||||
|   it('should support bulk download with archive, originals or both and file formatting', () => { | ||||
|   | ||||
| @@ -857,6 +857,9 @@ export class BulkEditorComponent | ||||
|         if (mergeDialog.deleteOriginals) { | ||||
|           args['delete_originals'] = true | ||||
|         } | ||||
|         if (mergeDialog.archiveFallback) { | ||||
|           args['archive_fallback'] = true | ||||
|         } | ||||
|         mergeDialog.buttonsEnabled = false | ||||
|         this.executeBulkOperation(modal, 'merge', args, mergeDialog.documentIDs) | ||||
|         this.toastService.showInfo( | ||||
|   | ||||
| @@ -376,7 +376,7 @@ describe('DocumentListComponent', () => { | ||||
|     expect(documentListService.selected.size).toEqual(3) | ||||
|   }) | ||||
|  | ||||
|   it('should support saving an edited view', () => { | ||||
|   it('should support saving a view', () => { | ||||
|     const view: SavedView = { | ||||
|       id: 10, | ||||
|       name: 'Saved View 10', | ||||
| @@ -414,6 +414,30 @@ describe('DocumentListComponent', () => { | ||||
|     ) | ||||
|   }) | ||||
|  | ||||
|   it('should handle error on view saving', () => { | ||||
|     component.list.activateSavedView({ | ||||
|       id: 10, | ||||
|       name: 'Saved View 10', | ||||
|       sort_field: 'added', | ||||
|       sort_reverse: true, | ||||
|       filter_rules: [ | ||||
|         { | ||||
|           rule_type: FILTER_HAS_TAGS_ANY, | ||||
|           value: '20', | ||||
|         }, | ||||
|       ], | ||||
|     }) | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     jest | ||||
|       .spyOn(savedViewService, 'patch') | ||||
|       .mockReturnValueOnce(throwError(() => new Error('Error saving view'))) | ||||
|     component.saveViewConfig() | ||||
|     expect(toastErrorSpy).toHaveBeenCalledWith( | ||||
|       'Failed to save view "Saved View 10".', | ||||
|       expect.any(Error) | ||||
|     ) | ||||
|   }) | ||||
|  | ||||
|   it('should support edited view saving as', () => { | ||||
|     const view: SavedView = { | ||||
|       id: 10, | ||||
|   | ||||
| @@ -377,12 +377,20 @@ export class DocumentListComponent | ||||
|       this.savedViewService | ||||
|         .patch(savedView) | ||||
|         .pipe(first()) | ||||
|         .subscribe((view) => { | ||||
|           this.unmodifiedSavedView = view | ||||
|           this.toastService.showInfo( | ||||
|             $localize`View "${this.list.activeSavedViewTitle}" saved successfully.` | ||||
|           ) | ||||
|           this.unmodifiedFilterRules = this.list.filterRules | ||||
|         .subscribe({ | ||||
|           next: (view) => { | ||||
|             this.unmodifiedSavedView = view | ||||
|             this.toastService.showInfo( | ||||
|               $localize`View "${this.list.activeSavedViewTitle}" saved successfully.` | ||||
|             ) | ||||
|             this.unmodifiedFilterRules = this.list.filterRules | ||||
|           }, | ||||
|           error: (err) => { | ||||
|             this.toastService.showError( | ||||
|               $localize`Failed to save view "${this.list.activeSavedViewTitle}".`, | ||||
|               err | ||||
|             ) | ||||
|           }, | ||||
|         }) | ||||
|     } | ||||
|   } | ||||
|   | ||||
| @@ -93,6 +93,7 @@ | ||||
|         } | ||||
|         <pngx-dates-dropdown class="flex-fill fade" [class.show]="show" | ||||
|           title="Dates" i18n-title | ||||
|           placement="bottom-end" | ||||
|           (datesSet)="updateRules()" | ||||
|           [(createdDateTo)]="dateCreatedTo" | ||||
|           [(createdDateFrom)]="dateCreatedFrom" | ||||
|   | ||||
| @@ -96,7 +96,10 @@ import { | ||||
| import { environment } from 'src/environments/environment' | ||||
| import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component' | ||||
| import { CustomFieldsQueryDropdownComponent } from '../../common/custom-fields-query-dropdown/custom-fields-query-dropdown.component' | ||||
| import { DatesDropdownComponent } from '../../common/dates-dropdown/dates-dropdown.component' | ||||
| import { | ||||
|   DatesDropdownComponent, | ||||
|   RelativeDate, | ||||
| } from '../../common/dates-dropdown/dates-dropdown.component' | ||||
| import { | ||||
|   FilterableDropdownComponent, | ||||
|   Intersection, | ||||
| @@ -422,7 +425,7 @@ describe('FilterEditorComponent', () => { | ||||
|         value: 'created:[-1 week to now]', | ||||
|       }, | ||||
|     ] | ||||
|     expect(component.dateCreatedRelativeDate).toEqual(0) // RELATIVE_DATE_QUERYSTRINGS['-1 week to now'] | ||||
|     expect(component.dateCreatedRelativeDate).toEqual(1) // RELATIVE_DATE_QUERYSTRINGS['-1 week to now'] | ||||
|     expect(component.textFilter).toBeNull() | ||||
|   })) | ||||
|  | ||||
| @@ -434,7 +437,7 @@ describe('FilterEditorComponent', () => { | ||||
|         value: 'added:[-1 week to now]', | ||||
|       }, | ||||
|     ] | ||||
|     expect(component.dateAddedRelativeDate).toEqual(0) // RELATIVE_DATE_QUERYSTRINGS['-1 week to now'] | ||||
|     expect(component.dateAddedRelativeDate).toEqual(1) // RELATIVE_DATE_QUERYSTRINGS['-1 week to now'] | ||||
|     expect(component.textFilter).toBeNull() | ||||
|   })) | ||||
|  | ||||
| @@ -1587,10 +1590,8 @@ describe('FilterEditorComponent', () => { | ||||
|     const dateCreatedDropdown = fixture.debugElement.queryAll( | ||||
|       By.directive(DatesDropdownComponent) | ||||
|     )[0] | ||||
|     const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll( | ||||
|       By.css('button') | ||||
|     )[1] | ||||
|     dateCreatedBeforeRelativeButton.triggerEventHandler('click') | ||||
|     component.dateCreatedRelativeDate = RelativeDate.WITHIN_1_WEEK | ||||
|     dateCreatedDropdown.triggerEventHandler('datesSet') | ||||
|     fixture.detectChanges() | ||||
|     tick(400) | ||||
|     expect(component.filterRules).toEqual([ | ||||
| @@ -1606,10 +1607,8 @@ describe('FilterEditorComponent', () => { | ||||
|     const dateCreatedDropdown = fixture.debugElement.queryAll( | ||||
|       By.directive(DatesDropdownComponent) | ||||
|     )[0] | ||||
|     const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll( | ||||
|       By.css('button') | ||||
|     )[1] | ||||
|     dateCreatedBeforeRelativeButton.triggerEventHandler('click') | ||||
|     component.dateCreatedRelativeDate = RelativeDate.WITHIN_1_WEEK | ||||
|     dateCreatedDropdown.triggerEventHandler('datesSet') | ||||
|     fixture.detectChanges() | ||||
|     tick(400) | ||||
|     expect(component.filterRules).toEqual([ | ||||
| @@ -1692,16 +1691,14 @@ describe('FilterEditorComponent', () => { | ||||
|     const datesDropdown = fixture.debugElement.query( | ||||
|       By.directive(DatesDropdownComponent) | ||||
|     ) | ||||
|     const dateCreatedBeforeRelativeButton = datesDropdown.queryAll( | ||||
|       By.css('button') | ||||
|     )[1] | ||||
|     dateCreatedBeforeRelativeButton.triggerEventHandler('click') | ||||
|     component.dateAddedRelativeDate = RelativeDate.WITHIN_1_WEEK | ||||
|     datesDropdown.triggerEventHandler('datesSet') | ||||
|     fixture.detectChanges() | ||||
|     tick(400) | ||||
|     expect(component.filterRules).toEqual([ | ||||
|       { | ||||
|         rule_type: FILTER_FULLTEXT_QUERY, | ||||
|         value: 'created:[-1 week to now]', | ||||
|         value: 'added:[-1 week to now]', | ||||
|       }, | ||||
|     ]) | ||||
|   })) | ||||
| @@ -1711,16 +1708,14 @@ describe('FilterEditorComponent', () => { | ||||
|     const datesDropdown = fixture.debugElement.query( | ||||
|       By.directive(DatesDropdownComponent) | ||||
|     ) | ||||
|     const dateCreatedBeforeRelativeButton = datesDropdown.queryAll( | ||||
|       By.css('button') | ||||
|     )[1] | ||||
|     dateCreatedBeforeRelativeButton.triggerEventHandler('click') | ||||
|     component.dateAddedRelativeDate = RelativeDate.WITHIN_1_WEEK | ||||
|     datesDropdown.triggerEventHandler('datesSet') | ||||
|     fixture.detectChanges() | ||||
|     tick(400) | ||||
|     expect(component.filterRules).toEqual([ | ||||
|       { | ||||
|         rule_type: FILTER_FULLTEXT_QUERY, | ||||
|         value: 'foo,created:[-1 week to now]', | ||||
|         value: 'foo,added:[-1 week to now]', | ||||
|       }, | ||||
|     ]) | ||||
|   })) | ||||
|   | ||||
| @@ -135,24 +135,44 @@ const TEXT_FILTER_MODIFIER_NOTNULL = 'not null' | ||||
| const TEXT_FILTER_MODIFIER_GT = 'greater' | ||||
| const TEXT_FILTER_MODIFIER_LT = 'less' | ||||
|  | ||||
| const RELATIVE_DATE_QUERY_REGEXP_CREATED = /created:\[([^\]]+)\]/g | ||||
| const RELATIVE_DATE_QUERY_REGEXP_ADDED = /added:\[([^\]]+)\]/g | ||||
| const RELATIVE_DATE_QUERY_REGEXP_CREATED = /created:[\["]([^\]]+)[\]"]/g | ||||
| const RELATIVE_DATE_QUERY_REGEXP_ADDED = /added:[\["]([^\]]+)[\]"]/g | ||||
| const RELATIVE_DATE_QUERYSTRINGS = [ | ||||
|   { | ||||
|     relativeDate: RelativeDate.WITHIN_1_WEEK, | ||||
|     dateQuery: '-1 week to now', | ||||
|     isRange: true, | ||||
|   }, | ||||
|   { | ||||
|     relativeDate: RelativeDate.WITHIN_1_MONTH, | ||||
|     dateQuery: '-1 month to now', | ||||
|     isRange: true, | ||||
|   }, | ||||
|   { | ||||
|     relativeDate: RelativeDate.WITHIN_3_MONTHS, | ||||
|     dateQuery: '-3 month to now', | ||||
|     isRange: true, | ||||
|   }, | ||||
|   { | ||||
|     relativeDate: RelativeDate.WITHIN_1_YEAR, | ||||
|     dateQuery: '-1 year to now', | ||||
|     isRange: true, | ||||
|   }, | ||||
|   { | ||||
|     relativeDate: RelativeDate.THIS_YEAR, | ||||
|     dateQuery: 'this year', | ||||
|   }, | ||||
|   { | ||||
|     relativeDate: RelativeDate.THIS_MONTH, | ||||
|     dateQuery: 'this month', | ||||
|   }, | ||||
|   { | ||||
|     relativeDate: RelativeDate.TODAY, | ||||
|     dateQuery: 'today', | ||||
|   }, | ||||
|   { | ||||
|     relativeDate: RelativeDate.YESTERDAY, | ||||
|     dateQuery: 'yesterday', | ||||
|   }, | ||||
| ] | ||||
|  | ||||
| @@ -907,12 +927,11 @@ export class FilterEditorComponent | ||||
|  | ||||
|       let existingRuleArgs = existingRule?.value.split(',') | ||||
|       if (this.dateCreatedRelativeDate !== null) { | ||||
|         const rd = RELATIVE_DATE_QUERYSTRINGS.find( | ||||
|           (qS) => qS.relativeDate == this.dateCreatedRelativeDate | ||||
|         ) | ||||
|         queryArgs.push( | ||||
|           `created:[${ | ||||
|             RELATIVE_DATE_QUERYSTRINGS.find( | ||||
|               (qS) => qS.relativeDate == this.dateCreatedRelativeDate | ||||
|             ).dateQuery | ||||
|           }]` | ||||
|           `created:${rd.isRange ? `[${rd.dateQuery}]` : `"${rd.dateQuery}"`}` | ||||
|         ) | ||||
|         if (existingRule) { | ||||
|           queryArgs = existingRuleArgs | ||||
| @@ -921,12 +940,11 @@ export class FilterEditorComponent | ||||
|         } | ||||
|       } | ||||
|       if (this.dateAddedRelativeDate !== null) { | ||||
|         const rd = RELATIVE_DATE_QUERYSTRINGS.find( | ||||
|           (qS) => qS.relativeDate == this.dateAddedRelativeDate | ||||
|         ) | ||||
|         queryArgs.push( | ||||
|           `added:[${ | ||||
|             RELATIVE_DATE_QUERYSTRINGS.find( | ||||
|               (qS) => qS.relativeDate == this.dateAddedRelativeDate | ||||
|             ).dateQuery | ||||
|           }]` | ||||
|           `added:${rd.isRange ? `[${rd.dateQuery}]` : `"${rd.dateQuery}"`}` | ||||
|         ) | ||||
|         if (existingRule) { | ||||
|           queryArgs = existingRuleArgs | ||||
|   | ||||
| @@ -17,7 +17,7 @@ | ||||
|     } | ||||
|   </div> | ||||
|   <div class="modal-footer"> | ||||
|     <button type="button" class="btn btn-outline-dark" (click)="cancel()" i18n [disabled]="!buttonsEnabled">Cancel</button> | ||||
|     <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="!buttonsEnabled">Cancel</button> | ||||
|     <button type="submit" class="btn btn-primary" i18n [disabled]="!buttonsEnabled">Save</button> | ||||
|   </div> | ||||
| </form> | ||||
|   | ||||
| @@ -17,6 +17,7 @@ import { DocumentListViewService } from 'src/app/services/document-list-view.ser | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| @@ -50,7 +51,8 @@ export class CustomFieldsComponent | ||||
|     private toastService: ToastService, | ||||
|     private documentListViewService: DocumentListViewService, | ||||
|     private settingsService: SettingsService, | ||||
|     private documentService: DocumentService | ||||
|     private documentService: DocumentService, | ||||
|     private savedViewService: SavedViewService | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
| @@ -115,6 +117,7 @@ export class CustomFieldsComponent | ||||
|           this.customFieldsService.clearCache() | ||||
|           this.settingsService.initializeDisplayFields() | ||||
|           this.documentService.reload() | ||||
|           this.savedViewService.reload() | ||||
|           this.reload() | ||||
|         }, | ||||
|         error: (e) => { | ||||
|   | ||||
| @@ -10,6 +10,7 @@ export enum PaperlessTaskName { | ||||
|   ConsumeFile = 'consume_file', | ||||
|   TrainClassifier = 'train_classifier', | ||||
|   SanityCheck = 'check_sanity', | ||||
|   IndexOptimize = 'index_optimize', | ||||
| } | ||||
|  | ||||
| export enum PaperlessTaskStatus { | ||||
|   | ||||
| @@ -58,6 +58,8 @@ export interface WorkflowAction extends ObjectWithId { | ||||
|  | ||||
|   assign_custom_fields?: number[] // [CustomField.id] | ||||
|  | ||||
|   assign_custom_fields_values?: object | ||||
|  | ||||
|   remove_tags?: number[] // Tag.id | ||||
|  | ||||
|   remove_all_tags?: boolean | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user