mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Compare commits
	
		
			268 Commits
		
	
	
		
			1002d37f6b
			...
			v2.19.2
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 1d8fadcb3c | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 4e85262781 | ||
|   | 7e5d80fa38 | ||
|   | 3cfd64b77a | ||
|   | 0fc595a16a | ||
|   | 91e2220f23 | ||
|   | 893c05dfdc | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | faf3e8dc0d | ||
|   | 41b9fff407 | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 26f61c900f | ||
|   | 8d0e07e931 | ||
|   | bf9e3fca48 | ||
|   | 144dd8cdf3 | ||
|   | 13161ebb01 | ||
|   | 0ebd9f24b5 | ||
|   | c9f49f390a | ||
|   | 31cee7481b | ||
|   | 78893292f8 | ||
|   | e4ac079cd7 | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 597c2629dd | ||
|   | 264166810c | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a8b42a3c7a | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | af7bac03f1 | ||
|   | 363fd5a97f | ||
|   | fcae006afa | ||
|   | a206ac78dd | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 4456be72c6 | ||
|   | 557612f7a2 | ||
|   | bf18eaa5c2 | ||
|   | d1c11bcf08 | ||
|   | 15e6809a71 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 7326224888 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 04a01fb9f4 | ||
|   | 340754d865 | ||
|   | 39c429bb87 | ||
|   | 8686f264cf | ||
|   | f6c004183e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d394053ddc | ||
|   | a36c28418c | ||
|   | f0d1c75fac | ||
|   | 495159f0b2 | ||
|   | 33fd8a6579 | ||
|   | e08e34fb90 | ||
|   | 6164bac66e | ||
|   | df86882e8e | ||
|   | 79b30fbade | ||
|   | d609b386fe | ||
|   | 502bbb2420 | ||
|   | 27574009e1 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | bd73555ecc | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 613c922dd2 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 1659aa08e4 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 68dfb4a930 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 3c439b970f | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 962f7994d1 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 93eea80f3e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 5bc27eb4b2 | ||
|   | b19701cb96 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9c552bc2d7 | ||
|   | 80fabb0b56 | ||
|   | af1c235af5 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 92ee906701 | ||
|   | d6710de486 | ||
|   | f71b13b82a | ||
|   | 3df43d828a | ||
|   | 643e2b4a8e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 6fa896df39 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 6aeb5a5503 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 86dbeb3a27 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | e97217f267 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 05d5d7e796 | ||
|   | ab7875cc76 | ||
|   | e8957de4a7 | ||
|   | 1717517e70 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | af544177d4 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 766af6a48a | ||
|   | e985051890 | ||
|   | 764ad059d1 | ||
|   | 5e47069934 | ||
|   | 4ff09c4cf4 | ||
|   | 53b393dab5 | ||
|   | 0114993ac6 | ||
|   | 6119c215e7 | ||
|   | 8d1f23e9d6 | ||
|   | c8850fa752 | ||
|   | 19a54b3b23 | ||
|   | 1cdd8d9ba8 | ||
|   | 4449dbadb5 | ||
|   | 43b4f36026 | ||
|   | 0e35acaef5 | ||
|   | 19ff339804 | ||
|   | 6b868a5ecb | ||
|   | 6231211f9b | ||
|   | 6dbd32759d | ||
|   | e0512e35a2 | ||
|   | 4cff907ba0 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 4b32c3228e | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 4ddac79f0f | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d4be3bd31d | ||
|   | d5aba09de9 | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | f2ef9af291 | ||
|   | 4905edbf79 | ||
|   | feb5d534b5 | ||
|   | d230514dd3 | ||
|   | 1709aee903 | ||
|   | 3e4aa87cc5 | ||
|   | fc95d42b35 | ||
|   | c4346124c3 | ||
|   | 44b8c4881a | ||
|   | d3d8eef0b6 | ||
|   | a283c1c320 | ||
|   | f3220ce981 | ||
|   | 2dc4f1f49b | ||
|   | 17509171bb | ||
|   | 9e11e7fd05 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 84942a4e69 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 48168df320 | ||
|   | cec665f8d5 | ||
|   | 8adc26e09d | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 84d85d7a23 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 71f20f62d0 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | a94a8e4c6f | ||
|   | 7a1aae7749 | ||
|   | 894939e492 | ||
|   | f431578f43 | ||
|   | 1b18c14188 | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | d721a88a2f | ||
|   | f7b4d38e39 | ||
|   | 46cf6b4583 | ||
|   | 2d701c5c1b | ||
|   | 1123d845ec | ||
|   | dfa6308ca4 | ||
|   | b5a17a8d11 | ||
|   | cfac74319f | ||
|   | f9f069b092 | ||
|   | b2703b4605 | ||
|   | 852eb0ef36 | ||
|   | 0870d42eae | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | e2cf95f8af | ||
|   | a79c8dc51c | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 4b95c2f0e5 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | e1c8cd779b | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | cc7c7f31ba | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 1d30ce2afa | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 5aa86f8755 | ||
|   | de2ddad5ee | ||
|   | d2064a2535 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | cc621cf729 | ||
|   | fc4134e15c | ||
|   | ac1b420966 | ||
|   | 80595899c1 | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 9463a8fd26 | ||
|   | 58ab137282 | ||
|   | 05c216b2a8 | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | d6db2d3fce | ||
|   | a6e41b4145 | ||
|   | cb927c5b22 | ||
|   | 107374af71 | ||
|   | a77141e133 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 117dfb83fe | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | fdef774a16 | ||
|   | 08887cb8e3 | ||
|   | 7b679e11bc | ||
|   | dbbebaeb89 | ||
|   | d9459ac37f | ||
|   | 4e0f5dff95 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 10ccccc987 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 27d72ebb18 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 909ccebb34 | ||
|   | 4275e18c10 | ||
|   | 0088333360 | ||
|   | ed1d488d6e | ||
|   | b25b15ba32 | ||
|   | f2fabc81d4 | ||
|   | f94c3eeea8 | ||
|   | bf468ac64f | ||
|   | 22064ed004 | ||
|   | 23daa0b974 | ||
|   | 7b63f5a98c | ||
|   | 7c76377477 | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 56c70bf177 | ||
|   | daf47f377b | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 64f31cac0c | ||
|   | dcc503c35f | ||
|   | a583cff21c | ||
|   | bfd468103b | ||
|   | be0c1fd1ed | ||
|   | 82370963da | ||
|   | 0fdfa42a83 | ||
|   | 0f0ba92e15 | ||
|   | 5f0281e427 | ||
|   | a0c7785881 | ||
|   | 349fbce579 | ||
|   | 217b004884 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 29c36542fa | ||
|   | d5b87aeffb | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 9225a38458 | ||
|   | 3fa89b85d7 | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 5e6b49971f | ||
|   | be63c79db1 | ||
|   | 26c70b69c4 | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | e0b0dd8548 | ||
|   | 1bbac9948a | ||
|   | ca9b5d9586 | ||
|   | 521fd1c957 | ||
|   | f00a565130 | ||
|   | d878bc153a | ||
|   | f5e6951910 | ||
|   | 91102d0335 | ||
|   | 82ec1be622 | ||
|   | 01a8cf6f36 | ||
|   | 6bdb365f87 | ||
|   | 696e591a3b | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 3c2782c3a9 | ||
|   | a68800d53c | ||
|   | 52a937cdcc | ||
|   | 00e629d957 | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 243b3bc812 | ||
|   | 0ccc2da9bb | ||
|   | b6dbbec019 | ||
|   | b1c406680f | ||
|   | 42bdbc1b2d | ||
|   | 2f529a9500 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | ee6b700243 | ||
|   | b1a84c65ed | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | edb8c06e2a | ||
|   | 1b6ec65f6e | ||
|   | 6d72ee795f | ||
|   | 6730896894 | ||
|   | 5d6ea70434 | ||
|   | fac1ee4283 | ||
|   | 1bee1495cf | ||
|   | 6dca4daea5 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 54e2b916e6 | ||
|   | ea62e30c90 | ||
|   | 91511b45cd | ||
|   | b5dd751b67 | ||
|   | 07c298523a | ||
|   | 0ea159683d | ||
|   | f0b6e79d14 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 302cb22ec6 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 4210addb46 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 06746b4b31 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 2f5533a179 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 2f267341f8 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 88befee527 | ||
|   | c4a7186cd2 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | d974f092aa | ||
|   | 23501b9060 | ||
|   | f09965464a | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | ae5bd2d2fd | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 2b73007e7e | ||
|   | 8505fa3e54 | ||
|   | a51093afc2 | ||
|   | 2fdae59288 | ||
|   | 4637f5c5e5 | ||
|   | 5e7ee924ff | ||
|   | fded55dc70 | ||
|   | 20da51278e | ||
|   | 293c84d871 | ||
|   | 1fe8599266 | ||
|   | 5410074062 | ||
|   | 4b8f6ed643 | ||
|   | f8689c4819 | ||
|   | cebc227701 | ||
|   | 814df94e8d | ||
|   | fa496dfc8d | ||
|   | 924471b59c | 
| @@ -10,10 +10,8 @@ component_management: | ||||
|       paths: | ||||
|         - src-ui/** | ||||
| # https://docs.codecov.com/docs/pull-request-comments | ||||
| # codecov will only comment if coverage changes | ||||
| comment: | ||||
|   layout: "header, diff, components, flags, files" | ||||
|   require_changes: true | ||||
|   # https://docs.codecov.com/docs/javascript-bundle-analysis | ||||
|   require_bundle_changes: true | ||||
|   bundle_change_threshold: "50Kb" | ||||
|   | ||||
| @@ -1,3 +0,0 @@ | ||||
| [codespell] | ||||
| write-changes = True | ||||
| ignore-words-list = criterias,afterall,valeu,ureue,equest,ure,assertIn | ||||
| @@ -3,7 +3,7 @@ | ||||
|     "dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml", | ||||
|     "service": "paperless-development", | ||||
|     "workspaceFolder": "/usr/src/paperless/paperless-ngx", | ||||
|     "postCreateCommand": "/bin/bash -c 'uv sync --group dev && uv run pre-commit install'", | ||||
|     "postCreateCommand": "/bin/bash -c 'rm -rf .venv/.*  && uv sync --group dev && uv run pre-commit install'", | ||||
|     "customizations": { | ||||
|         "vscode": { | ||||
|           "extensions": [ | ||||
|   | ||||
| @@ -49,7 +49,6 @@ services: | ||||
|       - ./data:/usr/src/paperless/paperless-ngx/data | ||||
|       - ./media:/usr/src/paperless/paperless-ngx/media | ||||
|       - ./consume:/usr/src/paperless/paperless-ngx/consume | ||||
|       - ~/.gitconfig:/usr/src/paperless/.gitconfig:ro | ||||
|     environment: | ||||
|       PAPERLESS_REDIS: redis://broker:6379 | ||||
|       PAPERLESS_TIKA_ENABLED: 1 | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| { | ||||
|     "python.testing.pytestArgs": [ | ||||
|         "src" | ||||
|     ], | ||||
|     "python.testing.pytestArgs": [], | ||||
|     "python.testing.unittestEnabled": false, | ||||
|     "python.testing.pytestEnabled": true, | ||||
|     "files.watcherExclude": { | ||||
|         "**/.venv/**": true, | ||||
|         "**/pytest_cache/**": true | ||||
|     } | ||||
|     }, | ||||
|     "python.testing.cwd": "${workspaceFolder}/src" | ||||
| } | ||||
|   | ||||
							
								
								
									
										107
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										107
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -12,22 +12,64 @@ on: | ||||
|     branches-ignore: | ||||
|       - 'translations**' | ||||
| env: | ||||
|   DEFAULT_UV_VERSION: "0.7.x" | ||||
|   DEFAULT_UV_VERSION: "0.9.x" | ||||
|   # This is the default version of Python to use in most steps which aren't specific | ||||
|   DEFAULT_PYTHON_VERSION: "3.11" | ||||
|   NLTK_DATA: "/usr/share/nltk_data" | ||||
| jobs: | ||||
|   detect-duplicate: | ||||
|     name: Detect Duplicate Run | ||||
|     runs-on: ubuntu-24.04 | ||||
|     outputs: | ||||
|       should_run: ${{ steps.check.outputs.should_run }} | ||||
|     steps: | ||||
|       - name: Check if workflow should run | ||||
|         id: check | ||||
|         uses: actions/github-script@v8 | ||||
|         with: | ||||
|           github-token: ${{ secrets.GITHUB_TOKEN }} | ||||
|           script: | | ||||
|             if (context.eventName !== 'push') { | ||||
|               core.info('Not a push event; running workflow.'); | ||||
|               core.setOutput('should_run', 'true'); | ||||
|               return; | ||||
|             } | ||||
|  | ||||
|             const ref = context.ref || ''; | ||||
|             if (!ref.startsWith('refs/heads/')) { | ||||
|               core.info('Push is not to a branch; running workflow.'); | ||||
|               core.setOutput('should_run', 'true'); | ||||
|               return; | ||||
|             } | ||||
|  | ||||
|             const branch = ref.substring('refs/heads/'.length); | ||||
|             const { owner, repo } = context.repo; | ||||
|             const prs = await github.paginate(github.rest.pulls.list, { | ||||
|               owner, | ||||
|               repo, | ||||
|               state: 'open', | ||||
|               head: `${owner}:${branch}`, | ||||
|               per_page: 100, | ||||
|             }); | ||||
|  | ||||
|             if (prs.length === 0) { | ||||
|               core.info(`No open PR found for ${branch}; running workflow.`); | ||||
|               core.setOutput('should_run', 'true'); | ||||
|             } else { | ||||
|               core.info(`Found ${prs.length} open PR(s) for ${branch}; skipping duplicate push run.`); | ||||
|               core.setOutput('should_run', 'false'); | ||||
|             } | ||||
|   pre-commit: | ||||
|     # We want to run on external PRs, but not on our own internal PRs as they'll be run | ||||
|     # by the push to the branch. Without this if check, checks are duplicated since | ||||
|     # internal PRs match both the push and pull_request events. | ||||
|     if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository | ||||
|     needs: | ||||
|       - detect-duplicate | ||||
|     if: needs.detect-duplicate.outputs.should_run == 'true' | ||||
|     name: Linting Checks | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v4 | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Install python | ||||
|         uses: actions/setup-python@v5 | ||||
|         uses: actions/setup-python@v6 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Check files | ||||
| @@ -39,10 +81,10 @@ jobs: | ||||
|       - pre-commit | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v4 | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         uses: actions/setup-python@v6 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
| @@ -89,14 +131,14 @@ jobs: | ||||
|       fail-fast: false | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v4 | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Start containers | ||||
|         run: | | ||||
|           docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml pull --quiet | ||||
|           docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml up --detach | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         uses: actions/setup-python@v6 | ||||
|         with: | ||||
|           python-version: "${{ matrix.python-version }}" | ||||
|       - name: Install uv | ||||
| @@ -121,8 +163,11 @@ jobs: | ||||
|       - name: List installed Python dependencies | ||||
|         run: | | ||||
|           uv pip list | ||||
|       - name: Install or update NLTK dependencies | ||||
|         run: uv run python -m nltk.downloader punkt punkt_tab snowball_data stopwords -d ${{ env.NLTK_DATA }} | ||||
|       - name: Tests | ||||
|         env: | ||||
|           NLTK_DATA: ${{ env.NLTK_DATA }} | ||||
|           PAPERLESS_CI_TEST: 1 | ||||
|           # Enable paperless_mail testing against real server | ||||
|           PAPERLESS_MAIL_TEST_HOST: ${{ secrets.TEST_MAIL_HOST }} | ||||
| @@ -138,13 +183,11 @@ jobs: | ||||
|         if: always() | ||||
|         uses: codecov/test-results-action@v1 | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: backend-python-${{ matrix.python-version }} | ||||
|           files: junit.xml | ||||
|       - name: Upload backend coverage to Codecov | ||||
|         uses: codecov/codecov-action@v5 | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: backend-python-${{ matrix.python-version }} | ||||
|           files: coverage.xml | ||||
|       - name: Stop containers | ||||
| @@ -158,13 +201,13 @@ jobs: | ||||
|     needs: | ||||
|       - pre-commit | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/checkout@v5 | ||||
|       - name: Install pnpm | ||||
|         uses: pnpm/action-setup@v4 | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v4 | ||||
|         uses: actions/setup-node@v5 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -191,13 +234,13 @@ jobs: | ||||
|         shard-index: [1, 2, 3, 4] | ||||
|         shard-count: [4] | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/checkout@v5 | ||||
|       - name: Install pnpm | ||||
|         uses: pnpm/action-setup@v4 | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v4 | ||||
|         uses: actions/setup-node@v5 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -220,13 +263,11 @@ jobs: | ||||
|         uses: codecov/test-results-action@v1 | ||||
|         if: always() | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: frontend-node-${{ matrix.node-version }} | ||||
|           directory: src-ui/ | ||||
|       - name: Upload frontend coverage to Codecov | ||||
|         uses: codecov/codecov-action@v5 | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: frontend-node-${{ matrix.node-version }} | ||||
|           directory: src-ui/coverage/ | ||||
|   tests-frontend-e2e: | ||||
| @@ -241,13 +282,13 @@ jobs: | ||||
|         shard-index: [1, 2] | ||||
|         shard-count: [2] | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/checkout@v5 | ||||
|       - name: Install pnpm | ||||
|         uses: pnpm/action-setup@v4 | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v4 | ||||
|         uses: actions/setup-node@v5 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -284,13 +325,13 @@ jobs: | ||||
|       - tests-frontend | ||||
|       - tests-frontend-e2e | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/checkout@v5 | ||||
|       - name: Install pnpm | ||||
|         uses: pnpm/action-setup@v4 | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v4 | ||||
|         uses: actions/setup-node@v5 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -312,7 +353,7 @@ jobs: | ||||
|   build-docker-image: | ||||
|     name: Build Docker image for ${{ github.ref_name }} | ||||
|     runs-on: ubuntu-24.04 | ||||
|     if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || startsWith(github.ref, 'refs/heads/fix-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v')) | ||||
|     if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || startsWith(github.ref, 'refs/heads/fix-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/l10n_')) | ||||
|     concurrency: | ||||
|       group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }} | ||||
|       cancel-in-progress: true | ||||
| @@ -359,7 +400,7 @@ jobs: | ||||
|             type=semver,pattern={{version}} | ||||
|             type=semver,pattern={{major}}.{{minor}} | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v4 | ||||
|         uses: actions/checkout@v5 | ||||
|       # If https://github.com/docker/buildx/issues/1044 is resolved, | ||||
|       # the append input with a native arm64 arch could be used to | ||||
|       # significantly speed up building | ||||
| @@ -429,10 +470,10 @@ jobs: | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v4 | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         uses: actions/setup-python@v6 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
| @@ -449,12 +490,12 @@ jobs: | ||||
|           sudo apt-get update -qq | ||||
|           sudo apt-get install -qq --no-install-recommends gettext liblept5 | ||||
|       - name: Download frontend artifact | ||||
|         uses: actions/download-artifact@v4 | ||||
|         uses: actions/download-artifact@v5 | ||||
|         with: | ||||
|           name: frontend-compiled | ||||
|           path: src/documents/static/frontend/ | ||||
|       - name: Download documentation artifact | ||||
|         uses: actions/download-artifact@v4 | ||||
|         uses: actions/download-artifact@v5 | ||||
|         with: | ||||
|           name: documentation | ||||
|           path: docs/_build/html/ | ||||
| @@ -534,7 +575,7 @@ jobs: | ||||
|     if: github.ref_type == 'tag' && (startsWith(github.ref_name, 'v') || contains(github.ref_name, '-beta.rc')) | ||||
|     steps: | ||||
|       - name: Download release artifact | ||||
|         uses: actions/download-artifact@v4 | ||||
|         uses: actions/download-artifact@v5 | ||||
|         with: | ||||
|           name: release | ||||
|           path: ./ | ||||
| @@ -575,12 +616,12 @@ jobs: | ||||
|     if: needs.publish-release.outputs.prerelease == 'false' | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v4 | ||||
|         uses: actions/checkout@v5 | ||||
|         with: | ||||
|           ref: main | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         uses: actions/setup-python@v6 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
| @@ -612,7 +653,7 @@ jobs: | ||||
|           git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA" | ||||
|           git push origin ${{ needs.publish-release.outputs.version }}-changelog | ||||
|       - name: Create Pull Request | ||||
|         uses: actions/github-script@v7 | ||||
|         uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             const { repo, owner } = context.repo; | ||||
|   | ||||
							
								
								
									
										11
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
								
							| @@ -6,10 +6,9 @@ | ||||
| # This workflow will not trigger runs on forked repos. | ||||
| name: Cleanup Image Tags | ||||
| on: | ||||
|   delete: | ||||
|   push: | ||||
|     paths: | ||||
|       - ".github/workflows/cleanup-tags.yml" | ||||
|   workflow_dispatch: | ||||
|   schedule: | ||||
|     - cron: '0 0 * * 0' | ||||
| concurrency: | ||||
|   group: registry-tags-cleanup | ||||
|   cancel-in-progress: false | ||||
| @@ -28,7 +27,7 @@ jobs: | ||||
|     steps: | ||||
|       - name: Clean temporary images | ||||
|         if: "${{ env.TOKEN != '' }}" | ||||
|         uses: stumpylog/image-cleaner-action/ephemeral@v0.10.0 | ||||
|         uses: stumpylog/image-cleaner-action/ephemeral@v0.11.0 | ||||
|         with: | ||||
|           token: "${{ env.TOKEN }}" | ||||
|           owner: "${{ github.repository_owner }}" | ||||
| @@ -54,7 +53,7 @@ jobs: | ||||
|     steps: | ||||
|       - name: Clean untagged images | ||||
|         if: "${{ env.TOKEN != '' }}" | ||||
|         uses: stumpylog/image-cleaner-action/untagged@v0.10.0 | ||||
|         uses: stumpylog/image-cleaner-action/untagged@v0.11.0 | ||||
|         with: | ||||
|           token: "${{ env.TOKEN }}" | ||||
|           owner: "${{ github.repository_owner }}" | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							| @@ -34,7 +34,7 @@ jobs: | ||||
|         # Learn more about CodeQL language support at https://git.io/codeql-language-support | ||||
|     steps: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v4 | ||||
|         uses: actions/checkout@v5 | ||||
|       # Initializes the CodeQL tools for scanning. | ||||
|       - name: Initialize CodeQL | ||||
|         uses: github/codeql-action/init@v3 | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/crowdin.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/crowdin.yml
									
									
									
									
										vendored
									
									
								
							| @@ -13,7 +13,7 @@ jobs: | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v4 | ||||
|         uses: actions/checkout@v5 | ||||
|         with: | ||||
|           token: ${{ secrets.PNGX_BOT_PAT }} | ||||
|       - name: crowdin action | ||||
|   | ||||
							
								
								
									
										10
									
								
								.github/workflows/pr-bot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/pr-bot.yml
									
									
									
									
										vendored
									
									
								
							| @@ -12,7 +12,7 @@ jobs: | ||||
|     steps: | ||||
|       - name: Label PR by file path or branch name | ||||
|         # see .github/labeler.yml for the labeler config | ||||
|         uses: actions/labeler@v5 | ||||
|         uses: actions/labeler@v6 | ||||
|         with: | ||||
|           repo-token: ${{ secrets.GITHUB_TOKEN }} | ||||
|       - name: Label by size | ||||
| @@ -26,7 +26,7 @@ jobs: | ||||
|           fail_if_xl: 'false' | ||||
|           excluded_files: /\.lock$/ /\.txt$/ ^src-ui/pnpm-lock\.yaml$ ^src-ui/messages\.xlf$ ^src/locale/en_US/LC_MESSAGES/django\.po$ | ||||
|       - name: Label by PR title | ||||
|         uses: actions/github-script@v7 | ||||
|         uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             const pr = context.payload.pull_request; | ||||
| @@ -37,7 +37,7 @@ jobs: | ||||
|               labels.push('bug'); | ||||
|             } else if (/^feature/i.test(title)) { | ||||
|               labels.push('enhancement'); | ||||
|             } else if (!/^(dependabot)/i.test(title)) { | ||||
|             } else if (!/^(dependabot)/i.test(title) && !/^(chore)/i.test(title)) { | ||||
|               labels.push('enhancement'); // Default fallback | ||||
|             } | ||||
|  | ||||
| @@ -52,7 +52,7 @@ jobs: | ||||
|             } | ||||
|       - name: Label bot-generated PRs | ||||
|         if: ${{ contains(github.actor, 'dependabot') || contains(github.actor, 'crowdin-bot') }} | ||||
|         uses: actions/github-script@v7 | ||||
|         uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             const pr = context.payload.pull_request; | ||||
| @@ -77,7 +77,7 @@ jobs: | ||||
|             } | ||||
|       - name: Welcome comment | ||||
|         if: ${{ !contains(github.actor, 'bot') }} | ||||
|         uses: actions/github-script@v7 | ||||
|         uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             const pr = context.payload.pull_request; | ||||
|   | ||||
							
								
								
									
										20
									
								
								.github/workflows/repo-maintenance.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										20
									
								
								.github/workflows/repo-maintenance.yml
									
									
									
									
										vendored
									
									
								
							| @@ -15,16 +15,23 @@ jobs: | ||||
|     if: github.repository_owner == 'paperless-ngx' | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - uses: actions/stale@v9 | ||||
|       - uses: actions/stale@v10 | ||||
|         with: | ||||
|           days-before-stale: 7 | ||||
|           days-before-close: 14 | ||||
|           any-of-labels: 'stale,cant-reproduce,not a bug' | ||||
|           any-of-issue-labels: 'cant-reproduce,not a bug' | ||||
|           stale-issue-label: stale | ||||
|           stale-pr-label: stale | ||||
|           stale-issue-message: > | ||||
|             This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details. | ||||
|  | ||||
|           days-before-pr-stale: 14 | ||||
|           days-before-pr-close: 7 | ||||
|           stale-pr-message: "" | ||||
|           stale-pr-label: stale | ||||
|           exempt-pr-labels: 'notable' | ||||
|           close-pr-message: > | ||||
|             This pull request has been automatically closed because it has not had recent activity. Thank you for your contributions. Please open a new pull request or discussion if you would like to continue working on this change. | ||||
|  | ||||
|   lock-threads: | ||||
|     name: 'Lock Old Threads' | ||||
|     if: github.repository_owner == 'paperless-ngx' | ||||
| @@ -50,7 +57,7 @@ jobs: | ||||
|     if: github.repository_owner == 'paperless-ngx' | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - uses: actions/github-script@v7 | ||||
|       - uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             function sleep(ms) { | ||||
| @@ -107,7 +114,7 @@ jobs: | ||||
|     if: github.repository_owner == 'paperless-ngx' | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - uses: actions/github-script@v7 | ||||
|       - uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             function sleep(ms) { | ||||
| @@ -199,7 +206,7 @@ jobs: | ||||
|     if: github.repository_owner == 'paperless-ngx' | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - uses: actions/github-script@v7 | ||||
|       - uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             function sleep(ms) { | ||||
| @@ -234,6 +241,7 @@ jobs: | ||||
|                 ) { | ||||
|                   nodes { | ||||
|                     id, | ||||
|                     createdAt, | ||||
|                     number, | ||||
|                     updatedAt, | ||||
|                     upvoteCount, | ||||
|   | ||||
							
								
								
									
										6
									
								
								.github/workflows/translate-strings.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/translate-strings.yml
									
									
									
									
										vendored
									
									
								
							| @@ -11,13 +11,13 @@ jobs: | ||||
|       contents: write | ||||
|     steps: | ||||
|       - name: Checkout code | ||||
|         uses: actions/checkout@v4 | ||||
|         uses: actions/checkout@v5 | ||||
|         with: | ||||
|           token: ${{ secrets.PNGX_BOT_PAT }} | ||||
|           ref: ${{ github.head_ref }} | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         uses: actions/setup-python@v6 | ||||
|       - name: Install system dependencies | ||||
|         run: | | ||||
|           sudo apt-get update -qq | ||||
| @@ -38,7 +38,7 @@ jobs: | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v4 | ||||
|         uses: actions/setup-node@v5 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
|   | ||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -107,3 +107,6 @@ celerybeat-schedule* | ||||
| /.devcontainer/data/ | ||||
| /.devcontainer/media/ | ||||
| /.devcontainer/redisdata/ | ||||
|  | ||||
| # ignore pnpm package store folder created when setting up the devcontainer | ||||
| .pnpm-store/ | ||||
|   | ||||
| @@ -4,7 +4,7 @@ | ||||
| repos: | ||||
|   # General hooks | ||||
|   - repo: https://github.com/pre-commit/pre-commit-hooks | ||||
|     rev: v5.0.0 | ||||
|     rev: v6.0.0 | ||||
|     hooks: | ||||
|       - id: check-docstring-first | ||||
|       - id: check-json | ||||
| @@ -18,7 +18,7 @@ repos: | ||||
|         exclude_types: | ||||
|           - svg | ||||
|           - pofile | ||||
|         exclude: "(^LICENSE$)" | ||||
|         exclude: "(^LICENSE$|^src/documents/static/bootstrap.min.css$)" | ||||
|       - id: mixed-line-ending | ||||
|         args: | ||||
|           - "--fix=lf" | ||||
| @@ -31,7 +31,7 @@ repos: | ||||
|     rev: v2.4.1 | ||||
|     hooks: | ||||
|       - id: codespell | ||||
|         exclude: "(^src-ui/src/locale/)|(^src-ui/pnpm-lock.yaml)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)" | ||||
|         additional_dependencies: [tomli] | ||||
|         exclude_types: | ||||
|           - pofile | ||||
|           - json | ||||
| @@ -49,17 +49,17 @@ repos: | ||||
|           - 'prettier-plugin-organize-imports@4.1.0' | ||||
|   # Python hooks | ||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||
|     rev: v0.12.2 | ||||
|     rev: v0.14.0 | ||||
|     hooks: | ||||
|       - id: ruff | ||||
|       - id: ruff-check | ||||
|       - id: ruff-format | ||||
|   - repo: https://github.com/tox-dev/pyproject-fmt | ||||
|     rev: "v2.6.0" | ||||
|     rev: "v2.11.0" | ||||
|     hooks: | ||||
|       - id: pyproject-fmt | ||||
|   # Dockerfile hooks | ||||
|   - repo: https://github.com/AleksaC/hadolint-py | ||||
|     rev: v2.12.1b3 | ||||
|     rev: v2.14.0 | ||||
|     hooks: | ||||
|       - id: hadolint | ||||
|   # Shell script hooks | ||||
| @@ -72,11 +72,13 @@ repos: | ||||
|         args: | ||||
|           - "--tab" | ||||
|   - repo: https://github.com/shellcheck-py/shellcheck-py | ||||
|     rev: "v0.10.0.1" | ||||
|     rev: "v0.11.0.1" | ||||
|     hooks: | ||||
|       - id: shellcheck | ||||
|   - repo: https://github.com/google/yamlfmt | ||||
|     rev: v0.17.2 | ||||
|     rev: v0.18.0 | ||||
|     hooks: | ||||
|       - id: yamlfmt | ||||
|         exclude: "^src-ui/pnpm-lock.yaml" | ||||
|         types: | ||||
|           - yaml | ||||
|   | ||||
| @@ -2,9 +2,11 @@ | ||||
|  | ||||
| If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome. | ||||
|  | ||||
| ⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged. | ||||
|  | ||||
| If you want to implement something big: | ||||
|  | ||||
| - Please start a discussion about that in the issues! Maybe something similar is already in development and we can make it happen together. | ||||
| - As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together. | ||||
| - When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project. | ||||
| - Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change. | ||||
| - Please see the [paperless-ngx merge process](#merging-prs) below. | ||||
| @@ -37,6 +39,8 @@ Before you can run `pytest`, ensure to [properly set up your local environment]( | ||||
|  | ||||
| Once you have submitted a **P**ull **R**equest it will be reviewed, approved, and merged by one or more community members of any team. Automated code tests and formatting checks must be passed. | ||||
|  | ||||
| Important: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Instead of opening a PR which does not meet this requirement, please open a feature request instead, to gather feedback from both users and the project maintainers. | ||||
|  | ||||
| ## Non-Trivial Requests | ||||
|  | ||||
| PRs deemed `non-trivial` will go through a stricter review process before being merged into `dev`. This is to ensure code quality and complete functionality (free of side effects). | ||||
| @@ -109,28 +113,12 @@ Paperless-ngx is a community project. We do our best to delegate permission and | ||||
|  | ||||
| ## Structure | ||||
|  | ||||
| As of writing, there are 21 members in paperless-ngx. 4 of these people have complete administrative privileges to the repo: | ||||
| There are currently 2 members in paperless-ngx with complete administrative privileges to the repo: | ||||
|  | ||||
| - [@shamoon](https://github.com/shamoon) | ||||
| - [@bauerj](https://github.com/bauerj) | ||||
| - [@qcasey](https://github.com/qcasey) | ||||
| - [@FrankStrieter](https://github.com/FrankStrieter) | ||||
| - [@stumpylog](https://github.com/stumpylog) | ||||
|  | ||||
| There are 5 teams collaborating on specific tasks within paperless-ngx: | ||||
|  | ||||
| - @paperless-ngx/backend (Python / django) | ||||
| - @paperless-ngx/frontend (JavaScript / Typescript) | ||||
| - @paperless-ngx/ci-cd (GitHub Actions / Deployment) | ||||
| - @paperless-ngx/issues (Issue triage) | ||||
| - @paperless-ngx/test (General testing for larger PRs) | ||||
|  | ||||
| ## Permissions | ||||
|  | ||||
| All team members are notified when mentioned or assigned to a relevant issue or pull request. Additionally, each team has slightly different access to paperless-ngx: | ||||
|  | ||||
| - The **test** team has no special permissions. | ||||
| - The **issues** team has `triage` access. This means they can organize issues and pull requests. | ||||
| - The **backend**, **frontend**, and **ci-cd** teams have `write` access. This means they can approve PRs and push code, containers, releases, and more. | ||||
| There are other members who occasionally contribute but we are actively seeking more dedicated maintainers of the project. Please reach out if you are interested. | ||||
|  | ||||
| ## Joining | ||||
|  | ||||
| @@ -141,13 +129,13 @@ The admins occasionally invite contributors directly if we believe having them o | ||||
| # Automatic Repository Maintenance | ||||
|  | ||||
| The Paperless-ngx team appreciates all effort and interest from the community in filing bug reports, creating feature requests, sharing ideas and helping other | ||||
| community members. That said, in an effort to keep the repository organized and managebale the project uses automatic handling of certain areas: | ||||
| community members. That said, in an effort to keep the repository organized and manageable the project uses automatic handling of certain areas: | ||||
|  | ||||
| - Issues that cannot be reproduced will be marked 'stale' after 7 days of inactivity and closed after 14 further days of inactivity. | ||||
| - Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity. | ||||
| - Discussions with a marked answer will be automatically closed. | ||||
| - Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity. | ||||
| - Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years. | ||||
| - Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity with less than 80 "up-votes", < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 40 "up-votes" at 2 years. | ||||
|  | ||||
| In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns. | ||||
| Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features. | ||||
|   | ||||
| @@ -32,7 +32,7 @@ RUN set -eux \ | ||||
| # Purpose: Installs s6-overlay and rootfs | ||||
| # Comments: | ||||
| #  - Don't leave anything extra in here either | ||||
| FROM ghcr.io/astral-sh/uv:0.7.19-python3.12-bookworm-slim AS s6-overlay-base | ||||
| FROM ghcr.io/astral-sh/uv:0.9.4-python3.12-bookworm-slim AS s6-overlay-base | ||||
|  | ||||
| WORKDIR /usr/src/s6 | ||||
|  | ||||
| @@ -265,4 +265,4 @@ ENTRYPOINT ["/init"] | ||||
|  | ||||
| EXPOSE 8000 | ||||
|  | ||||
| HEALTHCHECK --interval=30s --timeout=10s --retries=5 CMD [ "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000" ] | ||||
| HEALTHCHECK --interval=30s --timeout=10s --retries=5 CMD [ "curl", "-fs", "-S", "-L", "--max-time", "2", "http://localhost:8000" ] | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| # Docker Compose file for running paperless testing with actual gotenberg | ||||
| # Docker Compose file for running paperless testing with actual Gotenberg | ||||
| # and Tika containers for a more end to end test of the Tika related functionality | ||||
| # Can be used locally or by the CI to start the necessary containers with the | ||||
| # correct networking for the tests | ||||
| services: | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:8.20 | ||||
|     image: docker.io/gotenberg/gotenberg:8.24 | ||||
|     hostname: gotenberg | ||||
|     container_name: gotenberg | ||||
|     network_mode: host | ||||
|   | ||||
| @@ -32,6 +32,6 @@ | ||||
| # Note that this is different from PAPERLESS_OCR_LANGUAGE (default=eng), which defines | ||||
| # the language used for OCR. | ||||
| # The container installs English, German, Italian, Spanish and French by default. | ||||
| # See https://packages.debian.org/search?keywords=tesseract-ocr-&searchon=names&suite=buster | ||||
| # See https://packages.debian.org/search?keywords=tesseract-ocr-&searchon=names | ||||
| # for available languages. | ||||
| #PAPERLESS_OCR_LANGUAGES=tur ces | ||||
|   | ||||
| @@ -16,8 +16,8 @@ | ||||
| # - Instead of SQLite (default), MariaDB is used as the database server. | ||||
| # - Apache Tika and Gotenberg servers are started with paperless and paperless | ||||
| #   is configured to use these services. These provide support for consuming | ||||
| #   Office documents (Word, Excel, Power Point and their LibreOffice counter- | ||||
| #   parts. | ||||
| #   Office documents (Word, Excel, PowerPoint and their LibreOffice counter- | ||||
| #   parts). | ||||
| # | ||||
| # To install and update paperless with this file, do the following: | ||||
| # | ||||
| @@ -35,7 +35,7 @@ services: | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|   db: | ||||
|     image: docker.io/library/mariadb:11 | ||||
|     image: docker.io/library/mariadb:12 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - dbdata:/var/lib/mysql | ||||
| @@ -72,7 +72,7 @@ services: | ||||
|       PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 | ||||
|       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:8.20 | ||||
|     image: docker.io/gotenberg/gotenberg:8.24 | ||||
|     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. | ||||
|   | ||||
| @@ -31,7 +31,7 @@ services: | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|   db: | ||||
|     image: docker.io/library/mariadb:11 | ||||
|     image: docker.io/library/mariadb:12 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - dbdata:/var/lib/mysql | ||||
|   | ||||
| @@ -32,10 +32,10 @@ services: | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|   db: | ||||
|     image: docker.io/library/postgres:17 | ||||
|     image: docker.io/library/postgres:18 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - pgdata:/var/lib/postgresql/data | ||||
|       - pgdata:/var/lib/postgresql | ||||
|     environment: | ||||
|       POSTGRES_DB: paperless | ||||
|       POSTGRES_USER: paperless | ||||
|   | ||||
| @@ -16,8 +16,8 @@ | ||||
| # - Instead of SQLite (default), PostgreSQL is used as the database server. | ||||
| # - Apache Tika and Gotenberg servers are started with paperless and paperless | ||||
| #   is configured to use these services. These provide support for consuming | ||||
| #   Office documents (Word, Excel, Power Point and their LibreOffice counter- | ||||
| #   parts. | ||||
| #   Office documents (Word, Excel, PowerPoint and their LibreOffice counter- | ||||
| #   parts). | ||||
| # | ||||
| # To install and update paperless with this file, do the following: | ||||
| # | ||||
| @@ -35,10 +35,10 @@ services: | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|   db: | ||||
|     image: docker.io/library/postgres:17 | ||||
|     image: docker.io/library/postgres:18 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - pgdata:/var/lib/postgresql/data | ||||
|       - pgdata:/var/lib/postgresql | ||||
|     environment: | ||||
|       POSTGRES_DB: paperless | ||||
|       POSTGRES_USER: paperless | ||||
| @@ -66,7 +66,7 @@ services: | ||||
|       PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 | ||||
|       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:8.20 | ||||
|     image: docker.io/gotenberg/gotenberg:8.24 | ||||
|     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. | ||||
|   | ||||
| @@ -31,10 +31,10 @@ services: | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|   db: | ||||
|     image: docker.io/library/postgres:17 | ||||
|     image: docker.io/library/postgres:18 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - pgdata:/var/lib/postgresql/data | ||||
|       - pgdata:/var/lib/postgresql | ||||
|     environment: | ||||
|       POSTGRES_DB: paperless | ||||
|       POSTGRES_USER: paperless | ||||
|   | ||||
| @@ -16,8 +16,8 @@ | ||||
| # | ||||
| # - Apache Tika and Gotenberg servers are started with paperless and paperless | ||||
| #   is configured to use these services. These provide support for consuming | ||||
| #   Office documents (Word, Excel, Power Point and their LibreOffice counter- | ||||
| #   parts. | ||||
| #   Office documents (Word, Excel, PowerPoint and their LibreOffice counter- | ||||
| #   parts). | ||||
| # | ||||
| # To install and update paperless with this file, do the following: | ||||
| # | ||||
| @@ -55,7 +55,7 @@ services: | ||||
|       PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 | ||||
|       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:8.20 | ||||
|     image: docker.io/gotenberg/gotenberg:8.24 | ||||
|     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. | ||||
|   | ||||
| @@ -179,10 +179,14 @@ following: | ||||
|  | ||||
| ### Database Upgrades | ||||
|  | ||||
| In general, paperless does not require a specific version of PostgreSQL or MariaDB and it is | ||||
| Paperless-ngx is compatible with Django-supported versions of PostgreSQL and MariaDB and it is generally | ||||
| safe to update them to newer versions. However, you should always take a backup and follow | ||||
| the instructions from your database's documentation for how to upgrade between major versions. | ||||
|  | ||||
| !!! note | ||||
|  | ||||
|     As of Paperless-ngx v2.18, the minimum supported version of PostgreSQL is 14. | ||||
|  | ||||
| For PostgreSQL, refer to [Upgrading a PostgreSQL Cluster](https://www.postgresql.org/docs/current/upgrading.html). | ||||
|  | ||||
| For MariaDB, refer to [Upgrading MariaDB](https://mariadb.com/kb/en/upgrading/) | ||||
| @@ -306,7 +310,7 @@ in dedicated folders according to their nature: `archive`, `originals`, | ||||
| If `-sm` or `--split-manifest` is provided, information about document | ||||
| will be placed in individual json files, instead of a single JSON file. The main | ||||
| manifest.json will still contain application wide information (e.g. tags, correspondent, | ||||
| documenttype, etc) | ||||
| document type, etc) | ||||
|  | ||||
| If `-z` or `--zip` is provided, the export will be a zip file | ||||
| in the target directory, named according to the current local date or the | ||||
| @@ -467,7 +471,7 @@ Failing to invalidate the cache after such modifications can lead to stale data | ||||
| Use the following management command to clear the cache: | ||||
|  | ||||
| ``` | ||||
| invalidate_cachalot | ||||
| python3 manage.py invalidate_cachalot | ||||
| ``` | ||||
|  | ||||
| !!! info | ||||
|   | ||||
| @@ -434,6 +434,134 @@ provided. The template is provided as a string, potentially multiline, and rende | ||||
| In addition, the entire Document instance is available to be utilized in a more advanced way, as well as some variables which only make sense to be accessed | ||||
| with more complex logic. | ||||
|  | ||||
| #### Custom Jinja2 Filters | ||||
|  | ||||
| ##### Custom Field Access | ||||
|  | ||||
| The `get_cf_value` filter retrieves a value from custom field data with optional default fallback. | ||||
|  | ||||
| ###### Syntax | ||||
|  | ||||
| ```jinja2 | ||||
| {{ custom_fields | get_cf_value('field_name') }} | ||||
| {{ custom_fields | get_cf_value('field_name', 'default_value') }} | ||||
| ``` | ||||
|  | ||||
| ###### Parameters | ||||
|  | ||||
| -   `custom_fields`: This _must_ be the provided custom field data | ||||
| -   `name` (str): Name of the custom field to retrieve | ||||
| -   `default` (str, optional): Default value to return if field is not found or has no value | ||||
|  | ||||
| ###### Returns | ||||
|  | ||||
| -   `str | None`: The field value, default value, or `None` if neither exists | ||||
|  | ||||
| ###### Examples | ||||
|  | ||||
| ```jinja2 | ||||
| <!-- Basic usage --> | ||||
| {{ custom_fields | get_cf_value('department') }} | ||||
|  | ||||
| <!-- With default value --> | ||||
| {{ custom_fields | get_cf_value('phone', 'Not provided') }} | ||||
| ``` | ||||
|  | ||||
| ##### Datetime Formatting | ||||
|  | ||||
| The `datetime` filter formats a datetime string or datetime object using Python's strftime formatting. | ||||
|  | ||||
| ###### Syntax | ||||
|  | ||||
| ```jinja2 | ||||
| {{ datetime_value | datetime('%Y-%m-%d %H:%M:%S') }} | ||||
| ``` | ||||
|  | ||||
| ###### Parameters | ||||
|  | ||||
| -   `value` (str | datetime): Date/time value to format (strings will be parsed automatically) | ||||
| -   `format` (str): Python strftime format string | ||||
|  | ||||
| ###### Returns | ||||
|  | ||||
| -   `str`: Formatted datetime string | ||||
|  | ||||
| ###### Examples | ||||
|  | ||||
| ```jinja2 | ||||
| <!-- Format datetime object --> | ||||
| {{ created | datetime('%B %d, %Y at %I:%M %p') }} | ||||
| <!-- Output: "January 15, 2024 at 02:30 PM" --> | ||||
|  | ||||
| <!-- Custom formatting --> | ||||
| {{ custom_fields | get_cf_value('Date Field') | datetime('%A, %B %d, %Y') }} | ||||
| <!-- Output: "Monday, January 15, 2024" --> | ||||
| ``` | ||||
|  | ||||
| See the [strftime format code documentation](https://docs.python.org/3.13/library/datetime.html#strftime-and-strptime-format-codes) | ||||
| for the possible codes and their meanings. | ||||
|  | ||||
| ##### Date Localization | ||||
|  | ||||
| The `localize_date` filter formats a date or datetime object into a localized string using Babel internationalization. | ||||
| This takes into account the provided locale for translation. Since this must be used on a date or datetime object, | ||||
| you must access the field directly, i.e. `document.created`. | ||||
| An ISO string can also be provided to control the output format. | ||||
|  | ||||
| ###### Syntax | ||||
|  | ||||
| ```jinja2 | ||||
| {{ date_value | localize_date('medium', 'en_US') }} | ||||
| {{ datetime_value | localize_date('short', 'fr_FR') }} | ||||
| ``` | ||||
|  | ||||
| ###### Parameters | ||||
|  | ||||
| -   `value` (date | datetime | str): Date, datetime object or ISO string to format (datetime should be timezone-aware) | ||||
| -   `format` (str): Format type - either a Babel preset ('short', 'medium', 'long', 'full') or custom pattern | ||||
| -   `locale` (str): Locale code for localization (e.g., 'en_US', 'fr_FR', 'de_DE') | ||||
|  | ||||
| ###### Returns | ||||
|  | ||||
| -   `str`: Localized, formatted date string | ||||
|  | ||||
| ###### Examples | ||||
|  | ||||
| ```jinja2 | ||||
| <!-- Preset formats --> | ||||
| {{ document.created | localize_date('short', 'en_US') }} | ||||
| <!-- Output: "1/15/24" --> | ||||
|  | ||||
| {{ document.created | localize_date('medium', 'en_US') }} | ||||
| <!-- Output: "Jan 15, 2024" --> | ||||
|  | ||||
| {{ document.created | localize_date('long', 'en_US') }} | ||||
| <!-- Output: "January 15, 2024" --> | ||||
|  | ||||
| {{ document.created | localize_date('full', 'en_US') }} | ||||
| <!-- Output: "Monday, January 15, 2024" --> | ||||
|  | ||||
| <!-- Different locales --> | ||||
| {{ document.created | localize_date('medium', 'fr_FR') }} | ||||
| <!-- Output: "15 janv. 2024" --> | ||||
|  | ||||
| {{ document.created | localize_date('medium', 'de_DE') }} | ||||
| <!-- Output: "15.01.2024" --> | ||||
|  | ||||
| <!-- Custom patterns --> | ||||
| {{ document.created | localize_date('dd/MM/yyyy', 'en_GB') }} | ||||
| <!-- Output: "15/01/2024" --> | ||||
| ``` | ||||
|  | ||||
| See the [supported format codes](https://unicode.org/reports/tr35/tr35-dates.html#Date_Format_Patterns) for more options. | ||||
|  | ||||
| ### Format Presets | ||||
|  | ||||
| -   **short**: Abbreviated format (e.g., "1/15/24") | ||||
| -   **medium**: Medium-length format (e.g., "Jan 15, 2024") | ||||
| -   **long**: Long format with full month name (e.g., "January 15, 2024") | ||||
| -   **full**: Full format including day of week (e.g., "Monday, January 15, 2024") | ||||
|  | ||||
| #### Additional Variables | ||||
|  | ||||
| -   `{{ tag_name_list }}`: A list of tag names applied to the document, ordered by the tag name. Note this is a list, not a single string | ||||
|   | ||||
							
								
								
									
										16
									
								
								docs/api.md
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								docs/api.md
									
									
									
									
									
								
							| @@ -192,8 +192,8 @@ The endpoint supports the following optional form fields: | ||||
| -   `tags`: Similar to correspondent. Specify this multiple times to | ||||
|     have multiple tags added to the document. | ||||
| -   `archive_serial_number`: An optional archive serial number to set. | ||||
| -   `custom_fields`: An array of custom field ids to assign (with an empty | ||||
|     value) to the document. | ||||
| -   `custom_fields`: Either an array of custom field ids to assign (with an empty | ||||
|     value) to the document or an object mapping field id -> value. | ||||
|  | ||||
| The endpoint will immediately return HTTP 200 if the document consumption | ||||
| process was started successfully, with the UUID of the consumption task | ||||
| @@ -282,6 +282,18 @@ The following methods are supported: | ||||
|         -   `"merge": true or false` (defaults to false) | ||||
|     -   The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including | ||||
|         removing them) or be merged with existing permissions. | ||||
| -   `edit_pdf` | ||||
|     -   Requires `parameters`: | ||||
|         -   `"doc_ids": [DOCUMENT_ID]` A list of a single document ID to edit. | ||||
|         -   `"operations": [OPERATION, ...]` A list of operations to perform on the documents. Each operation is a dictionary | ||||
|             with the following keys: | ||||
|             -   `"page": PAGE_NUMBER` The page number to edit (1-based). | ||||
|             -   `"rotate": DEGREES` Optional rotation in degrees (90, 180, 270). | ||||
|             -   `"doc": OUTPUT_DOCUMENT_INDEX` Optional index of the output document for split operations. | ||||
|     -   Optional `parameters`: | ||||
|         -   `"delete_original": true` to delete the original documents after editing. | ||||
|         -   `"update_document": true` to update the existing document with the edited PDF. | ||||
|         -   `"include_metadata": true` to copy metadata from the original document to the edited document. | ||||
| -   `merge` | ||||
|     -   No additional `parameters` required. | ||||
|     -   The ordering of the merged document is determined by the list of IDs. | ||||
|   | ||||
| @@ -1,5 +1,450 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## paperless-ngx 2.19.1 | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| -   Fix: skip workflow title migration for empty titles [@shamoon](https://github.com/shamoon) ([#11136](https://github.com/paperless-ngx/paperless-ngx/pull/11136)) | ||||
| -   Fix: restore workflow title migration [@shamoon](https://github.com/shamoon) ([#11131](https://github.com/paperless-ngx/paperless-ngx/pull/11131)) | ||||
| -   Fix: retrieve document_count for tag children [@shamoon](https://github.com/shamoon) ([#11125](https://github.com/paperless-ngx/paperless-ngx/pull/11125)) | ||||
| -   Fix: move hierarchical order logic in dropdown sorting [@shamoon](https://github.com/shamoon) ([#11128](https://github.com/paperless-ngx/paperless-ngx/pull/11128)) | ||||
| -   Fix: use original object for children in tag list [@shamoon](https://github.com/shamoon) ([#11127](https://github.com/paperless-ngx/paperless-ngx/pull/11127)) | ||||
| -   Fix: dont display or fetch users or groups with insufficient perms [@shamoon](https://github.com/shamoon) ([#11111](https://github.com/paperless-ngx/paperless-ngx/pull/11111)) | ||||
|  | ||||
| ### All App Changes | ||||
|  | ||||
| <details> | ||||
| <summary>6 changes</summary> | ||||
|  | ||||
| -   Fix: skip workflow title migration for empty titles [@shamoon](https://github.com/shamoon) ([#11136](https://github.com/paperless-ngx/paperless-ngx/pull/11136)) | ||||
| -   Fix: restore workflow title migration [@shamoon](https://github.com/shamoon) ([#11131](https://github.com/paperless-ngx/paperless-ngx/pull/11131)) | ||||
| -   Fix: retrieve document_count for tag children [@shamoon](https://github.com/shamoon) ([#11125](https://github.com/paperless-ngx/paperless-ngx/pull/11125)) | ||||
| -   Fix: move hierarchical order logic in dropdown sorting [@shamoon](https://github.com/shamoon) ([#11128](https://github.com/paperless-ngx/paperless-ngx/pull/11128)) | ||||
| -   Fix: use original object for children in tag list [@shamoon](https://github.com/shamoon) ([#11127](https://github.com/paperless-ngx/paperless-ngx/pull/11127)) | ||||
| -   Fix: dont display or fetch users or groups with insufficient perms [@shamoon](https://github.com/shamoon) ([#11111](https://github.com/paperless-ngx/paperless-ngx/pull/11111)) | ||||
| </details> | ||||
|  | ||||
| ## paperless-ngx 2.19.0 | ||||
|  | ||||
| ### Notable Changes | ||||
|  | ||||
| -   Feature: Advanced Workflow Filters [@shamoon](https://github.com/shamoon) ([#11029](https://github.com/paperless-ngx/paperless-ngx/pull/11029)) | ||||
| -   Feature: Nested Tags [@shamoon](https://github.com/shamoon) ([#10833](https://github.com/paperless-ngx/paperless-ngx/pull/10833)) | ||||
|  | ||||
| ### Features / Enhancements | ||||
|  | ||||
| -   docker(deps): bump astral-sh/uv from 0.9.2-python3.12-bookworm-slim to 0.9.4-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11091](https://github.com/paperless-ngx/paperless-ngx/pull/11091)) | ||||
| -   Enhancement: use friendly file names when emailing documents [@JanKleine](https://github.com/JanKleine) ([#11055](https://github.com/paperless-ngx/paperless-ngx/pull/11055)) | ||||
| -   Feature: Advanced Workflow Filters [@shamoon](https://github.com/shamoon) ([#11029](https://github.com/paperless-ngx/paperless-ngx/pull/11029)) | ||||
| -   docker(deps): Bump astral-sh/uv from 0.8.22-python3.12-bookworm-slim to 0.9.2-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11052](https://github.com/paperless-ngx/paperless-ngx/pull/11052)) | ||||
| -   Feature: add support for emailing multiple documents [@JanKleine](https://github.com/JanKleine) ([#10666](https://github.com/paperless-ngx/paperless-ngx/pull/10666)) | ||||
| -   Enhancement: ignore same files in sanity checker as consumer [@shamoon](https://github.com/shamoon) ([#10999](https://github.com/paperless-ngx/paperless-ngx/pull/10999)) | ||||
| -   Enhancement: open color picker on swatch button click [@shamoon](https://github.com/shamoon) ([#10994](https://github.com/paperless-ngx/paperless-ngx/pull/10994)) | ||||
| -   Performance: Cache django-guardian permissions when counting documents [@Merinorus](https://github.com/Merinorus) ([#10657](https://github.com/paperless-ngx/paperless-ngx/pull/10657)) | ||||
| -   Tweakhancement: reorganize some list \& bulk editing buttons [@shamoon](https://github.com/shamoon) ([#10944](https://github.com/paperless-ngx/paperless-ngx/pull/10944)) | ||||
| -   Enhancement: support workflow path matching of barcode-split documents [@DerRockWolf](https://github.com/DerRockWolf) ([#10723](https://github.com/paperless-ngx/paperless-ngx/pull/10723)) | ||||
| -   Feature: processed mail UI [@shamoon](https://github.com/shamoon) ([#10866](https://github.com/paperless-ngx/paperless-ngx/pull/10866)) | ||||
| -   Enhancement: support custom field values on post document [@shamoon](https://github.com/shamoon) ([#10859](https://github.com/paperless-ngx/paperless-ngx/pull/10859)) | ||||
| -   Feature: Nested Tags [@shamoon](https://github.com/shamoon) ([#10833](https://github.com/paperless-ngx/paperless-ngx/pull/10833)) | ||||
| -   Enhancement: long text custom field [@jojo2357](https://github.com/jojo2357) ([#10846](https://github.com/paperless-ngx/paperless-ngx/pull/10846)) | ||||
| -   Enhancement: Add print button [@mpaletti](https://github.com/mpaletti) ([#10626](https://github.com/paperless-ngx/paperless-ngx/pull/10626)) | ||||
| -   Enhancement: add storage path as workflow trigger filter @david-loe ([#10771](https://github.com/paperless-ngx/paperless-ngx/pull/10771)) | ||||
| -   Enhancement: jinja template support for workflow title assignment [@sidey79](https://github.com/sidey79) ([#10700](https://github.com/paperless-ngx/paperless-ngx/pull/10700)) | ||||
| -   Enhancement: Limit excessively long content length when computing suggestions [@Merinorus](https://github.com/Merinorus) ([#10656](https://github.com/paperless-ngx/paperless-ngx/pull/10656)) | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| -   Fix: remove obsolete warning for custom field value index [@shamoon](https://github.com/shamoon) ([#11083](https://github.com/paperless-ngx/paperless-ngx/pull/11083)) | ||||
| -   Fix: set min-height for drag-drop items container [@shamoon](https://github.com/shamoon) ([#11064](https://github.com/paperless-ngx/paperless-ngx/pull/11064)) | ||||
| -   Fix custom field query dropdown toggle corners [@shamoon](https://github.com/shamoon) ([#11028](https://github.com/paperless-ngx/paperless-ngx/pull/11028)) | ||||
| -   Fix: correct save hotkey action when no next document exists [@shamoon](https://github.com/shamoon) ([#11027](https://github.com/paperless-ngx/paperless-ngx/pull/11027)) | ||||
| -   Fix: require only change permissions for task dismissal, add frontend error handling [@shamoon](https://github.com/shamoon) ([#11023](https://github.com/paperless-ngx/paperless-ngx/pull/11023)) | ||||
| -   Chore(deps): Bulk upgrade backend dependencies [@stumpylog](https://github.com/stumpylog) ([#10971](https://github.com/paperless-ngx/paperless-ngx/pull/10971)) | ||||
| -   Chore: remove Codecov token from CI workflow [@shamoon](https://github.com/shamoon) ([#10941](https://github.com/paperless-ngx/paperless-ngx/pull/10941)) | ||||
| -   Fix: fix select option removal and pagination update [@shamoon](https://github.com/shamoon) ([#10933](https://github.com/paperless-ngx/paperless-ngx/pull/10933)) | ||||
| -   Fix: skip fuzzy matching for empty document content [@shamoon](https://github.com/shamoon) ([#10914](https://github.com/paperless-ngx/paperless-ngx/pull/10914)) | ||||
| -   Fix: add extra error handling to \_consume for file checks [@shamoon](https://github.com/shamoon) ([#10897](https://github.com/paperless-ngx/paperless-ngx/pull/10897)) | ||||
| -   Fix: restore str celery beat schedule filename [@shamoon](https://github.com/shamoon) ([#10893](https://github.com/paperless-ngx/paperless-ngx/pull/10893)) | ||||
| -   Fix: fix pdf editor hover rotate counterclockwise button [@shamoon](https://github.com/shamoon) ([#10848](https://github.com/paperless-ngx/paperless-ngx/pull/10848)) | ||||
| -   Fix: warp long words in toast content [@shamoon](https://github.com/shamoon) ([#10839](https://github.com/paperless-ngx/paperless-ngx/pull/10839)) | ||||
| -   Fix: fix error when bulk adding empty doc link custom fields [@shamoon](https://github.com/shamoon) ([#10832](https://github.com/paperless-ngx/paperless-ngx/pull/10832)) | ||||
| -   Fix: set match value for correspondents created by mail rule [@shamoon](https://github.com/shamoon) ([#10820](https://github.com/paperless-ngx/paperless-ngx/pull/10820)) | ||||
|  | ||||
| ### Maintenance | ||||
|  | ||||
| -   Chore(deps): Bump the actions group with 5 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10978](https://github.com/paperless-ngx/paperless-ngx/pull/10978)) | ||||
| -   Chore: remove Codecov token from CI workflow [@shamoon](https://github.com/shamoon) ([#10941](https://github.com/paperless-ngx/paperless-ngx/pull/10941)) | ||||
|  | ||||
| ### Dependencies | ||||
|  | ||||
| <details> | ||||
| <summary>29 changes</summary> | ||||
|  | ||||
| -   docker(deps): bump astral-sh/uv from 0.9.2-python3.12-bookworm-slim to 0.9.4-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11091](https://github.com/paperless-ngx/paperless-ngx/pull/11091)) | ||||
| -   docker-compose(deps): Bump gotenberg/gotenberg from 8.23 to 8.24 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#11050](https://github.com/paperless-ngx/paperless-ngx/pull/11050)) | ||||
| -   Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11065](https://github.com/paperless-ngx/paperless-ngx/pull/11065)) | ||||
| -   docker(deps): Bump astral-sh/uv from 0.8.22-python3.12-bookworm-slim to 0.9.2-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11052](https://github.com/paperless-ngx/paperless-ngx/pull/11052)) | ||||
| -   Chore(deps): Bump the actions group with 5 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10978](https://github.com/paperless-ngx/paperless-ngx/pull/10978)) | ||||
| -   Chore(deps): Bump uuid from 11.1.0 to 13.0.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10983](https://github.com/paperless-ngx/paperless-ngx/pull/10983)) | ||||
| -   Chore(deps-dev): Bump @playwright/test from 1.55.0 to 1.55.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10982](https://github.com/paperless-ngx/paperless-ngx/pull/10982)) | ||||
| -   Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10981](https://github.com/paperless-ngx/paperless-ngx/pull/10981)) | ||||
| -   Chore(deps-dev): Bump webpack from 5.101.3 to 5.102.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10986](https://github.com/paperless-ngx/paperless-ngx/pull/10986)) | ||||
| -   Chore(deps-dev): Bump prettier-plugin-organize-imports from 4.2.0 to 4.3.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10985](https://github.com/paperless-ngx/paperless-ngx/pull/10985)) | ||||
| -   Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10980](https://github.com/paperless-ngx/paperless-ngx/pull/10980)) | ||||
| -   Chore(deps-dev): Bump @types/node from 24.3.0 to 24.6.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10984](https://github.com/paperless-ngx/paperless-ngx/pull/10984)) | ||||
| -   Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 21 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10979](https://github.com/paperless-ngx/paperless-ngx/pull/10979)) | ||||
| -   docker-compose(deps): Bump library/postgres from 17 to 18 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#10965](https://github.com/paperless-ngx/paperless-ngx/pull/10965)) | ||||
| -   Chore(deps): Bump the major-versions group with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10960](https://github.com/paperless-ngx/paperless-ngx/pull/10960)) | ||||
| -   Chore(deps): Bump types-colorama from 0.4.15.20240311 to 0.4.15.20250801 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10961](https://github.com/paperless-ngx/paperless-ngx/pull/10961)) | ||||
| -   Chore(deps): Bump django-guardian from 3.1.3 to 3.2.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10909](https://github.com/paperless-ngx/paperless-ngx/pull/10909)) | ||||
| -   Chore(deps): Bump django-soft-delete from 1.0.19 to 1.0.21 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10908](https://github.com/paperless-ngx/paperless-ngx/pull/10908)) | ||||
| -   Chore(deps): Bump whitenoise from 6.10.0 to 6.11.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10910](https://github.com/paperless-ngx/paperless-ngx/pull/10910)) | ||||
| -   Chore(deps): Bump django-cors-headers from 4.8.0 to 4.9.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10907](https://github.com/paperless-ngx/paperless-ngx/pull/10907)) | ||||
| -   docker(deps): bump astral-sh/uv from 0.8.17-python3.12-bookworm-slim to 0.8.19-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10906](https://github.com/paperless-ngx/paperless-ngx/pull/10906)) | ||||
| -   docker(deps): Bump astral-sh/uv from 0.8.15-python3.12-bookworm-slim to 0.8.17-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10864](https://github.com/paperless-ngx/paperless-ngx/pull/10864)) | ||||
| -   Chore(deps): Bump the small-changes group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10880](https://github.com/paperless-ngx/paperless-ngx/pull/10880)) | ||||
| -   Chore(deps): Bump django-guardian from 3.1.2 to 3.1.3 in the django group @[dependabot[bot]](https://github.com/apps/dependabot) ([#10863](https://github.com/paperless-ngx/paperless-ngx/pull/10863)) | ||||
| -   Chore(deps): Bump pytest-cov from 6.2.1 to 7.0.0 in the development group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#10822](https://github.com/paperless-ngx/paperless-ngx/pull/10822)) | ||||
| -   Chore(deps): Bump the django group with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10811](https://github.com/paperless-ngx/paperless-ngx/pull/10811)) | ||||
| -   docker-compose(deps): Bump gotenberg/gotenberg from 8.22 to 8.23 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#10812](https://github.com/paperless-ngx/paperless-ngx/pull/10812)) | ||||
| -   Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10821](https://github.com/paperless-ngx/paperless-ngx/pull/10821)) | ||||
| -   docker(deps): Bump astral-sh/uv from 0.8.13-python3.12-bookworm-slim to 0.8.15-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10810](https://github.com/paperless-ngx/paperless-ngx/pull/10810)) | ||||
| </details> | ||||
|  | ||||
| ### All App Changes | ||||
|  | ||||
| <details> | ||||
| <summary>51 changes</summary> | ||||
|  | ||||
| -   Tweak: improve tag parent validation error handling [@shamoon](https://github.com/shamoon) ([#11096](https://github.com/paperless-ngx/paperless-ngx/pull/11096)) | ||||
| -   Fix: remove obsolete warning for custom field value index [@shamoon](https://github.com/shamoon) ([#11083](https://github.com/paperless-ngx/paperless-ngx/pull/11083)) | ||||
| -   Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11065](https://github.com/paperless-ngx/paperless-ngx/pull/11065)) | ||||
| -   Enhancement: use friendly file names when emailing documents [@JanKleine](https://github.com/JanKleine) ([#11055](https://github.com/paperless-ngx/paperless-ngx/pull/11055)) | ||||
| -   Fix: set min-height for drag-drop items container [@shamoon](https://github.com/shamoon) ([#11064](https://github.com/paperless-ngx/paperless-ngx/pull/11064)) | ||||
| -   Feature: Advanced Workflow Filters [@shamoon](https://github.com/shamoon) ([#11029](https://github.com/paperless-ngx/paperless-ngx/pull/11029)) | ||||
| -   Feature: add support for emailing multiple documents [@JanKleine](https://github.com/JanKleine) ([#10666](https://github.com/paperless-ngx/paperless-ngx/pull/10666)) | ||||
| -   Fix custom field query dropdown toggle corners [@shamoon](https://github.com/shamoon) ([#11028](https://github.com/paperless-ngx/paperless-ngx/pull/11028)) | ||||
| -   Fix: correct save hotkey action when no next document exists [@shamoon](https://github.com/shamoon) ([#11027](https://github.com/paperless-ngx/paperless-ngx/pull/11027)) | ||||
| -   Fix: require only change permissions for task dismissal, add frontend error handling [@shamoon](https://github.com/shamoon) ([#11023](https://github.com/paperless-ngx/paperless-ngx/pull/11023)) | ||||
| -   Enhancement: ignore same files in sanity checker as consumer [@shamoon](https://github.com/shamoon) ([#10999](https://github.com/paperless-ngx/paperless-ngx/pull/10999)) | ||||
| -   Enhancement: open color picker on swatch button click [@shamoon](https://github.com/shamoon) ([#10994](https://github.com/paperless-ngx/paperless-ngx/pull/10994)) | ||||
| -   Chore(deps): Bump uuid from 11.1.0 to 13.0.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10983](https://github.com/paperless-ngx/paperless-ngx/pull/10983)) | ||||
| -   Chore(deps-dev): Bump @playwright/test from 1.55.0 to 1.55.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10982](https://github.com/paperless-ngx/paperless-ngx/pull/10982)) | ||||
| -   Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10981](https://github.com/paperless-ngx/paperless-ngx/pull/10981)) | ||||
| -   Chore(deps-dev): Bump webpack from 5.101.3 to 5.102.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10986](https://github.com/paperless-ngx/paperless-ngx/pull/10986)) | ||||
| -   Chore(deps-dev): Bump prettier-plugin-organize-imports from 4.2.0 to 4.3.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10985](https://github.com/paperless-ngx/paperless-ngx/pull/10985)) | ||||
| -   Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10980](https://github.com/paperless-ngx/paperless-ngx/pull/10980)) | ||||
| -   Chore(deps-dev): Bump @types/node from 24.3.0 to 24.6.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10984](https://github.com/paperless-ngx/paperless-ngx/pull/10984)) | ||||
| -   Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 21 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10979](https://github.com/paperless-ngx/paperless-ngx/pull/10979)) | ||||
| -   Performance: Cache django-guardian permissions when counting documents [@Merinorus](https://github.com/Merinorus) ([#10657](https://github.com/paperless-ngx/paperless-ngx/pull/10657)) | ||||
| -   Chore(deps): Bulk upgrade backend dependencies [@stumpylog](https://github.com/stumpylog) ([#10971](https://github.com/paperless-ngx/paperless-ngx/pull/10971)) | ||||
| -   Chore(deps): Bump the major-versions group with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10960](https://github.com/paperless-ngx/paperless-ngx/pull/10960)) | ||||
| -   Chore(deps): Bump types-colorama from 0.4.15.20240311 to 0.4.15.20250801 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10961](https://github.com/paperless-ngx/paperless-ngx/pull/10961)) | ||||
| -   Chore(deps): Bump django-guardian from 3.1.3 to 3.2.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10909](https://github.com/paperless-ngx/paperless-ngx/pull/10909)) | ||||
| -   Chore(deps): Bump django-soft-delete from 1.0.19 to 1.0.21 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10908](https://github.com/paperless-ngx/paperless-ngx/pull/10908)) | ||||
| -   Chore(deps): Bump whitenoise from 6.10.0 to 6.11.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10910](https://github.com/paperless-ngx/paperless-ngx/pull/10910)) | ||||
| -   Tweakhancement: reorganize some list \& bulk editing buttons [@shamoon](https://github.com/shamoon) ([#10944](https://github.com/paperless-ngx/paperless-ngx/pull/10944)) | ||||
| -   Chore(deps): Bump django-cors-headers from 4.8.0 to 4.9.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10907](https://github.com/paperless-ngx/paperless-ngx/pull/10907)) | ||||
| -   Fix: fix select option removal and pagination update [@shamoon](https://github.com/shamoon) ([#10933](https://github.com/paperless-ngx/paperless-ngx/pull/10933)) | ||||
| -   Enhancement: support workflow path matching of barcode-split documents [@DerRockWolf](https://github.com/DerRockWolf) ([#10723](https://github.com/paperless-ngx/paperless-ngx/pull/10723)) | ||||
| -   Fix: skip fuzzy matching for empty document content [@shamoon](https://github.com/shamoon) ([#10914](https://github.com/paperless-ngx/paperless-ngx/pull/10914)) | ||||
| -   Feature: processed mail UI [@shamoon](https://github.com/shamoon) ([#10866](https://github.com/paperless-ngx/paperless-ngx/pull/10866)) | ||||
| -   Fix: add extra error handling to \_consume for file checks [@shamoon](https://github.com/shamoon) ([#10897](https://github.com/paperless-ngx/paperless-ngx/pull/10897)) | ||||
| -   Fix: restore str celery beat schedule filename [@shamoon](https://github.com/shamoon) ([#10893](https://github.com/paperless-ngx/paperless-ngx/pull/10893)) | ||||
| -   Enhancement: support custom field values on post document [@shamoon](https://github.com/shamoon) ([#10859](https://github.com/paperless-ngx/paperless-ngx/pull/10859)) | ||||
| -   Feature: Nested Tags [@shamoon](https://github.com/shamoon) ([#10833](https://github.com/paperless-ngx/paperless-ngx/pull/10833)) | ||||
| -   Chore(deps): Bump the small-changes group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10880](https://github.com/paperless-ngx/paperless-ngx/pull/10880)) | ||||
| -   Chore(deps): Bump django-guardian from 3.1.2 to 3.1.3 in the django group @[dependabot[bot]](https://github.com/apps/dependabot) ([#10863](https://github.com/paperless-ngx/paperless-ngx/pull/10863)) | ||||
| -   Enhancement: long text custom field [@jojo2357](https://github.com/jojo2357) ([#10846](https://github.com/paperless-ngx/paperless-ngx/pull/10846)) | ||||
| -   Fix: fix pdf editor hover rotate counterclockwise button [@shamoon](https://github.com/shamoon) ([#10848](https://github.com/paperless-ngx/paperless-ngx/pull/10848)) | ||||
| -   Fix: warp long words in toast content [@shamoon](https://github.com/shamoon) ([#10839](https://github.com/paperless-ngx/paperless-ngx/pull/10839)) | ||||
| -   Fix: fix error when bulk adding empty doc link custom fields [@shamoon](https://github.com/shamoon) ([#10832](https://github.com/paperless-ngx/paperless-ngx/pull/10832)) | ||||
| -   Enhancement: Add print button [@mpaletti](https://github.com/mpaletti) ([#10626](https://github.com/paperless-ngx/paperless-ngx/pull/10626)) | ||||
| -   Enhancement: add storage path as workflow trigger filter @david-loe ([#10771](https://github.com/paperless-ngx/paperless-ngx/pull/10771)) | ||||
| -   Enhancement: jinja template support for workflow title assignment [@sidey79](https://github.com/sidey79) ([#10700](https://github.com/paperless-ngx/paperless-ngx/pull/10700)) | ||||
| -   Chore(deps): Bump pytest-cov from 6.2.1 to 7.0.0 in the development group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#10822](https://github.com/paperless-ngx/paperless-ngx/pull/10822)) | ||||
| -   Chore(deps): Bump the django group with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10811](https://github.com/paperless-ngx/paperless-ngx/pull/10811)) | ||||
| -   Enhancement: Limit excessively long content length when computing suggestions [@Merinorus](https://github.com/Merinorus) ([#10656](https://github.com/paperless-ngx/paperless-ngx/pull/10656)) | ||||
| -   Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10821](https://github.com/paperless-ngx/paperless-ngx/pull/10821)) | ||||
| -   Fix: set match value for correspondents created by mail rule [@shamoon](https://github.com/shamoon) ([#10820](https://github.com/paperless-ngx/paperless-ngx/pull/10820)) | ||||
| </details> | ||||
|  | ||||
| ## paperless-ngx 2.18.4 | ||||
|  | ||||
| ### Features / Enhancements | ||||
|  | ||||
| -   Enhancement: report websocket status [@shamoon](https://github.com/shamoon) ([#10777](https://github.com/paperless-ngx/paperless-ngx/pull/10777)) | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| -   Revert "Performance: Enable virtual scrolling for large custom field … [@shamoon](https://github.com/shamoon) ([#10803](https://github.com/paperless-ngx/paperless-ngx/pull/10803)) | ||||
| -   Fixhancement: update sidebar view counts on save \& next also [@shamoon](https://github.com/shamoon) ([#10793](https://github.com/paperless-ngx/paperless-ngx/pull/10793)) | ||||
| -   Performance fix: add paging for custom field select options [@shamoon](https://github.com/shamoon) ([#10755](https://github.com/paperless-ngx/paperless-ngx/pull/10755)) | ||||
|  | ||||
| ### Dependencies | ||||
|  | ||||
| <details> | ||||
| <summary>8 changes</summary> | ||||
|  | ||||
| -   Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 2 updates [@shamoon](https://github.com/shamoon) ([#10770](https://github.com/paperless-ngx/paperless-ngx/pull/10770)) | ||||
| -   Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10745](https://github.com/paperless-ngx/paperless-ngx/pull/10745)) | ||||
| -   Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 22 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10744](https://github.com/paperless-ngx/paperless-ngx/pull/10744)) | ||||
| -   Chore(deps): Bump bootstrap from 5.3.7 to 5.3.8 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10740](https://github.com/paperless-ngx/paperless-ngx/pull/10740)) | ||||
| -   Chore(deps-dev): Bump @<!---->playwright/test from 1.54.2 to 1.55.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10743](https://github.com/paperless-ngx/paperless-ngx/pull/10743)) | ||||
| -   Chore(deps-dev): Bump webpack from 5.101.0 to 5.101.3 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10751](https://github.com/paperless-ngx/paperless-ngx/pull/10751)) | ||||
| -   Chore(deps-dev): Bump @<!---->types/node from 24.1.0 to 24.3.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10750](https://github.com/paperless-ngx/paperless-ngx/pull/10750)) | ||||
| -   Chore(deps): Bump the actions group with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10757](https://github.com/paperless-ngx/paperless-ngx/pull/10757)) | ||||
| </details> | ||||
|  | ||||
| ### All App Changes | ||||
|  | ||||
| <details> | ||||
| <summary>13 changes</summary> | ||||
|  | ||||
| -   Revert "Performance: Enable virtual scrolling for large custom field … @shamoon ([#10803](https://github.com/paperless-ngx/paperless-ngx/pull/10803)) | ||||
| -   Fixhancement: update sidebar view counts on save \& next also @shamoon ([#10793](https://github.com/paperless-ngx/paperless-ngx/pull/10793)) | ||||
| -   Enhancement: report websocket status @shamoon ([#10777](https://github.com/paperless-ngx/paperless-ngx/pull/10777)) | ||||
| -   Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 2 updates @shamoon ([#10770](https://github.com/paperless-ngx/paperless-ngx/pull/10770)) | ||||
| -   Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10745](https://github.com/paperless-ngx/paperless-ngx/pull/10745)) | ||||
| -   Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 22 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10744](https://github.com/paperless-ngx/paperless-ngx/pull/10744)) | ||||
| -   Chore(deps): Bump bootstrap from 5.3.7 to 5.3.8 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10740](https://github.com/paperless-ngx/paperless-ngx/pull/10740)) | ||||
| -   Chore(deps-dev): Bump @<!---->playwright/test from 1.54.2 to 1.55.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10743](https://github.com/paperless-ngx/paperless-ngx/pull/10743)) | ||||
| -   Chore(deps-dev): Bump webpack from 5.101.0 to 5.101.3 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10751](https://github.com/paperless-ngx/paperless-ngx/pull/10751)) | ||||
| -   Chore(deps-dev): Bump @<!---->types/node from 24.1.0 to 24.3.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10750](https://github.com/paperless-ngx/paperless-ngx/pull/10750)) | ||||
| -   Chore: switch from os.path to pathlib.Path @gothicVI ([#10539](https://github.com/paperless-ngx/paperless-ngx/pull/10539)) | ||||
| -   Performance fix: add paging for custom field select options @shamoon ([#10755](https://github.com/paperless-ngx/paperless-ngx/pull/10755)) | ||||
| </details> | ||||
|  | ||||
| ## paperless-ngx 2.18.3 | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| -   Fix: include application config language settings for dateparser auto-detection [@shamoon](https://github.com/shamoon) ([#10722](https://github.com/paperless-ngx/paperless-ngx/pull/10722)) | ||||
| -   Fix: hide sidebar counts during saved views organization [@shamoon](https://github.com/shamoon) ([#10716](https://github.com/paperless-ngx/paperless-ngx/pull/10716)) | ||||
| -   Fix: wrap long view titles in sidebar [@shamoon](https://github.com/shamoon) ([#10715](https://github.com/paperless-ngx/paperless-ngx/pull/10715)) | ||||
| -   Fixhancement: more saved view count refreshes [@shamoon](https://github.com/shamoon) ([#10694](https://github.com/paperless-ngx/paperless-ngx/pull/10694)) | ||||
| -   Fix: include pagination array items for valid openapi schema [@shamoon](https://github.com/shamoon) ([#10682](https://github.com/paperless-ngx/paperless-ngx/pull/10682)) | ||||
| -   Fix: prevent scroll for view name in sidebar [@shamoon](https://github.com/shamoon) ([#10676](https://github.com/paperless-ngx/paperless-ngx/pull/10676)) | ||||
| -   Tweak: center document close button in app frame [@shamoon](https://github.com/shamoon) ([#10661](https://github.com/paperless-ngx/paperless-ngx/pull/10661)) | ||||
| -   Performance: Enable virtual scrolling for large custom field selects @david-loe ([#10708](https://github.com/paperless-ngx/paperless-ngx/pull/10708)) | ||||
|  | ||||
| ### Dependencies | ||||
|  | ||||
| <details> | ||||
| <summary>5 changes</summary> | ||||
|  | ||||
| -   Chore(deps): Update granian[uvloop] requirement from ~=2.4.1 to ~=2.5.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10529](https://github.com/paperless-ngx/paperless-ngx/pull/10529)) | ||||
| -   Chore(deps): Bump the small-changes group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10714](https://github.com/paperless-ngx/paperless-ngx/pull/10714)) | ||||
| -   docker-compose(deps): Bump library/mariadb from 11 to 12 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#10621](https://github.com/paperless-ngx/paperless-ngx/pull/10621)) | ||||
| -   docker-compose(deps): Bump gotenberg/gotenberg from 8.20 to 8.22 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#10687](https://github.com/paperless-ngx/paperless-ngx/pull/10687)) | ||||
| -   docker(deps): Bump astral-sh/uv from 0.8.8-python3.12-bookworm-slim to 0.8.13-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10685](https://github.com/paperless-ngx/paperless-ngx/pull/10685)) | ||||
| </details> | ||||
|  | ||||
| ### All App Changes | ||||
|  | ||||
| <details> | ||||
| <summary>11 changes</summary> | ||||
|  | ||||
| -   Fix: include application config language settings for dateparser auto-detection [@shamoon](https://github.com/shamoon) ([#10722](https://github.com/paperless-ngx/paperless-ngx/pull/10722)) | ||||
| -   Chore(deps): Update granian[uvloop] requirement from ~=2.4.1 to ~=2.5.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10529](https://github.com/paperless-ngx/paperless-ngx/pull/10529)) | ||||
| -   Chore(deps): Bump the small-changes group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10714](https://github.com/paperless-ngx/paperless-ngx/pull/10714)) | ||||
| -   Fix: hide sidebar counts during saved views organization [@shamoon](https://github.com/shamoon) ([#10716](https://github.com/paperless-ngx/paperless-ngx/pull/10716)) | ||||
| -   Fix: wrap long view titles in sidebar [@shamoon](https://github.com/shamoon) ([#10715](https://github.com/paperless-ngx/paperless-ngx/pull/10715)) | ||||
| -   Performance: Enable virtual scrolling for large custom field selects @david-loe ([#10708](https://github.com/paperless-ngx/paperless-ngx/pull/10708)) | ||||
| -   Chore: refactor document details component [@shamoon](https://github.com/shamoon) ([#10662](https://github.com/paperless-ngx/paperless-ngx/pull/10662)) | ||||
| -   Fixhancement: more saved view count refreshes [@shamoon](https://github.com/shamoon) ([#10694](https://github.com/paperless-ngx/paperless-ngx/pull/10694)) | ||||
| -   Fix: include pagination array items for valid openapi schema [@shamoon](https://github.com/shamoon) ([#10682](https://github.com/paperless-ngx/paperless-ngx/pull/10682)) | ||||
| -   Fix: prevent scroll for view name in sidebar [@shamoon](https://github.com/shamoon) ([#10676](https://github.com/paperless-ngx/paperless-ngx/pull/10676)) | ||||
| -   Tweak: center document close button in app frame [@shamoon](https://github.com/shamoon) ([#10661](https://github.com/paperless-ngx/paperless-ngx/pull/10661)) | ||||
| </details> | ||||
|  | ||||
| ## paperless-ngx 2.18.2 | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| -   Fix: prevent loss of changes when switching between open docs [@shamoon](https://github.com/shamoon) ([#10659](https://github.com/paperless-ngx/paperless-ngx/pull/10659)) | ||||
| -   Fix: ignore incomplete tasks for system status 'last run' [@shamoon](https://github.com/shamoon) ([#10641](https://github.com/paperless-ngx/paperless-ngx/pull/10641)) | ||||
| -   Fix: increase legibility of date filter clear button in light mode [@shamoon](https://github.com/shamoon) ([#10649](https://github.com/paperless-ngx/paperless-ngx/pull/10649)) | ||||
| -   Fix: ensure saved view count is visible with long names [@shamoon](https://github.com/shamoon) ([#10616](https://github.com/paperless-ngx/paperless-ngx/pull/10616)) | ||||
| -   Tweak: improve dateparser auto-detection messages [@shamoon](https://github.com/shamoon) ([#10640](https://github.com/paperless-ngx/paperless-ngx/pull/10640)) | ||||
|  | ||||
| ### Dependencies | ||||
|  | ||||
| -   Chore(deps): Bump the development group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10578](https://github.com/paperless-ngx/paperless-ngx/pull/10578)) | ||||
|  | ||||
| ### All App Changes | ||||
|  | ||||
| <details> | ||||
| <summary>6 changes</summary> | ||||
|  | ||||
| -   Fix: prevent loss of changes when switching between open docs [@shamoon](https://github.com/shamoon) ([#10659](https://github.com/paperless-ngx/paperless-ngx/pull/10659)) | ||||
| -   Fix: ignore incomplete tasks for system status 'last run' [@shamoon](https://github.com/shamoon) ([#10641](https://github.com/paperless-ngx/paperless-ngx/pull/10641)) | ||||
| -   Tweak: improve dateparser auto-detection messages [@shamoon](https://github.com/shamoon) ([#10640](https://github.com/paperless-ngx/paperless-ngx/pull/10640)) | ||||
| -   Fix: increase legibility of date filter clear button in light mode [@shamoon](https://github.com/shamoon) ([#10649](https://github.com/paperless-ngx/paperless-ngx/pull/10649)) | ||||
| -   Fix: ensure saved view count is visible with long names [@shamoon](https://github.com/shamoon) ([#10616](https://github.com/paperless-ngx/paperless-ngx/pull/10616)) | ||||
| -   Chore(deps): Bump the development group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10578](https://github.com/paperless-ngx/paperless-ngx/pull/10578)) | ||||
| </details> | ||||
|  | ||||
| ## paperless-ngx 2.18.1 | ||||
|  | ||||
| ### Features / Enhancements | ||||
|  | ||||
| -   Tweak: fix some button consistency [@shamoon](https://github.com/shamoon) ([#10593](https://github.com/paperless-ngx/paperless-ngx/pull/10593)) | ||||
| -   Fixhancement: mobile layout improvements for pdf editor [@shamoon](https://github.com/shamoon) ([#10588](https://github.com/paperless-ngx/paperless-ngx/pull/10588)) | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| -   Fix: fix app logo validation with no file [@shamoon](https://github.com/shamoon) ([#10599](https://github.com/paperless-ngx/paperless-ngx/pull/10599)) | ||||
|  | ||||
| ### Documentation | ||||
|  | ||||
| -   Documentation: fix filters docs [@shamoon](https://github.com/shamoon) ([#10600](https://github.com/paperless-ngx/paperless-ngx/pull/10600)) | ||||
|  | ||||
| ### All App Changes | ||||
|  | ||||
| <details> | ||||
| <summary>4 changes</summary> | ||||
|  | ||||
| -   Fix: fix app logo validation with no file [@shamoon](https://github.com/shamoon) ([#10599](https://github.com/paperless-ngx/paperless-ngx/pull/10599)) | ||||
| -   Tweak: fix some button consistency [@shamoon](https://github.com/shamoon) ([#10593](https://github.com/paperless-ngx/paperless-ngx/pull/10593)) | ||||
| -   Development: restore version tag display [@shamoon](https://github.com/shamoon) ([#10592](https://github.com/paperless-ngx/paperless-ngx/pull/10592)) | ||||
| -   Fixhancement: mobile layout improvements for pdf editor [@shamoon](https://github.com/shamoon) ([#10588](https://github.com/paperless-ngx/paperless-ngx/pull/10588)) | ||||
| </details> | ||||
|  | ||||
| ## paperless-ngx 2.18.0 | ||||
|  | ||||
| ### Notable Changes | ||||
|  | ||||
| -   Feature: PDF editor [@shamoon](https://github.com/shamoon) ([#10318](https://github.com/paperless-ngx/paperless-ngx/pull/10318)) | ||||
|  | ||||
| ### Features / Enhancements | ||||
|  | ||||
| -   Feature: Add filter to localize dates for filepath templating [@stumpylog](https://github.com/stumpylog) ([#10559](https://github.com/paperless-ngx/paperless-ngx/pull/10559)) | ||||
| -   Feature: PDF editor [@shamoon](https://github.com/shamoon) ([#10318](https://github.com/paperless-ngx/paperless-ngx/pull/10318)) | ||||
| -   Enhancement: support webhook restrictions [@shamoon](https://github.com/shamoon) ([#10555](https://github.com/paperless-ngx/paperless-ngx/pull/10555)) | ||||
| -   Performance: Classifier performance optimizations [@Merinorus](https://github.com/Merinorus) ([#10363](https://github.com/paperless-ngx/paperless-ngx/pull/10363)) | ||||
| -   Performance: add setting to enable DB connection pooling for PostgreSQL [@Merinorus](https://github.com/Merinorus) ([#10354](https://github.com/paperless-ngx/paperless-ngx/pull/10354)) | ||||
| -   Fixhancement: improve text thumbnail generation for large files [@shamoon](https://github.com/shamoon) ([#10483](https://github.com/paperless-ngx/paperless-ngx/pull/10483)) | ||||
| -   Enhancement: disable auto spellcheck on filtering dropdowns [@TheDodger](https://github.com/TheDodger) ([#10487](https://github.com/paperless-ngx/paperless-ngx/pull/10487)) | ||||
| -   Enhancement: display saved view counts [@shamoon](https://github.com/shamoon) ([#10246](https://github.com/paperless-ngx/paperless-ngx/pull/10246)) | ||||
| -   Fixhancement: add missing exact operator for boolean CF queries [@shamoon](https://github.com/shamoon) ([#10402](https://github.com/paperless-ngx/paperless-ngx/pull/10402)) | ||||
| -   Feature: add Vietnamese translation [@shamoon](https://github.com/shamoon) ([#10352](https://github.com/paperless-ngx/paperless-ngx/pull/10352)) | ||||
| -   Performance: Add support for configuring date parser languages [@Merinorus](https://github.com/Merinorus) ([#10181](https://github.com/paperless-ngx/paperless-ngx/pull/10181)) | ||||
| -   Enhancement: Add a database caching for improved performance [@Merinorus](https://github.com/Merinorus) ([#9784](https://github.com/paperless-ngx/paperless-ngx/pull/9784)) | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| -   Fix: include ignore for config logos in sanity checker [@shamoon](https://github.com/shamoon) ([#10473](https://github.com/paperless-ngx/paperless-ngx/pull/10473)) | ||||
| -   Fix: track and restore changed document fields from session storage [@shamoon](https://github.com/shamoon) ([#10468](https://github.com/paperless-ngx/paperless-ngx/pull/10468)) | ||||
| -   Fix: Make some natural keyword date searches timezone-aware [@shamoon](https://github.com/shamoon) ([#10416](https://github.com/paperless-ngx/paperless-ngx/pull/10416)) | ||||
| -   Fixhancement: follow redirects in curl health check [@V0idC0de](https://github.com/V0idC0de) ([#10415](https://github.com/paperless-ngx/paperless-ngx/pull/10415)) | ||||
| -   Fix: dont use translated verbose_name for getting object perms [@shamoon](https://github.com/shamoon) ([#10399](https://github.com/paperless-ngx/paperless-ngx/pull/10399)) | ||||
| -   Fix: fix date format for 'today' in DateComponent [@shamoon](https://github.com/shamoon) ([#10369](https://github.com/paperless-ngx/paperless-ngx/pull/10369)) | ||||
| -   Fix: default to empty permissions for group creation [@shamoon](https://github.com/shamoon) ([#10337](https://github.com/paperless-ngx/paperless-ngx/pull/10337)) | ||||
| -   Fix: correct api created coercion with timezone [@shamoon](https://github.com/shamoon) ([#10287](https://github.com/paperless-ngx/paperless-ngx/pull/10287)) | ||||
| -   Fix: reset search query for preview on reset filter [@shamoon](https://github.com/shamoon) ([#10279](https://github.com/paperless-ngx/paperless-ngx/pull/10279)) | ||||
| -   Chore: reject absurd max age values [@shamoon](https://github.com/shamoon) ([#10243](https://github.com/paperless-ngx/paperless-ngx/pull/10243)) | ||||
| -   Chore: add tasks task_id param to openapi spec [@shamoon](https://github.com/shamoon) ([#10469](https://github.com/paperless-ngx/paperless-ngx/pull/10469)) | ||||
| -   Chore: include advanced search query param in API spec [@shamoon](https://github.com/shamoon) ([#10449](https://github.com/paperless-ngx/paperless-ngx/pull/10449)) | ||||
|  | ||||
| ### Security | ||||
|  | ||||
| -   Address XSS vulnerability GHSA-6p53-hqqw-8j62 | ||||
|  | ||||
| ### Maintenance | ||||
|  | ||||
| -   docker(deps): Bump astral-sh/uv from 0.8.4-python3.12-bookworm-slim to 0.8.8-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10564](https://github.com/paperless-ngx/paperless-ngx/pull/10564)) | ||||
| -   docker(deps): Bump astral-sh/uv from 0.7.9-python3.12-bookworm-slim to 0.7.19-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10343](https://github.com/paperless-ngx/paperless-ngx/pull/10343)) | ||||
| -   Chore(deps): Bump the small-changes group across 1 directory with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10347](https://github.com/paperless-ngx/paperless-ngx/pull/10347)) | ||||
| -   Chore(deps-dev): Bump @types/node from 22.15.29 to 24.0.10 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10306](https://github.com/paperless-ngx/paperless-ngx/pull/10306)) | ||||
| -   Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10481](https://github.com/paperless-ngx/paperless-ngx/pull/10481)) | ||||
| -   docker(deps): bump astral-sh/uv from 0.7.19-python3.12-bookworm-slim to 0.8.3-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10465](https://github.com/paperless-ngx/paperless-ngx/pull/10465)) | ||||
| -   Chore: switch from os.path to pathlib.Path [@gothicVI](https://github.com/gothicVI) ([#10397](https://github.com/paperless-ngx/paperless-ngx/pull/10397)) | ||||
| -   Chore(deps): Bump the small-changes group with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10528](https://github.com/paperless-ngx/paperless-ngx/pull/10528)) | ||||
| -   Chore(deps): Bump the django group across 1 directory with 9 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10538](https://github.com/paperless-ngx/paperless-ngx/pull/10538)) | ||||
| -   Chore(deps): Bump stefanzweifel/git-auto-commit-action from 5 to 6 in the actions group @[dependabot[bot]](https://github.com/apps/dependabot) ([#10302](https://github.com/paperless-ngx/paperless-ngx/pull/10302)) | ||||
|  | ||||
| ### Dependencies | ||||
|  | ||||
| <details> | ||||
| <summary>23 changes</summary> | ||||
|  | ||||
| -   chore: Small targeted upgrades to dependencies [@stumpylog](https://github.com/stumpylog) ([#10561](https://github.com/paperless-ngx/paperless-ngx/pull/10561)) | ||||
| -   docker(deps): Bump astral-sh/uv from 0.8.4-python3.12-bookworm-slim to 0.8.8-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10564](https://github.com/paperless-ngx/paperless-ngx/pull/10564)) | ||||
| -   Chore(deps): Bump the django group across 1 directory with 9 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10538](https://github.com/paperless-ngx/paperless-ngx/pull/10538)) | ||||
| -   Chore(deps): Bump the small-changes group with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10528](https://github.com/paperless-ngx/paperless-ngx/pull/10528)) | ||||
| -   Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10497](https://github.com/paperless-ngx/paperless-ngx/pull/10497)) | ||||
| -   Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10498](https://github.com/paperless-ngx/paperless-ngx/pull/10498)) | ||||
| -   Chore(deps-dev): Bump @playwright/test from 1.53.2 to 1.54.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10499](https://github.com/paperless-ngx/paperless-ngx/pull/10499)) | ||||
| -   Chore(deps-dev): Bump webpack from 5.99.9 to 5.101.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10501](https://github.com/paperless-ngx/paperless-ngx/pull/10501)) | ||||
| -   Chore(deps-dev): Bump prettier-plugin-organize-imports from 4.1.0 to 4.2.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10500](https://github.com/paperless-ngx/paperless-ngx/pull/10500)) | ||||
| -   Chore(deps-dev): Bump @types/node from 24.0.10 to 24.1.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10502](https://github.com/paperless-ngx/paperless-ngx/pull/10502)) | ||||
| -   Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 16 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10496](https://github.com/paperless-ngx/paperless-ngx/pull/10496)) | ||||
| -   Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10481](https://github.com/paperless-ngx/paperless-ngx/pull/10481)) | ||||
| -   docker(deps): bump astral-sh/uv from 0.7.19-python3.12-bookworm-slim to 0.8.3-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10465](https://github.com/paperless-ngx/paperless-ngx/pull/10465)) | ||||
| -   docker(deps): Bump astral-sh/uv from 0.7.9-python3.12-bookworm-slim to 0.7.19-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10343](https://github.com/paperless-ngx/paperless-ngx/pull/10343)) | ||||
| -   Chore(deps): Bump the small-changes group across 1 directory with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10347](https://github.com/paperless-ngx/paperless-ngx/pull/10347)) | ||||
| -   Chore(deps): Bump stefanzweifel/git-auto-commit-action from 5 to 6 in the actions group @[dependabot[bot]](https://github.com/apps/dependabot) ([#10302](https://github.com/paperless-ngx/paperless-ngx/pull/10302)) | ||||
| -   Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10311](https://github.com/paperless-ngx/paperless-ngx/pull/10311)) | ||||
| -   Chore(deps-dev): Bump @types/node from 22.15.29 to 24.0.10 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10306](https://github.com/paperless-ngx/paperless-ngx/pull/10306)) | ||||
| -   Chore(deps): Bump bootstrap from 5.3.6 to 5.3.7 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10308](https://github.com/paperless-ngx/paperless-ngx/pull/10308)) | ||||
| -   Chore(deps-dev): Bump webpack from 5.98.0 to 5.99.9 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10309](https://github.com/paperless-ngx/paperless-ngx/pull/10309)) | ||||
| -   Chore(deps-dev): Bump @playwright/test from 1.51.1 to 1.53.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10307](https://github.com/paperless-ngx/paperless-ngx/pull/10307)) | ||||
| -   Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 13 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10303](https://github.com/paperless-ngx/paperless-ngx/pull/10303)) | ||||
| -   Chore: update to Angular 20 [@shamoon](https://github.com/shamoon) ([#10273](https://github.com/paperless-ngx/paperless-ngx/pull/10273)) | ||||
| </details> | ||||
|  | ||||
| ### All App Changes | ||||
|  | ||||
| <details> | ||||
| <summary>44 changes</summary> | ||||
|  | ||||
| -   chore: Small targeted upgrades to dependencies [@stumpylog](https://github.com/stumpylog) ([#10561](https://github.com/paperless-ngx/paperless-ngx/pull/10561)) | ||||
| -   Feature: Add filter to localize dates for filepath templating [@stumpylog](https://github.com/stumpylog) ([#10559](https://github.com/paperless-ngx/paperless-ngx/pull/10559)) | ||||
| -   Chore: Removes duplication and spread out config for codespell [@stumpylog](https://github.com/stumpylog) ([#10560](https://github.com/paperless-ngx/paperless-ngx/pull/10560)) | ||||
| -   Chore(deps): Bump the django group across 1 directory with 9 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10538](https://github.com/paperless-ngx/paperless-ngx/pull/10538)) | ||||
| -   Feature: PDF editor [@shamoon](https://github.com/shamoon) ([#10318](https://github.com/paperless-ngx/paperless-ngx/pull/10318)) | ||||
| -   Enhancement: support webhook restrictions [@shamoon](https://github.com/shamoon) ([#10555](https://github.com/paperless-ngx/paperless-ngx/pull/10555)) | ||||
| -   Performance: Classifier performance optimizations [@Merinorus](https://github.com/Merinorus) ([#10363](https://github.com/paperless-ngx/paperless-ngx/pull/10363)) | ||||
| -   Chore: switch from os.path to pathlib.Path [@gothicVI](https://github.com/gothicVI) ([#10397](https://github.com/paperless-ngx/paperless-ngx/pull/10397)) | ||||
| -   Chore(deps): Bump the small-changes group with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10528](https://github.com/paperless-ngx/paperless-ngx/pull/10528)) | ||||
| -   Performance: add setting to enable DB connection pooling for PostgreSQL [@Merinorus](https://github.com/Merinorus) ([#10354](https://github.com/paperless-ngx/paperless-ngx/pull/10354)) | ||||
| -   Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10497](https://github.com/paperless-ngx/paperless-ngx/pull/10497)) | ||||
| -   Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10498](https://github.com/paperless-ngx/paperless-ngx/pull/10498)) | ||||
| -   Chore(deps-dev): Bump @playwright/test from 1.53.2 to 1.54.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10499](https://github.com/paperless-ngx/paperless-ngx/pull/10499)) | ||||
| -   Chore(deps-dev): Bump webpack from 5.99.9 to 5.101.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10501](https://github.com/paperless-ngx/paperless-ngx/pull/10501)) | ||||
| -   Chore(deps-dev): Bump prettier-plugin-organize-imports from 4.1.0 to 4.2.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10500](https://github.com/paperless-ngx/paperless-ngx/pull/10500)) | ||||
| -   Chore(deps-dev): Bump @types/node from 24.0.10 to 24.1.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10502](https://github.com/paperless-ngx/paperless-ngx/pull/10502)) | ||||
| -   Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 16 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10496](https://github.com/paperless-ngx/paperless-ngx/pull/10496)) | ||||
| -   Fixhancement: improve text thumbnail generation for large files [@shamoon](https://github.com/shamoon) ([#10483](https://github.com/paperless-ngx/paperless-ngx/pull/10483)) | ||||
| -   Enhancement: disable auto spellcheck on filtering dropdowns [@TheDodger](https://github.com/TheDodger) ([#10487](https://github.com/paperless-ngx/paperless-ngx/pull/10487)) | ||||
| -   Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10481](https://github.com/paperless-ngx/paperless-ngx/pull/10481)) | ||||
| -   Fix: include ignore for config logos in sanity checker [@shamoon](https://github.com/shamoon) ([#10473](https://github.com/paperless-ngx/paperless-ngx/pull/10473)) | ||||
| -   Chore: add tasks task_id param to openapi spec [@shamoon](https://github.com/shamoon) ([#10469](https://github.com/paperless-ngx/paperless-ngx/pull/10469)) | ||||
| -   Fix: track and restore changed document fields from session storage [@shamoon](https://github.com/shamoon) ([#10468](https://github.com/paperless-ngx/paperless-ngx/pull/10468)) | ||||
| -   Chore: include advanced search query param in API spec [@shamoon](https://github.com/shamoon) ([#10449](https://github.com/paperless-ngx/paperless-ngx/pull/10449)) | ||||
| -   Enhancement: display saved view counts [@shamoon](https://github.com/shamoon) ([#10246](https://github.com/paperless-ngx/paperless-ngx/pull/10246)) | ||||
| -   Fix: Make some natural keyword date searches timezone-aware [@shamoon](https://github.com/shamoon) ([#10416](https://github.com/paperless-ngx/paperless-ngx/pull/10416)) | ||||
| -   Fixhancement: add missing exact operator for boolean CF queries [@shamoon](https://github.com/shamoon) ([#10402](https://github.com/paperless-ngx/paperless-ngx/pull/10402)) | ||||
| -   Fix: dont use translated verbose_name for getting object perms [@shamoon](https://github.com/shamoon) ([#10399](https://github.com/paperless-ngx/paperless-ngx/pull/10399)) | ||||
| -   Fix: fix date format for 'today' in DateComponent [@shamoon](https://github.com/shamoon) ([#10369](https://github.com/paperless-ngx/paperless-ngx/pull/10369)) | ||||
| -   Feature: add Vietnamese translation [@shamoon](https://github.com/shamoon) ([#10352](https://github.com/paperless-ngx/paperless-ngx/pull/10352)) | ||||
| -   Chore(deps): Bump the small-changes group across 1 directory with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10347](https://github.com/paperless-ngx/paperless-ngx/pull/10347)) | ||||
| -   Fix: default to empty permissions for group creation [@shamoon](https://github.com/shamoon) ([#10337](https://github.com/paperless-ngx/paperless-ngx/pull/10337)) | ||||
| -   Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10311](https://github.com/paperless-ngx/paperless-ngx/pull/10311)) | ||||
| -   Chore(deps-dev): Bump @types/node from 22.15.29 to 24.0.10 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10306](https://github.com/paperless-ngx/paperless-ngx/pull/10306)) | ||||
| -   Chore(deps): Bump bootstrap from 5.3.6 to 5.3.7 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10308](https://github.com/paperless-ngx/paperless-ngx/pull/10308)) | ||||
| -   Chore(deps-dev): Bump webpack from 5.98.0 to 5.99.9 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10309](https://github.com/paperless-ngx/paperless-ngx/pull/10309)) | ||||
| -   Chore(deps-dev): Bump @playwright/test from 1.51.1 to 1.53.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10307](https://github.com/paperless-ngx/paperless-ngx/pull/10307)) | ||||
| -   Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 13 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10303](https://github.com/paperless-ngx/paperless-ngx/pull/10303)) | ||||
| -   Performance: Add support for configuring date parser languages [@Merinorus](https://github.com/Merinorus) ([#10181](https://github.com/paperless-ngx/paperless-ngx/pull/10181)) | ||||
| -   Enhancement: Add a database caching for improved performance [@Merinorus](https://github.com/Merinorus) ([#9784](https://github.com/paperless-ngx/paperless-ngx/pull/9784)) | ||||
| -   Fix: correct api created coercion with timezone [@shamoon](https://github.com/shamoon) ([#10287](https://github.com/paperless-ngx/paperless-ngx/pull/10287)) | ||||
| -   Fix: reset search query for preview on reset filter [@shamoon](https://github.com/shamoon) ([#10279](https://github.com/paperless-ngx/paperless-ngx/pull/10279)) | ||||
| -   Chore: update to Angular 20 [@shamoon](https://github.com/shamoon) ([#10273](https://github.com/paperless-ngx/paperless-ngx/pull/10273)) | ||||
| -   Chore: reject absurd max age values [@shamoon](https://github.com/shamoon) ([#10243](https://github.com/paperless-ngx/paperless-ngx/pull/10243)) | ||||
| </details> | ||||
|  | ||||
| ## paperless-ngx 2.17.1 | ||||
|  | ||||
| ### Bug Fixes | ||||
| @@ -5423,9 +5868,6 @@ This release contains new database migrations. | ||||
|     Paperless will continue to work with WSGI, but you will not get any | ||||
|     status notifications. | ||||
|  | ||||
|     Apache `mod_wsgi` users, see | ||||
|     [this note](faq.md#how-do-i-get-websocket-support-with-apache-mod_wsgi). | ||||
|  | ||||
| -   Paperless now offers suggestions for tags, correspondents and types | ||||
|     on the document detail page. | ||||
|  | ||||
| @@ -6227,11 +6669,12 @@ primarily. | ||||
|         who are doing active development on Paperless using the Docker | ||||
|         environment: | ||||
|         [#376](https://github.com/the-paperless-project/paperless/pull/376). | ||||
| -   You now also have the ability to customise the interface to your | ||||
| -   ~~You now also have the ability to customise the interface to your | ||||
|     heart's content by creating a file called `overrides.css` and/or | ||||
|     `overrides.js` in the root of your media directory. Thanks to [Mark | ||||
|     McFate](https://github.com/SummittDweller) for this idea: | ||||
|     [#371](https://github.com/the-paperless-project/paperless/issues/371) | ||||
|     [#371](https://github.com/the-paperless-project/paperless/issues/371)~~ | ||||
|     (Not supported by Paperless-ngx) | ||||
|  | ||||
| ### 2.0.0 | ||||
|  | ||||
|   | ||||
| @@ -159,6 +159,23 @@ Available options are `postgresql` and `mariadb`. | ||||
|  | ||||
|     Defaults to unset, which uses Django’s built-in defaults. | ||||
|  | ||||
| #### [`PAPERLESS_DB_POOLSIZE=<int>`](#PAPERLESS_DB_POOLSIZE) {#PAPERLESS_DB_POOLSIZE} | ||||
|  | ||||
| : Defines the maximum number of database connections to keep in the pool. | ||||
|  | ||||
|     Only applies to PostgreSQL. This setting is ignored for other database engines. | ||||
|  | ||||
|     The value must be greater than or equal to 1 to be used. | ||||
|     Defaults to unset, which disables connection pooling. | ||||
|  | ||||
|     !!! note | ||||
|  | ||||
|         A small pool is typically sufficient — for example, a size of 4. | ||||
|         Make sure your PostgreSQL server's max_connections setting is large enough to handle: | ||||
|         ```(Paperless workers + Celery workers) × pool size + safety margin``` | ||||
|         For example, with 4 Paperless workers and 2 Celery workers, and a pool size of 4: | ||||
|         (4 + 2) × 4 + 10 = 34 connections required. | ||||
|  | ||||
| #### [`PAPERLESS_DB_READ_CACHE_ENABLED=<bool>`](#PAPERLESS_DB_READ_CACHE_ENABLED) {#PAPERLESS_DB_READ_CACHE_ENABLED} | ||||
|  | ||||
| : Caches the database read query results into Redis. This can significantly improve application response times by caching database queries, at the cost of slightly increased memory usage. | ||||
| @@ -167,9 +184,9 @@ Available options are `postgresql` and `mariadb`. | ||||
|  | ||||
|     !!! danger | ||||
|  | ||||
|     **Do not modify the database outside the application while it is running.** | ||||
|     This includes actions such as restoring a backup, upgrading the database, or performing manual inserts. All external modifications must be done **only when the application is stopped**. | ||||
|     After making any such changes, you **must invalidate the DB read cache** using the `invalidate_cachalot` management command. | ||||
|         **Do not modify the database outside the application while it is running.** | ||||
|         This includes actions such as restoring a backup, upgrading the database, or performing manual inserts. All external modifications must be done **only when the application is stopped**. | ||||
|         After making any such changes, you **must invalidate the DB read cache** using the `invalidate_cachalot` management command. | ||||
|  | ||||
| #### [`PAPERLESS_READ_CACHE_TTL=<int>`](#PAPERLESS_READ_CACHE_TTL) {#PAPERLESS_READ_CACHE_TTL} | ||||
|  | ||||
| @@ -179,7 +196,7 @@ Available options are `postgresql` and `mariadb`. | ||||
|  | ||||
|     !!! warning | ||||
|  | ||||
|     A high TTL increases memory usage over time. Memory may be used until end of TTL, even if the cache is invalidated with the `invalidate_cachalot` command. | ||||
|         A high TTL increases memory usage over time. Memory may be used until end of TTL, even if the cache is invalidated with the `invalidate_cachalot` command. | ||||
|  | ||||
| In case of an out-of-memory (OOM) situation, Redis may stop accepting new data — including cache entries, scheduled tasks, and documents to consume. | ||||
| If your system has limited RAM, consider configuring a dedicated Redis instance for the read cache, with a memory limit and the eviction policy set to `allkeys-lru`. | ||||
| @@ -963,21 +980,10 @@ paperless will process in parallel on a single document. | ||||
|         process very large documents faster, use a higher thread per worker | ||||
|         count. | ||||
|  | ||||
|     The default is a balance between the two, according to your CPU core | ||||
|     count, with a slight favor towards threads per worker: | ||||
|  | ||||
|     | CPU core count | Workers | Threads | | ||||
|     | -------------- | ------- | ------- | | ||||
|     | > 1            | > 1     | > 1     | | ||||
|     | > 2            | > 2     | > 1     | | ||||
|     | > 4            | > 2     | > 2     | | ||||
|     | > 6            | > 2     | > 3     | | ||||
|     | > 8            | > 2     | > 4     | | ||||
|     | > 12           | > 3     | > 4     | | ||||
|     | > 16           | > 4     | > 4     | | ||||
|  | ||||
|     If you only specify PAPERLESS_TASK_WORKERS, paperless will adjust | ||||
|     PAPERLESS_THREADS_PER_WORKER automatically. | ||||
|     If unset, paperless uses `max(floor(cpu_count / PAPERLESS_TASK_WORKERS), 1)` | ||||
|     threads per worker. The idea behind this is that as long as there are enough cores, | ||||
|     the total number of threads should less than or equal to the total number of (logical) | ||||
|     CPU cores. | ||||
|  | ||||
| #### [`PAPERLESS_WORKER_TIMEOUT=<num>`](#PAPERLESS_WORKER_TIMEOUT) {#PAPERLESS_WORKER_TIMEOUT} | ||||
|  | ||||
| @@ -1265,6 +1271,30 @@ within your documents. | ||||
|  | ||||
|     Defaults to false. | ||||
|  | ||||
| ## Workflow webhooks | ||||
|  | ||||
| #### [`PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES=<str>`](#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES) {#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES} | ||||
|  | ||||
| : A comma-separated list of allowed schemes for webhooks. This setting | ||||
| controls which URL schemes are permitted for webhook URLs. | ||||
|  | ||||
|     Defaults to `http,https`. | ||||
|  | ||||
| #### [`PAPERLESS_WEBHOOKS_ALLOWED_PORTS=<str>`](#PAPERLESS_WEBHOOKS_ALLOWED_PORTS) {#PAPERLESS_WEBHOOKS_ALLOWED_PORTS} | ||||
|  | ||||
| : A comma-separated list of allowed ports for webhooks. This setting | ||||
| controls which ports are permitted for webhook URLs. For example, if you | ||||
| set this to `80,443`, webhooks will only be sent to URLs that use these | ||||
| ports. | ||||
|  | ||||
|     Defaults to empty list, which allows all ports. | ||||
|  | ||||
| #### [`PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS=<bool>`](#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS) {#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS} | ||||
|  | ||||
| : If set to false, webhooks cannot be sent to internal URLs (e.g., localhost). | ||||
|  | ||||
|     Defaults to true, which allows internal requests. | ||||
|  | ||||
| ### Polling {#polling} | ||||
|  | ||||
| #### [`PAPERLESS_CONSUMER_POLLING=<num>`](#PAPERLESS_CONSUMER_POLLING) {#PAPERLESS_CONSUMER_POLLING} | ||||
| @@ -1718,6 +1748,11 @@ started by the container. | ||||
|  | ||||
| : Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg` | ||||
|  | ||||
| !!! note | ||||
|  | ||||
|     The logo file will be viewable by anyone with access to the Paperless instance login page, | ||||
|     so consider your choice of logo carefully and removing exif data from images before uploading. | ||||
|  | ||||
| #### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK} | ||||
|  | ||||
| !!! note | ||||
|   | ||||
| @@ -95,13 +95,13 @@ first-time setup. | ||||
|  | ||||
| 7.  You can now either ... | ||||
|  | ||||
|     -   install redis or | ||||
|     -   install Redis or | ||||
|  | ||||
|     -   use the included `scripts/start_services.sh` to use docker to fire | ||||
|         up a redis instance (and some other services such as tika, | ||||
|         gotenberg and a database server) or | ||||
|     -   use the included `scripts/start_services.sh` to use Docker to fire | ||||
|         up a Redis instance (and some other services such as Tika, | ||||
|         Gotenberg and a database server) or | ||||
|  | ||||
|     -   spin up a bare redis container | ||||
|     -   spin up a bare Redis container | ||||
|  | ||||
|         ``` | ||||
|         docker run -d -p 6379:6379 --restart unless-stopped redis:latest | ||||
| @@ -147,7 +147,7 @@ $ ng build --configuration production | ||||
| ### Testing | ||||
|  | ||||
| -   Run `pytest` in the `src/` directory to execute all tests. This also | ||||
|     generates a HTML coverage report. When runnings test, `paperless.conf` | ||||
|     generates a HTML coverage report. When running tests, `paperless.conf` | ||||
|     is loaded as well. However, the tests rely on the default | ||||
|     configuration. This is not ideal. But for now, make sure no settings | ||||
|     except for DEBUG are overridden when testing. | ||||
| @@ -470,9 +470,14 @@ To get started: | ||||
|  | ||||
| 2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start. | ||||
|  | ||||
| 3. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This | ||||
| 3. In case your host operating system is Windows: | ||||
|  | ||||
|     - The Source Control view in Visual Studio Code might show: "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user." Use "Manage Unsafe Repositories" to fix this. | ||||
|     - Git might have detecteded modifications for all files, because Windows is using CRLF line endings. Run `git checkout .` in the containers terminal to fix this issue. | ||||
|  | ||||
| 4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This | ||||
|    will initialize the database tables and create a superuser. Then you can compile the front end | ||||
|    for production or run the frontend in debug mode. | ||||
|  | ||||
| 4. The project is ready for debugging, start either run the fullstack debug or individual debug | ||||
| 5. The project is ready for debugging, start either run the fullstack debug or individual debug | ||||
|    processes. Yo spin up the project without debugging run the task **Project Start: Run all Services** | ||||
|   | ||||
| @@ -30,7 +30,7 @@ physical documents into a searchable online archive so you can keep, well, _less | ||||
| -   Utilizes the open-source Tesseract engine to recognize more than 100 languages. | ||||
| -   Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals. | ||||
| -   Uses machine-learning to automatically add tags, correspondents and document types to your documents. | ||||
| -   Supports PDF documents, images, plain text files, Office documents (Word, Excel, Powerpoint, and LibreOffice equivalents)[^1] and more. | ||||
| -   Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more. | ||||
| -   Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents. | ||||
| -   **Beautiful, modern web application** that features: | ||||
|     -   Customizable dashboard with statistics. | ||||
|   | ||||
| @@ -445,7 +445,7 @@ are released, dependency support is confirmed, etc. | ||||
| 13. Configure ImageMagick to allow processing of PDF documents. Most | ||||
|     distributions have this disabled by default, since PDF documents can | ||||
|     contain malware. If you don't do this, paperless will fall back to | ||||
|     ghostscript for certain steps such as thumbnail generation. | ||||
|     Ghostscript for certain steps such as thumbnail generation. | ||||
|  | ||||
|     Edit `/etc/ImageMagick-6/policy.xml` and adjust | ||||
|  | ||||
|   | ||||
| @@ -33,7 +33,7 @@ warns that | ||||
| `OCR for XX failed, but we're going to stick with what we've got since FORGIVING_OCR is enabled`, | ||||
| then you might need to install the [Tesseract language | ||||
| files](https://packages.ubuntu.com/search?keywords=tesseract-ocr) | ||||
| marching your document's languages. | ||||
| matching your document's languages. | ||||
|  | ||||
| As an example, if you are running Paperless-ngx from any Ubuntu or | ||||
| Debian box, and your documents are written in Spanish you may need to | ||||
| @@ -335,7 +335,7 @@ You may see errors when deleting documents like: | ||||
| Data too long for column 'transaction_id' at row 1 | ||||
| ``` | ||||
|  | ||||
| This error can occur in installations which have upgraded from a version of Paperless-ngx that used Django 4 (Paperless-ngx versions prior to v2.13.0) with a MariaDB/MySQL database. Due to the backawards-incompatible change in Django 5, the column "documents_document.transaction_id" will need to be re-created, which can be done with a one-time run of the following management command: | ||||
| This error can occur in installations which have upgraded from a version of Paperless-ngx that used Django 4 (Paperless-ngx versions prior to v2.13.0) with a MariaDB/MySQL database. Due to the backwards-incompatible change in Django 5, the column "documents_document.transaction_id" will need to be re-created, which can be done with a one-time run of the following management command: | ||||
|  | ||||
| ```shell-session | ||||
| $ python3 manage.py convert_mariadb_uuid | ||||
|   | ||||
							
								
								
									
										126
									
								
								docs/usage.md
									
									
									
									
									
								
							
							
						
						
									
										126
									
								
								docs/usage.md
									
									
									
									
									
								
							| @@ -30,6 +30,9 @@ Each document has data fields that you can assign to them: | ||||
| -   A _document type_ is used to demarcate the type of a document such | ||||
|     as letter, bank statement, invoice, contract, etc. It is used to | ||||
|     identify what a document is about. | ||||
| -   The document _storage path_ is the location where the document files | ||||
|     are stored. See [Storage Paths](advanced_usage.md#storage-paths) for | ||||
|     more information. | ||||
| -   The _date added_ of a document is the date the document was scanned | ||||
|     into paperless. You cannot and should not change this date. | ||||
| -   The _date created_ of a document is the date the document was | ||||
| @@ -89,6 +92,16 @@ and more. These areas allow you to view, add, edit, delete and manage permission | ||||
| for these objects. You can also manage saved views, mail accounts, mail rules, | ||||
| workflows and more from the management sections. | ||||
|  | ||||
| ### Nested Tags | ||||
|  | ||||
| Paperless-ngx v2.19 introduces support for nested tags, allowing you to create a | ||||
| hierarchy of tags, which may be useful for organizing your documents. Tags can | ||||
| have a 'parent' tag, creating a tree-like structure, to a maximum depth of 5. When | ||||
| a tag is added to a document, all of its parent tags are also added automatically | ||||
| and similarly, when a tag is removed from a document, all of its child tags are | ||||
| also removed. Additionally, assigning a parent to an existing tag will automatically | ||||
| update all documents that have this tag assigned, adding the parent tag as well. | ||||
|  | ||||
| ## Adding documents to Paperless-ngx | ||||
|  | ||||
| Once you've got Paperless setup, you need to start feeding documents | ||||
| @@ -248,6 +261,10 @@ different means. These are as follows: | ||||
| Paperless is set up to check your mails every 10 minutes. This can be | ||||
| configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON) | ||||
|  | ||||
| #### Processed Mail | ||||
|  | ||||
| Paperless keeps track of emails it has processed in order to avoid processing the same mail multiple times. This uses the message `UID` provided by the mail server, which should be unique for each message. You can view and manage processed mails from the web UI under Mail > Processed Mails. If you need to re-process a message, you can delete the corresponding processed mail entry, which will allow Paperless-ngx to process the email again the next time the mail fetch task runs. | ||||
|  | ||||
| #### OAuth Email Setup | ||||
|  | ||||
| Paperless-ngx supports OAuth2 authentication for Gmail and Outlook email accounts. To set up an email account with OAuth2, you will need to create a 'developer' app with the respective provider and obtain the client ID and client secret and set the appropriate [configuration variables](configuration.md#email_oauth). You will also need to set either [`PAPERLESS_OAUTH_CALLBACK_BASE_URL`](configuration.md#PAPERLESS_OAUTH_CALLBACK_BASE_URL) or [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) to the correct value for the OAuth2 flow to work correctly. | ||||
| @@ -397,7 +414,7 @@ fields and permissions, which will be merged. | ||||
|  | ||||
| #### Types {#workflow-trigger-types} | ||||
|  | ||||
| Currently, there are three events that correspond to workflow trigger 'types': | ||||
| Currently, there are four events that correspond to workflow trigger 'types': | ||||
|  | ||||
| 1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption | ||||
|    folder or API), file path, file name, mail rule | ||||
| @@ -405,12 +422,12 @@ Currently, there are three events that correspond to workflow trigger 'types': | ||||
|    but the document content has been extracted and metadata such as document type, tags, etc. have been set, so these can now | ||||
|    be used for filtering. | ||||
| 3. **Document Updated**: when a document is updated. Similar to 'added' events, triggers can include filtering by content matching, | ||||
|    tags, doc type, or correspondent. | ||||
|    tags, doc type, correspondent or storage path. | ||||
| 4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document | ||||
|    added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive | ||||
|    offsets will trigger after the date, negative offsets will trigger before). | ||||
|  | ||||
| The following flow diagram illustrates the three document trigger types: | ||||
| The following flow diagram illustrates the four document trigger types: | ||||
|  | ||||
| ```mermaid | ||||
| flowchart TD | ||||
| @@ -445,14 +462,24 @@ flowchart TD | ||||
| Workflows allow you to filter by: | ||||
|  | ||||
| -   Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch | ||||
| -   File name, including wildcards e.g. \*.pdf will apply to all pdfs | ||||
| -   File name, including wildcards e.g. \*.pdf will apply to all pdfs. | ||||
| -   File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for | ||||
|     example, automatically assigning documents to different owners based on the upload directory. | ||||
| -   Mail rule. Choosing this option will force 'mail fetch' to be the workflow source. | ||||
| -   Content matching (`Added` and `Updated` triggers only). Filter document content using the matching settings. | ||||
| -   Tags (`Added` and `Updated` triggers only). Filter for documents with any of the specified tags | ||||
| -   Document type (`Added` and `Updated` triggers only). Filter documents with this doc type | ||||
| -   Correspondent (`Added` and `Updated` triggers only). Filter documents with this correspondent | ||||
| -   Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings. | ||||
|  | ||||
| There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers: | ||||
|  | ||||
| -   Any Tags: Filter for documents with any of the specified tags. | ||||
| -   All Tags: Filter for documents with all of the specified tags. | ||||
| -   No Tags: Filter for documents with none of the specified tags. | ||||
| -   Document type: Filter documents with this document type. | ||||
| -   Not Document types: Filter documents without any of these document types. | ||||
| -   Correspondent: Filter documents with this correspondent. | ||||
| -   Not Correspondents: Filter documents without any of these correspondents. | ||||
| -   Storage path: Filter documents with this storage path. | ||||
| -   Not Storage paths: Filter documents without any of these storage paths. | ||||
| -   Custom field query: Filter documents with a custom field query (the same as used for the document list filters). | ||||
|  | ||||
| ### Workflow Actions | ||||
|  | ||||
| @@ -496,37 +523,58 @@ The following workflow action types are available: | ||||
| -   Encoding for the request body, either JSON or form data | ||||
| -   The request headers as key-value pairs | ||||
|  | ||||
| For security reasons, webhooks can be limited to specific ports and disallowed from connecting to local URLs. See the relevant | ||||
| [configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows, | ||||
| you may want to adjust these settings to prevent abuse. | ||||
|  | ||||
| #### Workflow placeholders | ||||
|  | ||||
| Some workflow text can include placeholders but the available options differ depending on the type of | ||||
| workflow trigger. This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been | ||||
| applied. You can use the following placeholders with any trigger type: | ||||
| Titles can be assigned by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/). | ||||
| This allows for complex logic to be used to generate the title, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures) | ||||
| and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11). | ||||
| The template is provided as a string. | ||||
|  | ||||
| -   `{correspondent}`: assigned correspondent name | ||||
| -   `{document_type}`: assigned document type name | ||||
| -   `{owner_username}`: assigned owner username | ||||
| -   `{added}`: added datetime | ||||
| -   `{added_year}`: added year | ||||
| -   `{added_year_short}`: added year | ||||
| -   `{added_month}`: added month | ||||
| -   `{added_month_name}`: added month name | ||||
| -   `{added_month_name_short}`: added month short name | ||||
| -   `{added_day}`: added day | ||||
| -   `{added_time}`: added time in HH:MM format | ||||
| -   `{original_filename}`: original file name without extension | ||||
| -   `{filename}`: current file name without extension | ||||
| Using Jinja2 Templates is also useful for [Date localization](advanced_usage.md#Date-Localization) in the title. | ||||
|  | ||||
| The available inputs differ depending on the type of workflow trigger. | ||||
| This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been | ||||
| applied. You can use the following placeholders in the template with any trigger type: | ||||
|  | ||||
| -   `{{correspondent}}`: assigned correspondent name | ||||
| -   `{{document_type}}`: assigned document type name | ||||
| -   `{{owner_username}}`: assigned owner username | ||||
| -   `{{added}}`: added datetime | ||||
| -   `{{added_year}}`: added year | ||||
| -   `{{added_year_short}}`: added year | ||||
| -   `{{added_month}}`: added month | ||||
| -   `{{added_month_name}}`: added month name | ||||
| -   `{{added_month_name_short}}`: added month short name | ||||
| -   `{{added_day}}`: added day | ||||
| -   `{{added_time}}`: added time in HH:MM format | ||||
| -   `{{original_filename}}`: original file name without extension | ||||
| -   `{{filename}}`: current file name without extension | ||||
|  | ||||
| The following placeholders are only available for "added" or "updated" triggers | ||||
|  | ||||
| -   `{created}`: created datetime | ||||
| -   `{created_year}`: created year | ||||
| -   `{created_year_short}`: created year | ||||
| -   `{created_month}`: created month | ||||
| -   `{created_month_name}`: created month name | ||||
| -   `{created_month_name_short}`: created month short name | ||||
| -   `{created_day}`: created day | ||||
| -   `{created_time}`: created time in HH:MM format | ||||
| -   `{doc_url}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set. | ||||
| -   `{{created}}`: created datetime | ||||
| -   `{{created_year}}`: created year | ||||
| -   `{{created_year_short}}`: created year | ||||
| -   `{{created_month}}`: created month | ||||
| -   `{{created_month_name}}`: created month name | ||||
| -   `{created_month_name_short}}`: created month short name | ||||
| -   `{{created_day}}`: created day | ||||
| -   `{{created_time}}`: created time in HH:MM format | ||||
| -   `{{doc_url}}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set. | ||||
|  | ||||
| ##### Examples | ||||
|  | ||||
| ```jinja2 | ||||
| {{ created | localize_date('MMMM', 'en_US') }} | ||||
| <!-- Output: "January" --> | ||||
|  | ||||
| {{ added | localize_date('MMMM', 'de_DE') }} | ||||
| <!-- Output: "Juni" --> # codespell:ignore | ||||
| ``` | ||||
|  | ||||
| ### Workflow permissions | ||||
|  | ||||
| @@ -573,12 +621,14 @@ The following custom field types are supported: | ||||
|  | ||||
| ## PDF Actions | ||||
|  | ||||
| Paperless-ngx supports four basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files): | ||||
| Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can | ||||
| open the 'PDF Editor' to use a simple UI for re-arranging, rotating, deleting pages and splitting documents. | ||||
|  | ||||
| -   Merging documents: available when selecting multiple documents for 'bulk editing'. | ||||
| -   Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page. | ||||
| -   Splitting documents: available from an individual document's details page. | ||||
| -   Deleting pages: available from an individual document's details page. | ||||
| -   Rotating documents: available when selecting multiple documents for 'bulk editing' and via the pdf editor on an individual document's details page. | ||||
| -   Splitting documents: via the pdf editor on an individual document's details page. | ||||
| -   Deleting pages: via the pdf editor on an individual document's details page. | ||||
| -   Re-arranging pages: via the pdf editor on an individual document's details page. | ||||
|  | ||||
| !!! important | ||||
|  | ||||
| @@ -596,7 +646,7 @@ When you first delete a document it is moved to the 'trash' until either it is e | ||||
| You can set how long documents remain in the trash before being automatically deleted with [`PAPERLESS_EMPTY_TRASH_DELAY`](configuration.md#PAPERLESS_EMPTY_TRASH_DELAY), which defaults | ||||
| to 30 days. Until the file is actually deleted (e.g. the trash is emptied), all files and database content remains intact and can be restored at any point up until that time. | ||||
|  | ||||
| Additionally you may configure a directory where deleted files are moved to when they the trash is emptied with [`PAPERLESS_EMPTY_TRASH_DIR`](configuration.md#PAPERLESS_EMPTY_TRASH_DIR). | ||||
| Additionally you may configure a directory where deleted files are moved to when the trash is emptied with [`PAPERLESS_EMPTY_TRASH_DIR`](configuration.md#PAPERLESS_EMPTY_TRASH_DIR). | ||||
| Note that the empty trash directory only stores the original file, the archive file and all database information is permanently removed once a document is fully deleted. | ||||
|  | ||||
| ## Best practices {#basic-searching} | ||||
|   | ||||
| @@ -52,12 +52,12 @@ if ! command -v wget &> /dev/null ; then | ||||
| fi | ||||
|  | ||||
| if ! command -v docker &> /dev/null ; then | ||||
| 	echo "docker executable not found. Is docker installed?" | ||||
| 	echo "docker executable not found. Is Docker installed?" | ||||
| 	exit 1 | ||||
| fi | ||||
|  | ||||
| if ! docker compose &> /dev/null ; then | ||||
| 	echo "docker compose plugin not found. Is docker compose installed?" | ||||
| 	echo "docker compose plugin not found. Is Docker Compose installed?" | ||||
| 	exit 1 | ||||
| fi | ||||
|  | ||||
| @@ -66,7 +66,7 @@ fi | ||||
| if ! docker stats --no-stream &> /dev/null ; then | ||||
| 	echo "" | ||||
| 	echo "WARN: It look like the current user does not have Docker permissions." | ||||
| 	echo "WARN: Use 'sudo usermod -aG docker $USER' to assign Docker permissions to the user (may require restarting shell)." | ||||
| 	echo "WARN: Use 'sudo usermod -aG docker $USER' to assign Docker permissions to the user (may require restarting the shell)." | ||||
| 	echo "" | ||||
| 	sleep 3 | ||||
| fi | ||||
| @@ -135,7 +135,7 @@ DATABASE_BACKEND=$ask_result | ||||
|  | ||||
| echo "" | ||||
| echo "Paperless is able to use Apache Tika to support Office documents such as" | ||||
| echo "Word, Excel, Powerpoint, and Libreoffice equivalents. This feature" | ||||
| echo "Word, Excel, PowerPoint, and LibreOffice equivalents. This feature" | ||||
| echo "requires more resources due to the required services." | ||||
| echo "" | ||||
|  | ||||
| @@ -157,7 +157,7 @@ echo "" | ||||
| echo "Specify the user id and group id you wish to run paperless as." | ||||
| echo "Paperless will also change ownership on the data, media and consume" | ||||
| echo "folder to the specified values, so it's a good idea to supply the user id" | ||||
| echo "and group id of your unix user account." | ||||
| echo "and group id of your Unix user account." | ||||
| echo "If unsure, leave default." | ||||
| echo "" | ||||
|  | ||||
| @@ -212,7 +212,7 @@ if [[ "$DATABASE_BACKEND" == "sqlite" ]] ; then | ||||
| 	echo -n "SQLite database, the " | ||||
| fi | ||||
| echo "search index and other data." | ||||
| echo "As with the media folder, leave empty to have this managed by docker." | ||||
| echo "As with the media folder, leave empty to have this managed by Docker." | ||||
| echo "" | ||||
| echo "CAUTION: If specified, you must specify an absolute path starting with /" | ||||
| echo "or a relative path starting with ./ here." | ||||
| @@ -224,7 +224,7 @@ DATA_FOLDER=$ask_result | ||||
| if [[ "$DATABASE_BACKEND" == "postgres" || "$DATABASE_BACKEND" == "mariadb" ]] ; then | ||||
| 	echo "" | ||||
| 	echo "The database folder, where your database stores its data." | ||||
| 	echo "Leave empty to have this managed by docker." | ||||
| 	echo "Leave empty to have this managed by Docker." | ||||
| 	echo "" | ||||
| 	echo "CAUTION: If specified, you must specify an absolute path starting with /" | ||||
| 	echo "or a relative path starting with ./ here." | ||||
| @@ -276,18 +276,18 @@ echo "" | ||||
| echo "Target folder: $TARGET_FOLDER" | ||||
| echo "Consume folder: $CONSUME_FOLDER" | ||||
| if [[ -z $MEDIA_FOLDER ]] ; then | ||||
| 	echo "Media folder: Managed by docker" | ||||
| 	echo "Media folder: Managed by Docker" | ||||
| else | ||||
| 	echo "Media folder: $MEDIA_FOLDER" | ||||
| fi | ||||
| if [[ -z $DATA_FOLDER ]] ; then | ||||
| 	echo "Data folder: Managed by docker" | ||||
| 	echo "Data folder: Managed by Docker" | ||||
| else | ||||
| 	echo "Data folder: $DATA_FOLDER" | ||||
| fi | ||||
| if [[ "$DATABASE_BACKEND" == "postgres" || "$DATABASE_BACKEND" == "mariadb" ]] ; then | ||||
| 	if [[ -z $DATABASE_FOLDER ]] ; then | ||||
| 		echo "Database folder: Managed by docker" | ||||
| 		echo "Database folder: Managed by Docker" | ||||
| 	else | ||||
| 		echo "Database folder: $DATABASE_FOLDER" | ||||
| 	fi | ||||
|   | ||||
| @@ -47,6 +47,7 @@ markdown_extensions: | ||||
|   - pymdownx.superfences | ||||
|   - pymdownx.inlinehilite | ||||
|   - pymdownx.snippets | ||||
|   - pymdownx.tilde | ||||
|   - footnotes | ||||
|   - pymdownx.superfences: | ||||
|       custom_fences: | ||||
|   | ||||
							
								
								
									
										120
									
								
								pyproject.toml
									
									
									
									
									
								
							
							
						
						
									
										120
									
								
								pyproject.toml
									
									
									
									
									
								
							| @@ -1,7 +1,7 @@ | ||||
| [project] | ||||
| name = "paperless-ngx" | ||||
| version = "2.17.1" | ||||
| description = "A community-supported supercharged version of paperless: scan, index and archive all your physical documents" | ||||
| version = "2.19.2" | ||||
| description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" | ||||
| readme = "README.md" | ||||
| requires-python = ">=3.10" | ||||
| classifiers = [ | ||||
| @@ -10,11 +10,13 @@ classifiers = [ | ||||
|   "Programming Language :: Python :: 3.11", | ||||
|   "Programming Language :: Python :: 3.12", | ||||
|   "Programming Language :: Python :: 3.13", | ||||
|   "Programming Language :: Python :: 3.14", | ||||
| ] | ||||
| # TODO: Move certain things to groups and then utilize that further | ||||
| # This will allow testing to not install a webserver, mysql, etc | ||||
|  | ||||
| dependencies = [ | ||||
|   "babel>=2.17", | ||||
|   "bleach~=6.2.0", | ||||
|   "celery[redis]~=5.5.1", | ||||
|   "channels~=4.2", | ||||
| @@ -23,33 +25,34 @@ dependencies = [ | ||||
|   "dateparser~=1.2", | ||||
|   # WARNING: django does not use semver. | ||||
|   #          Only patch versions are guaranteed to not introduce breaking changes. | ||||
|   "django~=5.1.7", | ||||
|   "django-allauth[socialaccount,mfa]~=65.4.0", | ||||
|   "django-auditlog~=3.1.2", | ||||
|   "django~=5.2.5", | ||||
|   "django-allauth[mfa,socialaccount]~=65.4.0", | ||||
|   "django-auditlog~=3.2.1", | ||||
|   "django-cachalot~=2.8.0", | ||||
|   "django-celery-results~=2.6.0", | ||||
|   "django-compression-middleware~=0.5.0", | ||||
|   "django-cors-headers~=4.7.0", | ||||
|   "django-cors-headers~=4.9.0", | ||||
|   "django-extensions~=4.1", | ||||
|   "django-filter~=25.1", | ||||
|   "django-guardian~=2.4.0", | ||||
|   "django-multiselectfield~=0.1.13", | ||||
|   "django-guardian~=3.2.0", | ||||
|   "django-multiselectfield~=1.0.1", | ||||
|   "django-soft-delete~=1.0.18", | ||||
|   "djangorestframework~=3.15", | ||||
|   "djangorestframework-guardian~=0.3.0", | ||||
|   "django-treenode>=0.23.2", | ||||
|   "djangorestframework~=3.16", | ||||
|   "djangorestframework-guardian~=0.4.0", | ||||
|   "drf-spectacular~=0.28", | ||||
|   "drf-spectacular-sidecar~=2025.4.1", | ||||
|   "drf-spectacular-sidecar~=2025.9.1", | ||||
|   "drf-writable-nested~=0.7.1", | ||||
|   "filelock~=3.18.0", | ||||
|   "filelock~=3.20.0", | ||||
|   "flower~=2.0.1", | ||||
|   "gotenberg-client~=0.10.0", | ||||
|   "gotenberg-client~=0.12.0", | ||||
|   "httpx-oauth~=0.16", | ||||
|   "imap-tools~=1.11.0", | ||||
|   "inotifyrecursive~=0.3", | ||||
|   "jinja2~=3.1.5", | ||||
|   "langdetect~=1.0.9", | ||||
|   "nltk~=3.9.1", | ||||
|   "ocrmypdf~=16.10.0", | ||||
|   "ocrmypdf~=16.11.0", | ||||
|   "pathvalidate~=3.3.1", | ||||
|   "pdf2image~=1.17.0", | ||||
|   "python-dateutil~=2.9.0", | ||||
| @@ -58,11 +61,11 @@ dependencies = [ | ||||
|   "python-ipware~=3.0.0", | ||||
|   "python-magic~=0.4.27", | ||||
|   "pyzbar~=0.1.9", | ||||
|   "rapidfuzz~=3.13.0", | ||||
|   "rapidfuzz~=3.14.0", | ||||
|   "redis[hiredis]~=5.2.1", | ||||
|   "scikit-learn~=1.7.0", | ||||
|   "setproctitle~=1.3.4", | ||||
|   "tika-client~=0.9.0", | ||||
|   "tika-client~=0.10.0", | ||||
|   "tqdm~=4.67.1", | ||||
|   "watchdog~=6.0", | ||||
|   "whitenoise~=6.9", | ||||
| @@ -74,12 +77,13 @@ optional-dependencies.mariadb = [ | ||||
|   "mysqlclient~=2.2.7", | ||||
| ] | ||||
| optional-dependencies.postgres = [ | ||||
|   "psycopg[c]==3.2.9", | ||||
|   "psycopg[c,pool]==3.2.9", | ||||
|   # Direct dependency for proper resolution of the pre-built wheels | ||||
|   "psycopg-c==3.2.9", | ||||
|   "psycopg-pool==3.2.6", | ||||
| ] | ||||
| optional-dependencies.webserver = [ | ||||
|   "granian[uvloop]~=2.4.1", | ||||
|   "granian[uvloop]~=2.5.1", | ||||
| ] | ||||
|  | ||||
| [dependency-groups] | ||||
| @@ -91,7 +95,7 @@ dev = [ | ||||
| ] | ||||
|  | ||||
| docs = [ | ||||
|   "mkdocs-glightbox~=0.4.0", | ||||
|   "mkdocs-glightbox~=0.5.1", | ||||
|   "mkdocs-material~=9.6.4", | ||||
| ] | ||||
|  | ||||
| @@ -99,9 +103,9 @@ testing = [ | ||||
|   "daphne", | ||||
|   "factory-boy~=3.3.1", | ||||
|   "imagehash", | ||||
|   "pytest~=8.3.3", | ||||
|   "pytest-cov~=6.0.0", | ||||
|   "pytest-django~=4.10.0", | ||||
|   "pytest~=8.4.1", | ||||
|   "pytest-cov~=7.0.0", | ||||
|   "pytest-django~=4.11.1", | ||||
|   "pytest-env", | ||||
|   "pytest-httpx", | ||||
|   "pytest-mock", | ||||
| @@ -111,9 +115,9 @@ testing = [ | ||||
| ] | ||||
|  | ||||
| lint = [ | ||||
|   "pre-commit~=4.1.0", | ||||
|   "pre-commit-uv~=4.1.3", | ||||
|   "ruff~=0.12.2", | ||||
|   "pre-commit~=4.3.0", | ||||
|   "pre-commit-uv~=4.2.0", | ||||
|   "ruff~=0.14.0", | ||||
| ] | ||||
|  | ||||
| typing = [ | ||||
| @@ -121,6 +125,7 @@ typing = [ | ||||
|   "django-filter-stubs", | ||||
|   "django-stubs[compatible-mypy]", | ||||
|   "djangorestframework-stubs[compatible-mypy]", | ||||
|   "lxml-stubs", | ||||
|   "mypy", | ||||
|   "types-bleach", | ||||
|   "types-colorama", | ||||
| @@ -128,11 +133,31 @@ typing = [ | ||||
|   "types-markdown", | ||||
|   "types-pygments", | ||||
|   "types-python-dateutil", | ||||
|   "types-pytz", | ||||
|   "types-redis", | ||||
|   "types-setuptools", | ||||
|   "types-tqdm", | ||||
| ] | ||||
|  | ||||
| [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.9/psycopg_c-3.2.9-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.9/psycopg_c-3.2.9-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.ruff] | ||||
| target-version = "py310" | ||||
| line-length = 88 | ||||
| @@ -202,32 +227,19 @@ 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/paperless_tesseract/tests/test_parser.py" = [ | ||||
|   "RUF001", | ||||
| ] | ||||
| lint.isort.force-single-line = true | ||||
|  | ||||
| [tool.codespell] | ||||
| write-changes = true | ||||
| ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober" | ||||
| skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json" | ||||
|  | ||||
| [tool.pytest.ini_options] | ||||
| minversion = "8.0" | ||||
| pythonpath = [ | ||||
| @@ -239,6 +251,7 @@ testpaths = [ | ||||
|   "src/paperless_mail/tests/", | ||||
|   "src/paperless_tesseract/tests/", | ||||
|   "src/paperless_tika/tests", | ||||
|   "src/paperless_text/tests/", | ||||
| ] | ||||
| addopts = [ | ||||
|   "--pythonwarnings=all", | ||||
| @@ -279,10 +292,10 @@ exclude_also = [ | ||||
| ] | ||||
|  | ||||
| [tool.mypy] | ||||
| mypy_path = "src" | ||||
| plugins = [ | ||||
|   "mypy_django_plugin.main", | ||||
|   "mypy_drf_plugin.main", | ||||
|   "numpy.typing.mypy_plugin", | ||||
| ] | ||||
| check_untyped_defs = true | ||||
| disallow_any_generics = true | ||||
| @@ -291,24 +304,5 @@ 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.9/psycopg_c-3.2.9-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.9/psycopg_c-3.2.9-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" | ||||
|   | ||||
| @@ -174,7 +174,7 @@ test('bulk edit', async ({ page }) => { | ||||
|   await expect(page.locator('pngx-document-list')).toHaveText( | ||||
|     /Selected 61 of 61 documents/i | ||||
|   ) | ||||
|   await page.getByRole('button', { name: 'Cancel' }).click() | ||||
|   await page.getByRole('button', { name: 'None' }).click() | ||||
|  | ||||
|   await page.locator('pngx-document-card-small').nth(1).click() | ||||
|   await page.locator('pngx-document-card-small').nth(2).click() | ||||
|   | ||||
							
								
								
									
										2006
									
								
								src-ui/messages.xlf
									
									
									
									
									
								
							
							
						
						
									
										2006
									
								
								src-ui/messages.xlf
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "paperless-ngx-ui", | ||||
|   "version": "2.17.1", | ||||
|   "version": "2.19.2", | ||||
|   "scripts": { | ||||
|     "preinstall": "npx only-allow pnpm", | ||||
|     "ng": "ng", | ||||
| @@ -11,65 +11,66 @@ | ||||
|   }, | ||||
|   "private": true, | ||||
|   "dependencies": { | ||||
|     "@angular/cdk": "^20.0.4", | ||||
|     "@angular/common": "~20.0.6", | ||||
|     "@angular/compiler": "~20.0.6", | ||||
|     "@angular/core": "~20.0.6", | ||||
|     "@angular/forms": "~20.0.6", | ||||
|     "@angular/localize": "~20.0.6", | ||||
|     "@angular/platform-browser": "~20.0.6", | ||||
|     "@angular/platform-browser-dynamic": "~20.0.6", | ||||
|     "@angular/router": "~20.0.6", | ||||
|     "@angular/cdk": "^20.2.6", | ||||
|     "@angular/common": "~20.3.2", | ||||
|     "@angular/compiler": "~20.3.2", | ||||
|     "@angular/core": "~20.3.2", | ||||
|     "@angular/forms": "~20.3.2", | ||||
|     "@angular/localize": "~20.3.2", | ||||
|     "@angular/platform-browser": "~20.3.2", | ||||
|     "@angular/platform-browser-dynamic": "~20.3.2", | ||||
|     "@angular/router": "~20.3.2", | ||||
|     "@ng-bootstrap/ng-bootstrap": "^19.0.1", | ||||
|     "@ng-select/ng-select": "^15.1.3", | ||||
|     "@ng-select/ng-select": "^20.2.2", | ||||
|     "@ngneat/dirty-check-forms": "^3.0.3", | ||||
|     "@popperjs/core": "^2.11.8", | ||||
|     "bootstrap": "^5.3.7", | ||||
|     "bootstrap": "^5.3.8", | ||||
|     "file-saver": "^2.0.5", | ||||
|     "mime-names": "^1.0.0", | ||||
|     "ng2-pdf-viewer": "^10.4.0", | ||||
|     "ngx-bootstrap-icons": "^1.9.3", | ||||
|     "ngx-color": "^10.0.0", | ||||
|     "ngx-cookie-service": "^20.0.1", | ||||
|     "ngx-device-detector": "^10.0.2", | ||||
|     "ngx-ui-tour-ng-bootstrap": "^17.0.0", | ||||
|     "ngx-color": "^10.1.0", | ||||
|     "ngx-cookie-service": "^20.1.0", | ||||
|     "ngx-device-detector": "^10.1.0", | ||||
|     "ngx-ui-tour-ng-bootstrap": "^17.0.1", | ||||
|     "rxjs": "^7.8.2", | ||||
|     "tslib": "^2.8.1", | ||||
|     "utif": "^3.1.0", | ||||
|     "uuid": "^11.1.0", | ||||
|     "uuid": "^13.0.0", | ||||
|     "zone.js": "^0.15.1" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@angular-builders/custom-webpack": "^20.0.0", | ||||
|     "@angular-builders/jest": "^20.0.0", | ||||
|     "@angular-devkit/core": "^20.0.4", | ||||
|     "@angular-devkit/schematics": "^20.0.4", | ||||
|     "@angular-eslint/builder": "20.1.1", | ||||
|     "@angular-eslint/eslint-plugin": "20.1.1", | ||||
|     "@angular-eslint/eslint-plugin-template": "20.1.1", | ||||
|     "@angular-eslint/schematics": "20.1.1", | ||||
|     "@angular-eslint/template-parser": "20.1.1", | ||||
|     "@angular/build": "^20.0.4", | ||||
|     "@angular/cli": "~20.0.4", | ||||
|     "@angular/compiler-cli": "~20.0.6", | ||||
|     "@angular-devkit/core": "^20.3.3", | ||||
|     "@angular-devkit/schematics": "^20.3.3", | ||||
|     "@angular-eslint/builder": "20.3.0", | ||||
|     "@angular-eslint/eslint-plugin": "20.3.0", | ||||
|     "@angular-eslint/eslint-plugin-template": "20.3.0", | ||||
|     "@angular-eslint/schematics": "20.3.0", | ||||
|     "@angular-eslint/template-parser": "20.3.0", | ||||
|     "@angular/build": "^20.3.3", | ||||
|     "@angular/cli": "~20.3.3", | ||||
|     "@angular/compiler-cli": "~20.3.2", | ||||
|     "@codecov/webpack-plugin": "^1.9.1", | ||||
|     "@playwright/test": "^1.53.2", | ||||
|     "@types/jest": "^29.5.14", | ||||
|     "@types/node": "^24.0.10", | ||||
|     "@typescript-eslint/eslint-plugin": "^8.35.1", | ||||
|     "@typescript-eslint/parser": "^8.35.1", | ||||
|     "@typescript-eslint/utils": "^8.35.1", | ||||
|     "eslint": "^9.30.1", | ||||
|     "jest": "29.7.0", | ||||
|     "jest-environment-jsdom": "^29.7.0", | ||||
|     "@playwright/test": "^1.55.1", | ||||
|     "@types/jest": "^30.0.0", | ||||
|     "@types/node": "^24.6.1", | ||||
|     "@typescript-eslint/eslint-plugin": "^8.45.0", | ||||
|     "@typescript-eslint/parser": "^8.45.0", | ||||
|     "@typescript-eslint/utils": "^8.45.0", | ||||
|     "eslint": "^9.36.0", | ||||
|     "jest": "30.2.0", | ||||
|     "jest-environment-jsdom": "^30.2.0", | ||||
|     "jest-junit": "^16.0.0", | ||||
|     "jest-preset-angular": "^14.5.5", | ||||
|     "jest-preset-angular": "^15.0.2", | ||||
|     "jest-websocket-mock": "^2.5.0", | ||||
|     "prettier-plugin-organize-imports": "^4.1.0", | ||||
|     "prettier-plugin-organize-imports": "^4.3.0", | ||||
|     "ts-node": "~10.9.1", | ||||
|     "typescript": "^5.8.3", | ||||
|     "webpack": "^5.99.9" | ||||
|     "webpack": "^5.102.0" | ||||
|   }, | ||||
|   "packageManager": "pnpm@10.17.1", | ||||
|   "pnpm": { | ||||
|     "onlyBuiltDependencies": [ | ||||
|       "@parcel/watcher", | ||||
|   | ||||
							
								
								
									
										6415
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6415
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,12 +1,16 @@ | ||||
| import '@angular/localize/init' | ||||
| import { jest } from '@jest/globals' | ||||
| import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone' | ||||
| import { TextDecoder, TextEncoder } from 'util' | ||||
| import { TextDecoder, TextEncoder } from 'node:util' | ||||
| if (process.env.NODE_ENV === 'test') { | ||||
|   setupZoneTestEnv() | ||||
| } | ||||
| global.TextEncoder = TextEncoder | ||||
| global.TextDecoder = TextDecoder | ||||
| ;(globalThis as any).TextEncoder = TextEncoder as unknown as { | ||||
|   new (): TextEncoder | ||||
| } | ||||
| ;(globalThis as any).TextDecoder = TextDecoder as unknown as { | ||||
|   new (): TextDecoder | ||||
| } | ||||
|  | ||||
| import { registerLocaleData } from '@angular/common' | ||||
| import localeAf from '@angular/common/locales/af' | ||||
| @@ -116,13 +120,39 @@ if (!URL.revokeObjectURL) { | ||||
|   Object.defineProperty(window.URL, 'revokeObjectURL', { value: jest.fn() }) | ||||
| } | ||||
| Object.defineProperty(window, 'ResizeObserver', { value: mock() }) | ||||
| Object.defineProperty(window, 'location', { | ||||
|   configurable: true, | ||||
|   value: { reload: jest.fn() }, | ||||
| }) | ||||
|  | ||||
| if (typeof IntersectionObserver === 'undefined') { | ||||
|   class MockIntersectionObserver { | ||||
|     constructor( | ||||
|       public callback: IntersectionObserverCallback, | ||||
|       public options?: IntersectionObserverInit | ||||
|     ) {} | ||||
|  | ||||
|     observe = jest.fn() | ||||
|     unobserve = jest.fn() | ||||
|     disconnect = jest.fn() | ||||
|     takeRecords = jest.fn() | ||||
|   } | ||||
|  | ||||
|   Object.defineProperty(window, 'IntersectionObserver', { | ||||
|     writable: true, | ||||
|     configurable: true, | ||||
|     value: MockIntersectionObserver, | ||||
|   }) | ||||
| } | ||||
|  | ||||
| HTMLCanvasElement.prototype.getContext = < | ||||
|   typeof HTMLCanvasElement.prototype.getContext | ||||
| >jest.fn() | ||||
|  | ||||
| jest.mock('uuid', () => ({ | ||||
|   v4: jest.fn(() => | ||||
|     'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char: string) => { | ||||
|       const random = Math.floor(Math.random() * 16) | ||||
|       const value = char === 'x' ? random : (random & 0x3) | 0x8 | ||||
|       return value.toString(16) | ||||
|     }) | ||||
|   ), | ||||
| })) | ||||
|  | ||||
| jest.mock('pdfjs-dist') | ||||
|   | ||||
| @@ -50,7 +50,7 @@ | ||||
|     <div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div> | ||||
|     <div class="btn-toolbar" role="toolbar"> | ||||
|         <div class="btn-group me-2"> | ||||
|             <button type="button" (click)="discardChanges()" class="btn btn-secondary" [disabled]="loading || (isDirty$ | async) === false" i18n>Discard</button> | ||||
|             <button type="button" (click)="discardChanges()" class="btn btn-outline-secondary" [disabled]="loading || (isDirty$ | async) === false" i18n>Discard</button> | ||||
|         </div> | ||||
|         <div class="btn-group"> | ||||
|             <button type="submit" class="btn btn-primary" [disabled]="loading || !configForm.valid || (isDirty$ | async) === false" i18n>Save</button> | ||||
|   | ||||
| @@ -176,6 +176,7 @@ | ||||
|             <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> | ||||
|                 <pngx-input-check i18n-title title="Show document counts in sidebar saved views" formControlName="sidebarViewsShowCount"></pngx-input-check> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
| @@ -357,6 +358,6 @@ | ||||
|  | ||||
|   <div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div> | ||||
|  | ||||
|   <button type="submit" class="btn btn-primary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button> | ||||
|   <button type="button" (click)="reset()" class="btn btn-secondary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button> | ||||
|   <button type="button" (click)="reset()" class="btn btn-outline-secondary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button> | ||||
|   <button type="submit" class="btn btn-primary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button> | ||||
| </form> | ||||
|   | ||||
| @@ -31,10 +31,12 @@ import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' | ||||
| import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { GroupService } from 'src/app/services/rest/group.service' | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { SystemStatusService } from 'src/app/services/system-status.service' | ||||
| import { Toast, ToastService } from 'src/app/services/toast.service' | ||||
| import * as navUtils from 'src/app/utils/navigation' | ||||
| import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { CheckComponent } from '../../common/input/check/check.component' | ||||
| @@ -59,6 +61,40 @@ const groups = [ | ||||
|   { id: 2, name: 'group2' }, | ||||
| ] | ||||
|  | ||||
| const status: SystemStatus = { | ||||
|   pngx_version: '2.4.3', | ||||
|   server_os: 'macOS-14.1.1-arm64-arm-64bit', | ||||
|   install_type: InstallType.BareMetal, | ||||
|   storage: { total: 494384795648, available: 13573525504 }, | ||||
|   database: { | ||||
|     type: 'sqlite', | ||||
|     url: '/paperless-ngx/data/db.sqlite3', | ||||
|     status: SystemStatusItemStatus.ERROR, | ||||
|     error: null, | ||||
|     migration_status: { | ||||
|       latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data', | ||||
|       unapplied_migrations: [], | ||||
|     }, | ||||
|   }, | ||||
|   tasks: { | ||||
|     redis_url: 'redis://localhost:6379', | ||||
|     redis_status: SystemStatusItemStatus.ERROR, | ||||
|     redis_error: 'Error 61 connecting to localhost:6379. Connection refused.', | ||||
|     celery_status: SystemStatusItemStatus.ERROR, | ||||
|     celery_url: 'celery@localhost', | ||||
|     celery_error: 'Error connecting to celery@localhost', | ||||
|     index_status: SystemStatusItemStatus.OK, | ||||
|     index_last_modified: new Date().toISOString(), | ||||
|     index_error: null, | ||||
|     classifier_status: SystemStatusItemStatus.OK, | ||||
|     classifier_last_trained: new Date().toISOString(), | ||||
|     classifier_error: null, | ||||
|     sanity_check_status: SystemStatusItemStatus.ERROR, | ||||
|     sanity_check_last_run: new Date().toISOString(), | ||||
|     sanity_check_error: 'Error running sanity check.', | ||||
|   }, | ||||
| } | ||||
|  | ||||
| describe('SettingsComponent', () => { | ||||
|   let component: SettingsComponent | ||||
|   let fixture: ComponentFixture<SettingsComponent> | ||||
| @@ -72,6 +108,7 @@ describe('SettingsComponent', () => { | ||||
|   let groupService: GroupService | ||||
|   let modalService: NgbModal | ||||
|   let systemStatusService: SystemStatusService | ||||
|   let savedViewsService: SavedViewService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     TestBed.configureTestingModule({ | ||||
| @@ -122,6 +159,7 @@ describe('SettingsComponent', () => { | ||||
|     permissionsService = TestBed.inject(PermissionsService) | ||||
|     modalService = TestBed.inject(NgbModal) | ||||
|     systemStatusService = TestBed.inject(SystemStatusService) | ||||
|     savedViewsService = TestBed.inject(SavedViewService) | ||||
|     jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) | ||||
|     jest | ||||
|       .spyOn(permissionsService, 'currentUserHasObjectPermissions') | ||||
| @@ -212,7 +250,7 @@ describe('SettingsComponent', () => { | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     expect(storeSpy).toHaveBeenCalled() | ||||
|     expect(appearanceSettingsSpy).not.toHaveBeenCalled() | ||||
|     expect(setSpy).toHaveBeenCalledTimes(29) | ||||
|     expect(setSpy).toHaveBeenCalledTimes(30) | ||||
|  | ||||
|     // succeed | ||||
|     storeSpy.mockReturnValueOnce(of(true)) | ||||
| @@ -222,6 +260,9 @@ describe('SettingsComponent', () => { | ||||
|   }) | ||||
|  | ||||
|   it('should offer reload if settings changes require', () => { | ||||
|     const reloadSpy = jest | ||||
|       .spyOn(navUtils, 'locationReload') | ||||
|       .mockImplementation(() => {}) | ||||
|     completeSetup() | ||||
|     let toast: Toast | ||||
|     toastService.getToasts().subscribe((t) => (toast = t[0])) | ||||
| @@ -238,6 +279,7 @@ describe('SettingsComponent', () => { | ||||
|  | ||||
|     expect(toast.actionName).toEqual('Reload now') | ||||
|     toast.action() | ||||
|     expect(reloadSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should allow setting theme color, visually apply change immediately but not save', () => { | ||||
| @@ -266,7 +308,7 @@ describe('SettingsComponent', () => { | ||||
|       ) | ||||
|     completeSetup(userService) | ||||
|     fixture.detectChanges() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should show errors on load if load groups failure', () => { | ||||
| @@ -278,44 +320,10 @@ describe('SettingsComponent', () => { | ||||
|       ) | ||||
|     completeSetup(groupService) | ||||
|     fixture.detectChanges() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should load system status on initialize, show errors if needed', () => { | ||||
|     const status: SystemStatus = { | ||||
|       pngx_version: '2.4.3', | ||||
|       server_os: 'macOS-14.1.1-arm64-arm-64bit', | ||||
|       install_type: InstallType.BareMetal, | ||||
|       storage: { total: 494384795648, available: 13573525504 }, | ||||
|       database: { | ||||
|         type: 'sqlite', | ||||
|         url: '/paperless-ngx/data/db.sqlite3', | ||||
|         status: SystemStatusItemStatus.ERROR, | ||||
|         error: null, | ||||
|         migration_status: { | ||||
|           latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data', | ||||
|           unapplied_migrations: [], | ||||
|         }, | ||||
|       }, | ||||
|       tasks: { | ||||
|         redis_url: 'redis://localhost:6379', | ||||
|         redis_status: SystemStatusItemStatus.ERROR, | ||||
|         redis_error: | ||||
|           'Error 61 connecting to localhost:6379. Connection refused.', | ||||
|         celery_status: SystemStatusItemStatus.ERROR, | ||||
|         celery_url: 'celery@localhost', | ||||
|         celery_error: 'Error connecting to celery@localhost', | ||||
|         index_status: SystemStatusItemStatus.OK, | ||||
|         index_last_modified: new Date().toISOString(), | ||||
|         index_error: null, | ||||
|         classifier_status: SystemStatusItemStatus.OK, | ||||
|         classifier_last_trained: new Date().toISOString(), | ||||
|         classifier_error: null, | ||||
|         sanity_check_status: SystemStatusItemStatus.ERROR, | ||||
|         sanity_check_last_run: new Date().toISOString(), | ||||
|         sanity_check_error: 'Error running sanity check.', | ||||
|       }, | ||||
|     } | ||||
|     jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status)) | ||||
|     jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true) | ||||
|     completeSetup() | ||||
| @@ -332,6 +340,8 @@ describe('SettingsComponent', () => { | ||||
|  | ||||
|   it('should open system status dialog', () => { | ||||
|     const modalOpenSpy = jest.spyOn(modalService, 'open') | ||||
|     jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status)) | ||||
|     jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true) | ||||
|     completeSetup() | ||||
|     component.showSystemStatus() | ||||
|     expect(modalOpenSpy).toHaveBeenCalledWith(SystemStatusDialogComponent, { | ||||
| @@ -345,4 +355,14 @@ describe('SettingsComponent', () => { | ||||
|     component.reset() | ||||
|     expect(component.settingsForm.get('themeColor').value).toEqual('') | ||||
|   }) | ||||
|  | ||||
|   it('should trigger maybeRefreshDocumentCounts on settings save', () => { | ||||
|     completeSetup() | ||||
|     const maybeRefreshSpy = jest.spyOn( | ||||
|       savedViewsService, | ||||
|       'maybeRefreshDocumentCounts' | ||||
|     ) | ||||
|     settingsService.settingsSaved.emit(true) | ||||
|     expect(maybeRefreshSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -49,6 +49,7 @@ import { | ||||
|   PermissionsService, | ||||
| } from 'src/app/services/permissions.service' | ||||
| import { GroupService } from 'src/app/services/rest/group.service' | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { | ||||
|   LanguageOption, | ||||
| @@ -56,6 +57,7 @@ import { | ||||
| } from 'src/app/services/settings.service' | ||||
| import { SystemStatusService } from 'src/app/services/system-status.service' | ||||
| import { Toast, ToastService } from 'src/app/services/toast.service' | ||||
| import { locationReload } from 'src/app/utils/navigation' | ||||
| import { CheckComponent } from '../../common/input/check/check.component' | ||||
| import { ColorComponent } from '../../common/input/color/color.component' | ||||
| import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component' | ||||
| @@ -117,6 +119,7 @@ export class SettingsComponent | ||||
|   permissionsService = inject(PermissionsService) | ||||
|   private modalService = inject(NgbModal) | ||||
|   private systemStatusService = inject(SystemStatusService) | ||||
|   private savedViewsService = inject(SavedViewService) | ||||
|  | ||||
|   activeNavID: number | ||||
|  | ||||
| @@ -152,6 +155,7 @@ export class SettingsComponent | ||||
|     notificationsConsumerSuppressOnDashboard: new FormControl(null), | ||||
|  | ||||
|     savedViewsWarnOnUnsavedChange: new FormControl(null), | ||||
|     sidebarViewsShowCount: new FormControl(null), | ||||
|   }) | ||||
|  | ||||
|   SettingsNavIDs = SettingsNavIDs | ||||
| @@ -181,7 +185,8 @@ export class SettingsComponent | ||||
|       this.systemStatus.tasks.classifier_status === | ||||
|         SystemStatusItemStatus.ERROR || | ||||
|       this.systemStatus.tasks.sanity_check_status === | ||||
|         SystemStatusItemStatus.ERROR | ||||
|         SystemStatusItemStatus.ERROR || | ||||
|       this.systemStatus.websocket_connected === SystemStatusItemStatus.ERROR | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| @@ -197,6 +202,7 @@ export class SettingsComponent | ||||
|     super() | ||||
|     this.settings.settingsSaved.subscribe(() => { | ||||
|       if (!this.savePending) this.initialize() | ||||
|       this.savedViewsService.maybeRefreshDocumentCounts() | ||||
|     }) | ||||
|   } | ||||
|  | ||||
| @@ -308,6 +314,9 @@ export class SettingsComponent | ||||
|       savedViewsWarnOnUnsavedChange: this.settings.get( | ||||
|         SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE | ||||
|       ), | ||||
|       sidebarViewsShowCount: this.settings.get( | ||||
|         SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT | ||||
|       ), | ||||
|       defaultPermsOwner: this.settings.get(SETTINGS_KEYS.DEFAULT_PERMS_OWNER), | ||||
|       defaultPermsViewUsers: this.settings.get( | ||||
|         SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS | ||||
| @@ -485,6 +494,10 @@ export class SettingsComponent | ||||
|       SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE, | ||||
|       this.settingsForm.value.savedViewsWarnOnUnsavedChange | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT, | ||||
|       this.settingsForm.value.sidebarViewsShowCount | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.DEFAULT_PERMS_OWNER, | ||||
|       this.settingsForm.value.defaultPermsOwner | ||||
| @@ -539,7 +552,7 @@ export class SettingsComponent | ||||
|             savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.` | ||||
|             savedToast.actionName = $localize`Reload now` | ||||
|             savedToast.action = () => { | ||||
|               location.reload() | ||||
|               locationReload() | ||||
|             } | ||||
|           } | ||||
|  | ||||
|   | ||||
| @@ -16,6 +16,7 @@ import { | ||||
|   NgbNavItem, | ||||
| } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { throwError } from 'rxjs' | ||||
| import { routes } from 'src/app/app-routing.module' | ||||
| import { | ||||
|   PaperlessTask, | ||||
| @@ -28,6 +29,7 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard' | ||||
| import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { TasksService } from 'src/app/services/tasks.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { environment } from 'src/environments/environment' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { PageHeaderComponent } from '../../common/page-header/page-header.component' | ||||
| @@ -123,6 +125,7 @@ describe('TasksComponent', () => { | ||||
|   let router: Router | ||||
|   let httpTestingController: HttpTestingController | ||||
|   let reloadSpy | ||||
|   let toastService: ToastService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     TestBed.configureTestingModule({ | ||||
| @@ -157,6 +160,7 @@ describe('TasksComponent', () => { | ||||
|     httpTestingController = TestBed.inject(HttpTestingController) | ||||
|     modalService = TestBed.inject(NgbModal) | ||||
|     router = TestBed.inject(Router) | ||||
|     toastService = TestBed.inject(ToastService) | ||||
|     fixture = TestBed.createComponent(TasksComponent) | ||||
|     component = fixture.componentInstance | ||||
|     jest.useFakeTimers() | ||||
| @@ -249,6 +253,42 @@ describe('TasksComponent', () => { | ||||
|     expect(dismissSpy).toHaveBeenCalledWith(selected) | ||||
|   }) | ||||
|  | ||||
|   it('should show an error and re-enable modal buttons when dismissing multiple tasks fails', () => { | ||||
|     component.selectedTasks = new Set([tasks[0].id, tasks[1].id]) | ||||
|     const error = new Error('dismiss failed') | ||||
|     const toastSpy = jest.spyOn(toastService, 'showError') | ||||
|     const dismissSpy = jest | ||||
|       .spyOn(tasksService, 'dismissTasks') | ||||
|       .mockReturnValue(throwError(() => error)) | ||||
|  | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) | ||||
|  | ||||
|     component.dismissTasks() | ||||
|     expect(modal).not.toBeUndefined() | ||||
|  | ||||
|     modal.componentInstance.confirmClicked.emit() | ||||
|  | ||||
|     expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id])) | ||||
|     expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error) | ||||
|     expect(modal.componentInstance.buttonsEnabled).toBe(true) | ||||
|     expect(component.selectedTasks.size).toBe(0) | ||||
|   }) | ||||
|  | ||||
|   it('should show an error when dismissing a single task fails', () => { | ||||
|     const error = new Error('dismiss failed') | ||||
|     const toastSpy = jest.spyOn(toastService, 'showError') | ||||
|     const dismissSpy = jest | ||||
|       .spyOn(tasksService, 'dismissTasks') | ||||
|       .mockReturnValue(throwError(() => error)) | ||||
|  | ||||
|     component.dismissTask(tasks[0]) | ||||
|  | ||||
|     expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id])) | ||||
|     expect(toastSpy).toHaveBeenCalledWith('Error dismissing task', error) | ||||
|     expect(component.selectedTasks.size).toBe(0) | ||||
|   }) | ||||
|  | ||||
|   it('should support dismiss all tasks', () => { | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) | ||||
|   | ||||
| @@ -24,6 +24,7 @@ import { PaperlessTask } from 'src/app/data/paperless-task' | ||||
| import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' | ||||
| import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' | ||||
| import { TasksService } from 'src/app/services/tasks.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { PageHeaderComponent } from '../../common/page-header/page-header.component' | ||||
| import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' | ||||
| @@ -72,6 +73,7 @@ export class TasksComponent | ||||
|   tasksService = inject(TasksService) | ||||
|   private modalService = inject(NgbModal) | ||||
|   private readonly router = inject(Router) | ||||
|   private readonly toastService = inject(ToastService) | ||||
|  | ||||
|   public activeTab: TaskTab | ||||
|   public selectedTasks: Set<number> = new Set() | ||||
| @@ -154,11 +156,19 @@ export class TasksComponent | ||||
|       modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { | ||||
|         modal.componentInstance.buttonsEnabled = false | ||||
|         modal.close() | ||||
|         this.tasksService.dismissTasks(tasks) | ||||
|         this.tasksService.dismissTasks(tasks).subscribe({ | ||||
|           error: (e) => { | ||||
|             this.toastService.showError($localize`Error dismissing tasks`, e) | ||||
|             modal.componentInstance.buttonsEnabled = true | ||||
|           }, | ||||
|         }) | ||||
|         this.clearSelection() | ||||
|       }) | ||||
|     } else { | ||||
|       this.tasksService.dismissTasks(tasks) | ||||
|       this.tasksService.dismissTasks(tasks).subscribe({ | ||||
|         error: (e) => | ||||
|           this.toastService.showError($localize`Error dismissing task`, e), | ||||
|       }) | ||||
|       this.clearSelection() | ||||
|     } | ||||
|   } | ||||
|   | ||||
| @@ -7,7 +7,7 @@ | ||||
|   > | ||||
| </pngx-page-header> | ||||
|  | ||||
| @if (users) { | ||||
| @if (canViewUsers && users) { | ||||
|   <h4 class="d-flex"> | ||||
|     <ng-container i18n>Users</ng-container> | ||||
|     <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }"> | ||||
| @@ -45,7 +45,7 @@ | ||||
|   </ul> | ||||
| } | ||||
|  | ||||
| @if (groups) { | ||||
| @if (canViewGroups && groups) { | ||||
|   <h4 class="mt-4 d-flex"> | ||||
|     <ng-container i18n>Groups</ng-container> | ||||
|     <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }"> | ||||
| @@ -86,7 +86,7 @@ | ||||
|   </ul> | ||||
| } | ||||
|  | ||||
| @if (!users || !groups) { | ||||
| @if ((canViewUsers && !users) || (canViewGroups && !groups)) { | ||||
|   <div> | ||||
|     <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> | ||||
|     <div class="visually-hidden" i18n>Loading...</div> | ||||
|   | ||||
| @@ -19,6 +19,7 @@ import { GroupService } from 'src/app/services/rest/group.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import * as navUtils from 'src/app/utils/navigation' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component' | ||||
| import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component' | ||||
| @@ -107,7 +108,7 @@ describe('UsersAndGroupsComponent', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     editDialog.failed.emit() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     settingsService.currentUser = users[1] // simulate logged in as different user | ||||
|     editDialog.succeeded.emit(users[0]) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith( | ||||
| @@ -130,7 +131,7 @@ describe('UsersAndGroupsComponent', () => { | ||||
|       throwError(() => new Error('error deleting user')) | ||||
|     ) | ||||
|     deleteDialog.confirm() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     deleteSpy.mockReturnValueOnce(of(true)) | ||||
|     deleteDialog.confirm() | ||||
|     expect(listAllSpy).toHaveBeenCalled() | ||||
| @@ -142,19 +143,18 @@ describe('UsersAndGroupsComponent', () => { | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     component.editUser(users[0]) | ||||
|     const navSpy = jest | ||||
|       .spyOn(navUtils, 'setLocationHref') | ||||
|       .mockImplementation(() => {}) | ||||
|     const editDialog = modal.componentInstance as UserEditDialogComponent | ||||
|     editDialog.passwordIsSet = true | ||||
|     settingsService.currentUser = users[0] // simulate logged in as same user | ||||
|     editDialog.succeeded.emit(users[0]) | ||||
|     fixture.detectChanges() | ||||
|     Object.defineProperty(window, 'location', { | ||||
|       value: { | ||||
|         href: 'http://localhost/', | ||||
|       }, | ||||
|       writable: true, // possibility to override | ||||
|     }) | ||||
|     tick(2600) | ||||
|     expect(window.location.href).toContain('logout') | ||||
|     expect(navSpy).toHaveBeenCalledWith( | ||||
|       `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` | ||||
|     ) | ||||
|   })) | ||||
|  | ||||
|   it('should support edit / create group, show error if needed', () => { | ||||
| @@ -166,7 +166,7 @@ describe('UsersAndGroupsComponent', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     editDialog.failed.emit() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     editDialog.succeeded.emit(groups[0]) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledWith( | ||||
|       `Saved group "${groups[0].name}".` | ||||
| @@ -188,7 +188,7 @@ describe('UsersAndGroupsComponent', () => { | ||||
|       throwError(() => new Error('error deleting group')) | ||||
|     ) | ||||
|     deleteDialog.confirm() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     deleteSpy.mockReturnValueOnce(of(true)) | ||||
|     deleteDialog.confirm() | ||||
|     expect(listAllSpy).toHaveBeenCalled() | ||||
| @@ -210,7 +210,7 @@ describe('UsersAndGroupsComponent', () => { | ||||
|       ) | ||||
|     completeSetup(userService) | ||||
|     fixture.detectChanges() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should show errors on load if load groups failure', () => { | ||||
| @@ -222,6 +222,6 @@ describe('UsersAndGroupsComponent', () => { | ||||
|       ) | ||||
|     completeSetup(groupService) | ||||
|     fixture.detectChanges() | ||||
|     expect(toastErrorSpy).toBeCalled() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -5,11 +5,16 @@ import { Subject, first, takeUntil } from 'rxjs' | ||||
| import { Group } from 'src/app/data/group' | ||||
| import { User } from 'src/app/data/user' | ||||
| import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { | ||||
|   PermissionAction, | ||||
|   PermissionType, | ||||
|   PermissionsService, | ||||
| } from 'src/app/services/permissions.service' | ||||
| import { GroupService } from 'src/app/services/rest/group.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { setLocationHref } from 'src/app/utils/navigation' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component' | ||||
| import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component' | ||||
| @@ -43,30 +48,48 @@ export class UsersAndGroupsComponent | ||||
|  | ||||
|   unsubscribeNotifier: Subject<any> = new Subject() | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.usersService | ||||
|       .listAll(null, null, { full_perms: true }) | ||||
|       .pipe(first(), takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe({ | ||||
|         next: (r) => { | ||||
|           this.users = r.results | ||||
|         }, | ||||
|         error: (e) => { | ||||
|           this.toastService.showError($localize`Error retrieving users`, e) | ||||
|         }, | ||||
|       }) | ||||
|   public get canViewUsers(): boolean { | ||||
|     return this.permissionsService.currentUserCan( | ||||
|       PermissionAction.View, | ||||
|       PermissionType.User | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|     this.groupsService | ||||
|       .listAll(null, null, { full_perms: true }) | ||||
|       .pipe(first(), takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe({ | ||||
|         next: (r) => { | ||||
|           this.groups = r.results | ||||
|         }, | ||||
|         error: (e) => { | ||||
|           this.toastService.showError($localize`Error retrieving groups`, e) | ||||
|         }, | ||||
|       }) | ||||
|   public get canViewGroups(): boolean { | ||||
|     return this.permissionsService.currentUserCan( | ||||
|       PermissionAction.View, | ||||
|       PermissionType.Group | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     if (this.canViewUsers) { | ||||
|       this.usersService | ||||
|         .listAll(null, null, { full_perms: true }) | ||||
|         .pipe(first(), takeUntil(this.unsubscribeNotifier)) | ||||
|         .subscribe({ | ||||
|           next: (r) => { | ||||
|             this.users = r.results | ||||
|           }, | ||||
|           error: (e) => { | ||||
|             this.toastService.showError($localize`Error retrieving users`, e) | ||||
|           }, | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     if (this.canViewGroups) { | ||||
|       this.groupsService | ||||
|         .listAll(null, null, { full_perms: true }) | ||||
|         .pipe(first(), takeUntil(this.unsubscribeNotifier)) | ||||
|         .subscribe({ | ||||
|           next: (r) => { | ||||
|             this.groups = r.results | ||||
|           }, | ||||
|           error: (e) => { | ||||
|             this.toastService.showError($localize`Error retrieving groups`, e) | ||||
|           }, | ||||
|         }) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy() { | ||||
| @@ -93,7 +116,9 @@ export class UsersAndGroupsComponent | ||||
|             $localize`Password has been changed, you will be logged out momentarily.` | ||||
|           ) | ||||
|           setTimeout(() => { | ||||
|             window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` | ||||
|             setLocationHref( | ||||
|               `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` | ||||
|             ) | ||||
|           }, 2500) | ||||
|         } else { | ||||
|           this.toastService.showInfo( | ||||
|   | ||||
| @@ -108,11 +108,19 @@ | ||||
|                 <li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews" | ||||
|                   cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)" | ||||
|                   (cdkDragEnded)="onDragEnd($event)"> | ||||
|                   <a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}" | ||||
|                   <a class="nav-link" routerLink="view/{{view.id}}" | ||||
|                     routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name" | ||||
|                     [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" | ||||
|                     popoverClass="popover-slim"> | ||||
|                     <i-bs class="me-1" name="funnel"></i-bs><span> {{view.name}}</span> | ||||
|                     <i-bs class="me-1" name="funnel"></i-bs> | ||||
|                       <span> <div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div> | ||||
|                         @if (showSidebarCounts && !slimSidebarEnabled) { | ||||
|                           <span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span> | ||||
|                         } | ||||
|                       </span> | ||||
|                     @if (showSidebarCounts && slimSidebarEnabled) { | ||||
|                       <span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span> | ||||
|                     } | ||||
|                   </a> | ||||
|                   @if (settingsService.organizingSidebarSavedViews) { | ||||
|                     <div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle> | ||||
| @@ -139,7 +147,7 @@ | ||||
|                   [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" | ||||
|                   popoverClass="popover-slim"> | ||||
|                   <i-bs class="me-1" name="file-text"></i-bs><span> {{d.title | documentTitle}}</span> | ||||
|                   <span class="close" (click)="closeDocument(d); $event.preventDefault()"> | ||||
|                   <span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()"> | ||||
|                     <i-bs name="x"></i-bs> | ||||
|                   </span> | ||||
|                 </a> | ||||
|   | ||||
| @@ -19,6 +19,10 @@ | ||||
|     height: 0.8em; | ||||
|   } | ||||
|  | ||||
|   .view-name { | ||||
|     max-width: calc(100% - 50px) | ||||
|   } | ||||
|  | ||||
|   .nav-group:not(:has(.app-link)) .sidebar-heading { | ||||
|     display: none !important; | ||||
|   } | ||||
| @@ -187,7 +191,7 @@ main { | ||||
|   list-style-type: none; | ||||
|  | ||||
|   &:hover .close { | ||||
|     display: block; | ||||
|     display: flex; | ||||
|   } | ||||
|  | ||||
|   .close { | ||||
|   | ||||
| @@ -92,6 +92,7 @@ describe('AppFrameComponent', () => { | ||||
|   let router: Router | ||||
|   let savedViewSpy | ||||
|   let modalService: NgbModal | ||||
|   let maybeRefreshSpy | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     TestBed.configureTestingModule({ | ||||
| @@ -113,7 +114,11 @@ describe('AppFrameComponent', () => { | ||||
|         { | ||||
|           provide: SavedViewService, | ||||
|           useValue: { | ||||
|             reload: () => {}, | ||||
|             reload: (fn: any) => { | ||||
|               if (fn) { | ||||
|                 fn() | ||||
|               } | ||||
|             }, | ||||
|             listAll: () => | ||||
|               of({ | ||||
|                 all: [saved_views.map((v) => v.id)], | ||||
| @@ -121,6 +126,8 @@ describe('AppFrameComponent', () => { | ||||
|                 results: saved_views, | ||||
|               }), | ||||
|             sidebarViews: saved_views.filter((v) => v.show_in_sidebar), | ||||
|             getDocumentCount: (view: SavedView) => 5, | ||||
|             maybeRefreshDocumentCounts: () => {}, | ||||
|           }, | ||||
|         }, | ||||
|         PermissionsService, | ||||
| @@ -169,6 +176,7 @@ describe('AppFrameComponent', () => { | ||||
|     jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) | ||||
|  | ||||
|     savedViewSpy = jest.spyOn(savedViewService, 'reload') | ||||
|     maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts') | ||||
|  | ||||
|     fixture = TestBed.createComponent(AppFrameComponent) | ||||
|     component = fixture.componentInstance | ||||
| @@ -359,4 +367,8 @@ describe('AppFrameComponent', () => { | ||||
|     expect(toastErrorSpy).toHaveBeenCalledTimes(2) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledTimes(3) | ||||
|   }) | ||||
|  | ||||
|   it('should call maybeRefreshDocumentCounts after saved views reload', () => { | ||||
|     expect(maybeRefreshSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -102,7 +102,9 @@ export class AppFrameComponent | ||||
|         PermissionType.SavedView | ||||
|       ) | ||||
|     ) { | ||||
|       this.savedViewService.reload() | ||||
|       this.savedViewService.reload(() => { | ||||
|         this.savedViewService.maybeRefreshDocumentCounts() | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -143,7 +145,7 @@ export class AppFrameComponent | ||||
|   } | ||||
|  | ||||
|   get versionString(): string { | ||||
|     return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.production ? '' : ` #${environment.tag}`}` | ||||
|     return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}` | ||||
|   } | ||||
|  | ||||
|   get customAppTitle(): string { | ||||
| @@ -283,4 +285,11 @@ export class AppFrameComponent | ||||
|   onLogout() { | ||||
|     this.openDocumentsService.closeAll() | ||||
|   } | ||||
|  | ||||
|   get showSidebarCounts(): boolean { | ||||
|     return ( | ||||
|       this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) && | ||||
|       !this.settingsService.organizingSidebarSavedViews | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,54 +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)="cancel()"> | ||||
|     </button> | ||||
| </div> | ||||
| <div class="modal-body"> | ||||
|     <div class="row"> | ||||
|         <div class="col"> | ||||
|             <div class="btn-toolbar flex-nowrap"> | ||||
|                 <div class="input-group input-group-sm"> | ||||
|                     <div class="input-group-text" i18n>Page</div> | ||||
|                     <input class="form-control mw-60" type="number" min="1" [(ngModel)]="currentPage" /> | ||||
|                     <div class="input-group-text" i18n>of {{totalPages}}</div> | ||||
|                 </div> | ||||
|                 <div class="input-group input-group-sm ms-auto"> | ||||
|                     <span class="input-group-text" i18n>Pages to remove</span> | ||||
|                     <input [ngModel]="pagesString" class="form-control" disabled /> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="pdf-viewer-container w-100 mt-3"> | ||||
|                 <pdf-viewer #pdfViewer [src]="pdfSrc" [(page)]="currentPage" | ||||
|                 [original-size]="false" | ||||
|                 [zoom]="1" | ||||
|                 zoom-scale="page-fit" | ||||
|                 [render-text]="false" | ||||
|                 (pagerendered)="pageRendered($event)" | ||||
|                 (after-load-complete)="pdfPreviewLoaded($event)"> | ||||
|                 </pdf-viewer> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| <div class="modal-footer flex-nowrap"> | ||||
|     <div> | ||||
|         @if (message) { | ||||
|             <p [innerHTML]="message | safeHtml"></p> | ||||
|         } | ||||
|         @if (messageBold) { | ||||
|             <p class="mb-0 small"><b [innerHTML]="messageBold | safeHtml"></b></p> | ||||
|         } | ||||
|     </div> | ||||
|     <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled"> | ||||
|             <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span> | ||||
|         </button> | ||||
|     <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled"> | ||||
|         {{btnCaption}} | ||||
|     </button> | ||||
| </div> | ||||
|  | ||||
| <ng-template #pageCheckOverlay let-page="page" let-pages="pages"> | ||||
|     <div class="position-absolute top-0 start-0 w-100 h-100 p-2" (click)="pageCheckChanged(page)"> | ||||
|         <input type="checkbox" class="form-check-input" /> | ||||
|     </div> | ||||
| </ng-template> | ||||
| @@ -1,28 +0,0 @@ | ||||
| .pdf-viewer-container { | ||||
|   background-color: gray; | ||||
|   height: 550px; | ||||
|  | ||||
|   pdf-viewer { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .mw-60 { | ||||
|   max-width: 60px; | ||||
| } | ||||
|  | ||||
| div.position-absolute:has(.form-check-input:checked) { | ||||
|   background-color: rgba(var(--bs-dark-rgb), 0.4); | ||||
| } | ||||
|  | ||||
| .form-check-input { | ||||
|   &:checked { | ||||
|     background-color: var(--bs-danger); | ||||
|     border-color: var(--bs-danger); | ||||
|   } | ||||
|   &:focus { | ||||
|     box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), var(--pngx-focus-alpha)); | ||||
|     border-color: var(--bs-danger); | ||||
|   } | ||||
| } | ||||
| @@ -1,60 +0,0 @@ | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' | ||||
| import { DeletePagesConfirmDialogComponent } from './delete-pages-confirm-dialog.component' | ||||
|  | ||||
| describe('DeletePagesConfirmDialogComponent', () => { | ||||
|   let component: DeletePagesConfirmDialogComponent | ||||
|   let fixture: ComponentFixture<DeletePagesConfirmDialogComponent> | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       declarations: [], | ||||
|       imports: [ | ||||
|         NgxBootstrapIconsModule.pick(allIcons), | ||||
|         FormsModule, | ||||
|         ReactiveFormsModule, | ||||
|         DeletePagesConfirmDialogComponent, | ||||
|       ], | ||||
|       providers: [ | ||||
|         NgbActiveModal, | ||||
|         SafeHtmlPipe, | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|     fixture = TestBed.createComponent(DeletePagesConfirmDialogComponent) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should return a string with comma-separated pages', () => { | ||||
|     component.pages = [1, 2, 3, 4] | ||||
|     expect(component.pagesString).toEqual('1, 2, 3, 4') | ||||
|   }) | ||||
|  | ||||
|   it('should update totalPages when pdf is loaded', () => { | ||||
|     component.pdfPreviewLoaded({ numPages: 5 } as any) | ||||
|     expect(component.totalPages).toEqual(5) | ||||
|   }) | ||||
|  | ||||
|   it('should update checks when page is rendered', () => { | ||||
|     const event = { | ||||
|       target: document.createElement('div'), | ||||
|       detail: { pageNumber: 1 }, | ||||
|     } as any | ||||
|     component.pageRendered(event) | ||||
|     expect(component['checks'].length).toEqual(1) | ||||
|   }) | ||||
|  | ||||
|   it('should update pages when page check is changed', () => { | ||||
|     component.pageCheckChanged(1) | ||||
|     expect(component.pages).toEqual([1]) | ||||
|     component.pageCheckChanged(1) | ||||
|     expect(component.pages).toEqual([]) | ||||
|   }) | ||||
| }) | ||||
| @@ -1,69 +0,0 @@ | ||||
| import { Component, TemplateRef, ViewChild, inject } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { | ||||
|   PDFDocumentProxy, | ||||
|   PdfViewerComponent, | ||||
|   PdfViewerModule, | ||||
| } from 'ng2-pdf-viewer' | ||||
| import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { ConfirmDialogComponent } from '../confirm-dialog.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-delete-pages-confirm-dialog', | ||||
|   templateUrl: './delete-pages-confirm-dialog.component.html', | ||||
|   styleUrl: './delete-pages-confirm-dialog.component.scss', | ||||
|   imports: [PdfViewerModule, FormsModule, ReactiveFormsModule, SafeHtmlPipe], | ||||
| }) | ||||
| export class DeletePagesConfirmDialogComponent extends ConfirmDialogComponent { | ||||
|   private documentService = inject(DocumentService) | ||||
|  | ||||
|   public documentID: number | ||||
|   public pages: number[] = [] | ||||
|   public currentPage: number = 1 | ||||
|   public totalPages: number | ||||
|  | ||||
|   @ViewChild('pdfViewer') pdfViewer: PdfViewerComponent | ||||
|   @ViewChild('pageCheckOverlay') pageCheckOverlay!: TemplateRef<any> | ||||
|   private checks: HTMLElement[] = [] | ||||
|  | ||||
|   public get pagesString(): string { | ||||
|     return this.pages.join(', ') | ||||
|   } | ||||
|  | ||||
|   public get pdfSrc(): string { | ||||
|     return this.documentService.getPreviewUrl(this.documentID) | ||||
|   } | ||||
|  | ||||
|   constructor() { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   public pdfPreviewLoaded(pdf: PDFDocumentProxy) { | ||||
|     this.totalPages = pdf.numPages | ||||
|   } | ||||
|  | ||||
|   pageRendered(event: CustomEvent) { | ||||
|     const pageDiv = event.target as HTMLDivElement | ||||
|     const check = this.pageCheckOverlay.createEmbeddedView({ | ||||
|       page: event.detail.pageNumber, | ||||
|     }) | ||||
|     this.checks[event.detail.pageNumber - 1] = check.rootNodes[0] | ||||
|     pageDiv?.insertBefore(check.rootNodes[0], pageDiv.firstChild) | ||||
|     this.updateChecks() | ||||
|   } | ||||
|  | ||||
|   pageCheckChanged(pageNumber: number) { | ||||
|     if (!this.pages.includes(pageNumber)) this.pages.push(pageNumber) | ||||
|     else if (this.pages.includes(pageNumber)) | ||||
|       this.pages.splice(this.pages.indexOf(pageNumber), 1) | ||||
|     this.updateChecks() | ||||
|   } | ||||
|  | ||||
|   private updateChecks() { | ||||
|     this.checks.forEach((check, i) => { | ||||
|       const input = check.getElementsByTagName('input')[0] | ||||
|       input.checked = this.pages.includes(i + 1) | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| @@ -1,59 +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)="cancel()"> | ||||
|     </button> | ||||
| </div> | ||||
| <div class="modal-body"> | ||||
|     <p>{{message}}</p> | ||||
|     <div class="row mb-2"> | ||||
|         <div class="col-7"> | ||||
|             <div class="input-group input-group-sm"> | ||||
|                 <div class="input-group-text" i18n>Page</div> | ||||
|                 <input class="form-control" type="number" min="1" [(ngModel)]="page" /> | ||||
|                 <div class="input-group-text" i18n>of {{totalPages}}</div> | ||||
|             </div> | ||||
|             <div class="pdf-viewer-container w-100 mt-3"> | ||||
|                 <pdf-viewer [src]="pdfSrc" [(page)]="page" | ||||
|                 [original-size]="false" | ||||
|                 [zoom]="1" | ||||
|                 zoom-scale="page-fit" | ||||
|                 (after-load-complete)="pdfPreviewLoaded($event)"> | ||||
|                 </pdf-viewer> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div class="col-5"> | ||||
|             <div class="d-grid"> | ||||
|                 <button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="!canSplit"> | ||||
|                     <i-bs name="plus-circle"></i-bs>  | ||||
|                     <span i18n>Add Split</span> | ||||
|                 </button> | ||||
|             </div> | ||||
|  | ||||
|             <ul class="list-group mt-3"> | ||||
|                 @for (pageStr of pagesString.split(','); track pageStr; let i = $index) { | ||||
|                     <li class="list-group-item d-flex align-items-center"> | ||||
|                         {{pageStr}} | ||||
|                         @if (pagesString.split(',').length > 1) { | ||||
|                               | ||||
|                             <button class="btn btn-sm btn-danger ms-auto" (click)="removeSplit(i)"> | ||||
|                                 <i-bs name="trash"></i-bs> | ||||
|                             </button> | ||||
|                         } | ||||
|                     </li> | ||||
|                 } | ||||
|             </ul> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| <div class="modal-footer"> | ||||
|     <div class="form-check form-switch me-auto"> | ||||
|        <input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalSwitch" [(ngModel)]="deleteOriginal" [disabled]="!userOwnsDocument"> | ||||
|        <label class="form-check-label" for="deleteOriginalSwitch" i18n>Delete original document after successful split</label> | ||||
|      </div> | ||||
|     <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled"> | ||||
|             <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span> | ||||
|         </button> | ||||
|     <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled"> | ||||
|         {{btnCaption}} | ||||
|     </button> | ||||
| </div> | ||||
| @@ -1,9 +0,0 @@ | ||||
| .pdf-viewer-container { | ||||
|     background-color: gray; | ||||
|     height: 500px; | ||||
|  | ||||
|     pdf-viewer { | ||||
|       width: 100%; | ||||
|       height: 100%; | ||||
|     } | ||||
|   } | ||||
| @@ -1,107 +0,0 @@ | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
|  | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { PdfViewerModule } from 'ng2-pdf-viewer' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { of } from 'rxjs' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { SplitConfirmDialogComponent } from './split-confirm-dialog.component' | ||||
|  | ||||
| describe('SplitConfirmDialogComponent', () => { | ||||
|   let component: SplitConfirmDialogComponent | ||||
|   let fixture: ComponentFixture<SplitConfirmDialogComponent> | ||||
|   let documentService: DocumentService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       imports: [ | ||||
|         NgxBootstrapIconsModule.pick(allIcons), | ||||
|         ReactiveFormsModule, | ||||
|         FormsModule, | ||||
|         PdfViewerModule, | ||||
|         SplitConfirmDialogComponent, | ||||
|       ], | ||||
|       providers: [ | ||||
|         NgbActiveModal, | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|  | ||||
|     fixture = TestBed.createComponent(SplitConfirmDialogComponent) | ||||
|     documentService = TestBed.inject(DocumentService) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should load document on init', () => { | ||||
|     const getSpy = jest.spyOn(documentService, 'get') | ||||
|     component.documentID = 1 | ||||
|     getSpy.mockReturnValue(of({ id: 1 } as any)) | ||||
|     component.ngOnInit() | ||||
|     expect(documentService.get).toHaveBeenCalledWith(1) | ||||
|   }) | ||||
|  | ||||
|   it('should update pagesString when pages are added', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 2 | ||||
|     component.addSplit() | ||||
|     expect(component.pagesString).toEqual('1-2,3-5') | ||||
|     component.page = 4 | ||||
|     component.addSplit() | ||||
|     expect(component.pagesString).toEqual('1-2,3-4,5') | ||||
|   }) | ||||
|  | ||||
|   it('should update pagesString when pages are removed', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 2 | ||||
|     component.addSplit() | ||||
|     component.page = 4 | ||||
|     component.addSplit() | ||||
|     expect(component.pagesString).toEqual('1-2,3-4,5') | ||||
|     component.removeSplit(0) | ||||
|     expect(component.pagesString).toEqual('1-4,5') | ||||
|   }) | ||||
|  | ||||
|   it('should enable confirm button when pages are added', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 2 | ||||
|     component.addSplit() | ||||
|     expect(component.confirmButtonEnabled).toBeTruthy() | ||||
|   }) | ||||
|  | ||||
|   it('should disable confirm button when all pages are removed', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 2 | ||||
|     component.addSplit() | ||||
|     component.removeSplit(0) | ||||
|     expect(component.confirmButtonEnabled).toBeFalsy() | ||||
|   }) | ||||
|  | ||||
|   it('should not add split if page is the last page', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 5 | ||||
|     component.addSplit() | ||||
|     expect(component.pagesString).toEqual('1-5') | ||||
|   }) | ||||
|  | ||||
|   it('should update totalPages when pdf is loaded', () => { | ||||
|     component.pdfPreviewLoaded({ numPages: 5 } as any) | ||||
|     expect(component.totalPages).toEqual(5) | ||||
|   }) | ||||
|  | ||||
|   it('should correctly disable split button', () => { | ||||
|     component.totalPages = 5 | ||||
|     component.page = 1 | ||||
|     expect(component.canSplit).toBeTruthy() | ||||
|     component.page = 5 | ||||
|     expect(component.canSplit).toBeFalsy() | ||||
|     component.page = 4 | ||||
|     expect(component.canSplit).toBeTruthy() | ||||
|     component['pages'] = new Set([1, 2, 3, 4]) | ||||
|     expect(component.canSplit).toBeFalsy() | ||||
|   }) | ||||
| }) | ||||
| @@ -1,98 +0,0 @@ | ||||
| import { Component, OnInit, inject } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { Document } from 'src/app/data/document' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { ConfirmDialogComponent } from '../confirm-dialog.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-split-confirm-dialog', | ||||
|   templateUrl: './split-confirm-dialog.component.html', | ||||
|   styleUrl: './split-confirm-dialog.component.scss', | ||||
|   imports: [ | ||||
|     FormsModule, | ||||
|     ReactiveFormsModule, | ||||
|     NgxBootstrapIconsModule, | ||||
|     PdfViewerModule, | ||||
|   ], | ||||
| }) | ||||
| export class SplitConfirmDialogComponent | ||||
|   extends ConfirmDialogComponent | ||||
|   implements OnInit | ||||
| { | ||||
|   private documentService = inject(DocumentService) | ||||
|   private permissionService = inject(PermissionsService) | ||||
|  | ||||
|   public get pagesString(): string { | ||||
|     let pagesStr = '' | ||||
|  | ||||
|     let lastPage = 1 | ||||
|     for (let i = 1; i <= this.totalPages; i++) { | ||||
|       if (this.pages.has(i) || i === this.totalPages) { | ||||
|         if (lastPage === i) { | ||||
|           pagesStr += `${i},` | ||||
|           lastPage = Math.min(i + 1, this.totalPages) | ||||
|         } else { | ||||
|           pagesStr += `${lastPage}-${i},` | ||||
|           lastPage = Math.min(i + 1, this.totalPages) | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return pagesStr.replace(/,$/, '') | ||||
|   } | ||||
|  | ||||
|   private pages: Set<number> = new Set() | ||||
|  | ||||
|   public documentID: number | ||||
|   private document: Document | ||||
|   public page: number = 1 | ||||
|   public totalPages: number | ||||
|   public deleteOriginal: boolean = false | ||||
|  | ||||
|   public get canSplit(): boolean { | ||||
|     return ( | ||||
|       this.page < this.totalPages && | ||||
|       this.pages.size < this.totalPages - 1 && | ||||
|       !this.pages.has(this.page) | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   public get pdfSrc(): string { | ||||
|     return this.documentService.getPreviewUrl(this.documentID) | ||||
|   } | ||||
|  | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.confirmButtonEnabled = this.pages.size > 0 | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.documentService.get(this.documentID).subscribe((r) => { | ||||
|       this.document = r | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   pdfPreviewLoaded(pdf: PDFDocumentProxy) { | ||||
|     this.totalPages = pdf.numPages | ||||
|   } | ||||
|  | ||||
|   addSplit() { | ||||
|     if (this.page === this.totalPages) return | ||||
|     this.pages.add(this.page) | ||||
|     this.pages = new Set(Array.from(this.pages).sort((a, b) => a - b)) | ||||
|     this.confirmButtonEnabled = this.pages.size > 0 | ||||
|   } | ||||
|  | ||||
|   removeSplit(i: number) { | ||||
|     let page = Array.from(this.pages)[Math.min(i, this.pages.size - 1)] | ||||
|     this.pages.delete(page) | ||||
|     this.confirmButtonEnabled = this.pages.size > 0 | ||||
|   } | ||||
|  | ||||
|   get userOwnsDocument(): boolean { | ||||
|     return this.permissionService.currentUserOwnsObject(this.document) | ||||
|   } | ||||
| } | ||||
| @@ -35,6 +35,9 @@ | ||||
|             @case (CustomFieldDataType.Select) { | ||||
|                 <span [ngbTooltip]="nameTooltip">{{getSelectValue(field, value)}}</span> | ||||
|             } | ||||
|             @case (CustomFieldDataType.LongText) { | ||||
|                 <p class="mb-0" [ngbTooltip]="nameTooltip">{{value | slice:0:20}}{{value.length > 20 ? '...' : ''}}</p> | ||||
|             } | ||||
|             @default { | ||||
|               <span [ngbTooltip]="nameTooltip">{{value}}</span> | ||||
|             } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { CurrencyPipe, getLocaleCurrencyCode } from '@angular/common' | ||||
| import { Component, Input, LOCALE_ID, OnInit, inject } from '@angular/core' | ||||
| import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common' | ||||
| import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core' | ||||
| import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { takeUntil } from 'rxjs' | ||||
| import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' | ||||
| @@ -14,7 +14,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading | ||||
|   selector: 'pngx-custom-field-display', | ||||
|   templateUrl: './custom-field-display.component.html', | ||||
|   styleUrl: './custom-field-display.component.scss', | ||||
|   imports: [CustomDatePipe, CurrencyPipe, NgbTooltipModule], | ||||
|   imports: [CustomDatePipe, CurrencyPipe, NgbTooltipModule, SlicePipe], | ||||
| }) | ||||
| export class CustomFieldDisplayComponent | ||||
|   extends LoadingComponentWithPermissions | ||||
|   | ||||
| @@ -1,28 +1,36 @@ | ||||
| <div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions"> | ||||
|   <button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled"> | ||||
|     <i-bs name="{{icon}}"></i-bs> | ||||
|     <div class="d-none d-sm-inline"> {{title}}</div> | ||||
|     @if (isActive) { | ||||
|       <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge> | ||||
|     } | ||||
|   </button> | ||||
|   <div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}"> | ||||
|     <div class="list-group list-group-flush"> | ||||
|       @for (element of selectionModel.queries; track element.id; let i = $index) { | ||||
|         <div class="list-group-item px-0 d-flex flex-nowrap"> | ||||
|           @switch (element.type) { | ||||
|             @case (CustomFieldQueryComponentType.Atom) { | ||||
|               <ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container> | ||||
|             } | ||||
|             @case (CustomFieldQueryComponentType.Expression) { | ||||
|               <ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container> | ||||
|             } | ||||
|           } | ||||
|         </div> | ||||
| @if (useDropdown) { | ||||
|   <div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions"> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled"> | ||||
|       <i-bs name="{{icon}}"></i-bs> | ||||
|       <div class="d-none d-sm-inline"> {{title}}</div> | ||||
|       @if (isActive) { | ||||
|         <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge> | ||||
|       } | ||||
|     </button> | ||||
|     <div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}"> | ||||
|       <ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
| } @else { | ||||
|   <ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container> | ||||
| } | ||||
|  | ||||
| <ng-template #list let-queries="queries"> | ||||
|   <div class="list-group list-group-flush"> | ||||
|     @for (element of queries; track element.id; let i = $index) { | ||||
|       <div class="list-group-item px-0 d-flex flex-nowrap"> | ||||
|         @switch (element.type) { | ||||
|           @case (CustomFieldQueryComponentType.Atom) { | ||||
|             <ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container> | ||||
|           } | ||||
|           @case (CustomFieldQueryComponentType.Expression) { | ||||
|             <ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container> | ||||
|           } | ||||
|         } | ||||
|       </div> | ||||
|     } | ||||
|   </div> | ||||
| </ng-template> | ||||
|  | ||||
| <ng-template #comparisonValueTemplate let-atom="atom"> | ||||
|   @if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) { | ||||
|   | ||||
| @@ -41,9 +41,3 @@ | ||||
|     min-width: 140px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .btn-group-xs { | ||||
|   > .btn { | ||||
|     border-radius: 0.15rem; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -120,6 +120,12 @@ export class CustomFieldQueriesModel { | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   addInitialAtom() { | ||||
|     this.addAtom( | ||||
|       new CustomFieldQueryAtom([null, CustomFieldQueryOperator.Exists, 'true']) | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   private findElement( | ||||
|     queryElement: CustomFieldQueryElement, | ||||
|     elements: any[] | ||||
| @@ -206,6 +212,9 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm | ||||
|   @Input() | ||||
|   applyOnClose = false | ||||
|  | ||||
|   @Input() | ||||
|   useDropdown: boolean = true | ||||
|  | ||||
|   get name(): string { | ||||
|     return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null | ||||
|   } | ||||
| @@ -246,7 +255,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm | ||||
|  | ||||
|   customFields: CustomField[] = [] | ||||
|  | ||||
|   public readonly today: string = new Date().toISOString().split('T')[0] | ||||
|   public readonly today: string = new Date().toLocaleDateString('en-CA') | ||||
|  | ||||
|   constructor() { | ||||
|     super() | ||||
| @@ -258,13 +267,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm | ||||
|   public onOpenChange(open: boolean) { | ||||
|     if (open) { | ||||
|       if (this.selectionModel.queries.length === 0) { | ||||
|         this.selectionModel.addAtom( | ||||
|           new CustomFieldQueryAtom([ | ||||
|             null, | ||||
|             CustomFieldQueryOperator.Exists, | ||||
|             'true', | ||||
|           ]) | ||||
|         ) | ||||
|         this.selectionModel.addInitialAtom() | ||||
|       } | ||||
|       if ( | ||||
|         this.selectionModel.queries.length === 1 && | ||||
|   | ||||
| @@ -11,7 +11,7 @@ | ||||
|         <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="check" class="variant-unfocused text-dark"></i-bs> | ||||
|               <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> | ||||
|             </a> | ||||
|           } | ||||
|   | ||||
| @@ -165,7 +165,7 @@ export class DatesDropdownComponent implements OnInit, OnDestroy { | ||||
|   @Input() | ||||
|   placement: string = 'bottom-start' | ||||
|  | ||||
|   public readonly today: string = new Date().toISOString().split('T')[0] | ||||
|   public readonly today: string = new Date().toLocaleDateString('en-CA') | ||||
|  | ||||
|   get isActive(): boolean { | ||||
|     return ( | ||||
|   | ||||
| @@ -28,8 +28,15 @@ | ||||
|               </div> | ||||
|             } | ||||
|           </div> | ||||
|           @if (object?.id) { | ||||
|             <small class="d-block mt-2" i18n>Warning: existing instances of this field will retain their current value index (e.g. option #1, #2, #3) after editing the options here</small> | ||||
|           @if (allSelectOptions.length > SELECT_OPTION_PAGE_SIZE) { | ||||
|             <ngb-pagination | ||||
|               class="d-flex justify-content-end" | ||||
|               [pageSize]="SELECT_OPTION_PAGE_SIZE" | ||||
|               [collectionSize]="allSelectOptions.length" | ||||
|               [(page)]="selectOptionsPage" | ||||
|               [maxSize]="5" | ||||
|               size="sm" | ||||
|             ></ngb-pagination> | ||||
|           } | ||||
|         } | ||||
|         @case (CustomFieldDataType.Monetary) { | ||||
|   | ||||
| @@ -125,4 +125,42 @@ describe('CustomFieldEditDialogComponent', () => { | ||||
|     fixture.detectChanges() | ||||
|     expect(document.activeElement).toBe(selectOptionInputs.last.nativeElement) | ||||
|   }) | ||||
|  | ||||
|   it('should send all select options including those changed in form on save', () => { | ||||
|     component.dialogMode = EditDialogMode.EDIT | ||||
|     component.object = { | ||||
|       id: 1, | ||||
|       name: 'Field 1', | ||||
|       data_type: CustomFieldDataType.Select, | ||||
|       extra_data: { | ||||
|         select_options: Array.from({ length: 50 }, (_, i) => ({ | ||||
|           label: `Option ${i + 1}`, | ||||
|           id: `${i + 1}-xyz`, | ||||
|         })), | ||||
|       }, | ||||
|     } | ||||
|     fixture.detectChanges() | ||||
|     component.ngOnInit() | ||||
|     component.selectOptionsPage = 2 | ||||
|     fixture.detectChanges() | ||||
|     component.objectForm | ||||
|       .get('extra_data') | ||||
|       .get('select_options') | ||||
|       .get('0') | ||||
|       .get('label') | ||||
|       .setValue('Updated Option 9') | ||||
|     const formValues = (component as any).getFormValues() | ||||
|     // first item unchanged | ||||
|     expect(formValues.extra_data.select_options[0]).toEqual({ | ||||
|       label: 'Option 1', | ||||
|       id: '1-xyz', | ||||
|     }) | ||||
|     // page 2 first item updated | ||||
|     expect( | ||||
|       formValues.extra_data.select_options[component.SELECT_OPTION_PAGE_SIZE] | ||||
|     ).toEqual({ | ||||
|       label: 'Updated Option 9', | ||||
|       id: '9-xyz', | ||||
|     }) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -14,6 +14,7 @@ import { | ||||
|   FormsModule, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { takeUntil } from 'rxjs' | ||||
| import { | ||||
| @@ -28,6 +29,8 @@ import { SelectComponent } from '../../input/select/select.component' | ||||
| import { TextComponent } from '../../input/text/text.component' | ||||
| import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component' | ||||
|  | ||||
| const SELECT_OPTION_PAGE_SIZE = 8 | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-custom-field-edit-dialog', | ||||
|   templateUrl: './custom-field-edit-dialog.component.html', | ||||
| @@ -37,6 +40,7 @@ import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component' | ||||
|     TextComponent, | ||||
|     FormsModule, | ||||
|     ReactiveFormsModule, | ||||
|     NgbPaginationModule, | ||||
|     NgxBootstrapIconsModule, | ||||
|   ], | ||||
| }) | ||||
| @@ -45,6 +49,21 @@ export class CustomFieldEditDialogComponent | ||||
|   implements OnInit, AfterViewInit | ||||
| { | ||||
|   CustomFieldDataType = CustomFieldDataType | ||||
|   SELECT_OPTION_PAGE_SIZE = SELECT_OPTION_PAGE_SIZE | ||||
|  | ||||
|   private _allSelectOptions: any[] = [] | ||||
|   public get allSelectOptions(): any[] { | ||||
|     return this._allSelectOptions | ||||
|   } | ||||
|  | ||||
|   private _selectOptionsPage: number | ||||
|   public get selectOptionsPage(): number { | ||||
|     return this._selectOptionsPage | ||||
|   } | ||||
|   public set selectOptionsPage(v: number) { | ||||
|     this._selectOptionsPage = v | ||||
|     this.updateSelectOptions() | ||||
|   } | ||||
|  | ||||
|   @ViewChildren('selectOption') | ||||
|   private selectOptionInputs: QueryList<ElementRef> | ||||
| @@ -67,17 +86,10 @@ export class CustomFieldEditDialogComponent | ||||
|       this.objectForm.get('data_type').disable() | ||||
|     } | ||||
|     if (this.object?.data_type === CustomFieldDataType.Select) { | ||||
|       this.selectOptions.clear() | ||||
|       this.object.extra_data.select_options | ||||
|         .filter((option) => option) | ||||
|         .forEach((option) => | ||||
|           this.selectOptions.push( | ||||
|             new FormGroup({ | ||||
|               label: new FormControl(option.label), | ||||
|               id: new FormControl(option.id), | ||||
|             }) | ||||
|           ) | ||||
|         ) | ||||
|       this._allSelectOptions = [ | ||||
|         ...(this.object.extra_data.select_options ?? []), | ||||
|       ] | ||||
|       this.selectOptionsPage = 1 | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -87,6 +99,19 @@ export class CustomFieldEditDialogComponent | ||||
|       .subscribe(() => { | ||||
|         this.selectOptionInputs.last?.nativeElement.focus() | ||||
|       }) | ||||
|  | ||||
|     this.objectForm.valueChanges | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe((change) => { | ||||
|         // Update the relevant select options values if changed in the form, which is only a page of the entire list | ||||
|         this.objectForm | ||||
|           .get('extra_data.select_options') | ||||
|           ?.value.forEach((option, index) => { | ||||
|             this._allSelectOptions[ | ||||
|               index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE | ||||
|             ] = option | ||||
|           }) | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   getCreateTitle() { | ||||
| @@ -108,6 +133,17 @@ export class CustomFieldEditDialogComponent | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   protected getFormValues() { | ||||
|     const formValues = super.getFormValues() | ||||
|     if ( | ||||
|       this.objectForm.get('data_type')?.value === CustomFieldDataType.Select | ||||
|     ) { | ||||
|       // Make sure we send all select options, with updated values | ||||
|       formValues.extra_data.select_options = this._allSelectOptions | ||||
|     } | ||||
|     return formValues | ||||
|   } | ||||
|  | ||||
|   getDataTypes() { | ||||
|     return DATA_TYPE_LABELS | ||||
|   } | ||||
| @@ -116,13 +152,41 @@ export class CustomFieldEditDialogComponent | ||||
|     return this.dialogMode === EditDialogMode.EDIT | ||||
|   } | ||||
|  | ||||
|   private updateSelectOptions() { | ||||
|     this.selectOptions.clear() | ||||
|     this._allSelectOptions | ||||
|       .slice( | ||||
|         (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE, | ||||
|         this.selectOptionsPage * SELECT_OPTION_PAGE_SIZE | ||||
|       ) | ||||
|       .forEach((option) => | ||||
|         this.selectOptions.push( | ||||
|           new FormGroup({ | ||||
|             label: new FormControl(option.label), | ||||
|             id: new FormControl(option.id), | ||||
|           }) | ||||
|         ) | ||||
|       ) | ||||
|   } | ||||
|  | ||||
|   public addSelectOption() { | ||||
|     this.selectOptions.push( | ||||
|       new FormGroup({ label: new FormControl(null), id: new FormControl(null) }) | ||||
|     this._allSelectOptions.push({ label: null, id: null }) | ||||
|     this.selectOptionsPage = Math.ceil( | ||||
|       this.allSelectOptions.length / SELECT_OPTION_PAGE_SIZE | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   public removeSelectOption(index: number) { | ||||
|     this.selectOptions.removeAt(index) | ||||
|     const globalIndex = | ||||
|       index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE | ||||
|     this._allSelectOptions.splice(globalIndex, 1) | ||||
|  | ||||
|     const totalPages = Math.max( | ||||
|       1, | ||||
|       Math.ceil(this._allSelectOptions.length / SELECT_OPTION_PAGE_SIZE) | ||||
|     ) | ||||
|     const targetPage = Math.min(this.selectOptionsPage, totalPages) | ||||
|  | ||||
|     this.selectOptionsPage = targetPage | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -147,9 +147,13 @@ export abstract class EditDialogComponent< | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   protected getFormValues(): any { | ||||
|     return Object.assign({}, this.objectForm.value) | ||||
|   } | ||||
|  | ||||
|   save() { | ||||
|     this.error = null | ||||
|     const formValues = Object.assign({}, this.objectForm.value) | ||||
|     const formValues = this.getFormValues() | ||||
|     const permissionsObject: PermissionsFormObject = | ||||
|       this.objectForm.get('permissions_form')?.value | ||||
|     if (permissionsObject) { | ||||
|   | ||||
| @@ -12,6 +12,8 @@ | ||||
|  | ||||
|     <pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color> | ||||
|  | ||||
|     <pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags" [allowNull]="true" [error]="error?.parent"></pngx-input-select> | ||||
|  | ||||
|     <pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check> | ||||
|     <pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> | ||||
|     @if (patternRequired) { | ||||
|   | ||||
| @@ -35,11 +35,16 @@ import { TextComponent } from '../../input/text/text.component' | ||||
|   ], | ||||
| }) | ||||
| export class TagEditDialogComponent extends EditDialogComponent<Tag> { | ||||
|   tags: Tag[] | ||||
|  | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.service = inject(TagService) | ||||
|     this.userService = inject(UserService) | ||||
|     this.settingsService = inject(SettingsService) | ||||
|     this.service.listAll().subscribe((result) => { | ||||
|       this.tags = result.results | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   getCreateTitle() { | ||||
| @@ -55,6 +60,7 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> { | ||||
|       name: new FormControl(''), | ||||
|       color: new FormControl(randomColor()), | ||||
|       is_inbox_tag: new FormControl(false), | ||||
|       parent: new FormControl(null), | ||||
|       matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM), | ||||
|       match: new FormControl(''), | ||||
|       is_insensitive: new FormControl(true), | ||||
|   | ||||
| @@ -156,30 +156,97 @@ | ||||
|     <p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p> | ||||
|     <div class="row"> | ||||
|       <div class="col"> | ||||
|         <pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text> | ||||
|         <pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" horizontal="true" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text> | ||||
|         @if (formGroup.get('type').value === WorkflowTriggerType.Consumption) { | ||||
|           <pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select> | ||||
|           <pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a>" [error]="error?.filter_path"></pngx-input-text> | ||||
|           <pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select> | ||||
|           <pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" horizontal="true" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select> | ||||
|           <pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a>" [error]="error?.filter_path"></pngx-input-text> | ||||
|           <pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select> | ||||
|         } | ||||
|         @if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) { | ||||
|           <pngx-input-select i18n-title title="Content matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> | ||||
|           @if (patternRequired) { | ||||
|             <pngx-input-text i18n-title title="Content matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text> | ||||
|           <pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> | ||||
|           @if (matchingPatternRequired(formGroup)) { | ||||
|             <pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text> | ||||
|           } | ||||
|           @if (patternRequired) { | ||||
|             <pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive"></pngx-input-check> | ||||
|           @if (matchingPatternRequired(formGroup)) { | ||||
|             <pngx-input-check i18n-title title="Case insensitive" horizontal="true" formControlName="is_insensitive"></pngx-input-check> | ||||
|           } | ||||
|         } | ||||
|       </div> | ||||
|       @if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) { | ||||
|         <div class="col-md-6"> | ||||
|           <pngx-input-tags [allowCreate]="false" i18n-title title="Has any of tags" formControlName="filter_has_tags"></pngx-input-tags> | ||||
|           <pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select> | ||||
|           <pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select> | ||||
|         </div> | ||||
|       } | ||||
|     </div> | ||||
|     @if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) { | ||||
|       <div class="row mt-3"> | ||||
|         <div class="col"> | ||||
|           <div class="trigger-filters mb-3"> | ||||
|             <div class="d-flex align-items-center"> | ||||
|               <label class="form-label mb-0" i18n>Advanced Filters</label> | ||||
|               <button | ||||
|                 type="button" | ||||
|                 class="btn btn-sm btn-outline-primary ms-auto" | ||||
|                 (click)="addFilter(formGroup)" | ||||
|                 [disabled]="!canAddFilter(formGroup)" | ||||
|               > | ||||
|                 <i-bs name="plus-circle"></i-bs> <span i18n>Add filter</span> | ||||
|               </button> | ||||
|             </div> | ||||
|             <ul class="mt-2 list-group filters" formArrayName="filters"> | ||||
|               @if (getFiltersFormArray(formGroup).length === 0) { | ||||
|                 <p class="text-muted small" i18n>No advanced workflow filters defined.</p> | ||||
|               } | ||||
|               @for (filter of getFiltersFormArray(formGroup).controls; track filter; let filterIndex = $index) { | ||||
|                 <li [formGroupName]="filterIndex" class="list-group-item"> | ||||
|                   <div class="d-flex align-items-center gap-2"> | ||||
|                     <div class="w-25"> | ||||
|                       <pngx-input-select | ||||
|                         i18n-title | ||||
|                         [items]="getFilterTypeOptions(formGroup, filterIndex)" | ||||
|                         formControlName="type" | ||||
|                         [allowNull]="false" | ||||
|                       ></pngx-input-select> | ||||
|                     </div> | ||||
|                     <div class="flex-grow-1"> | ||||
|                       @if (isTagsFilter(filter.get('type').value)) { | ||||
|                         <pngx-input-tags | ||||
|                           [allowCreate]="false" | ||||
|                           [title]="null" | ||||
|                           formControlName="values" | ||||
|                         ></pngx-input-tags> | ||||
|                       } @else if ( | ||||
|                         isCustomFieldQueryFilter(filter.get('type').value) | ||||
|                       ) { | ||||
|                         <pngx-custom-fields-query-dropdown | ||||
|                           [selectionModel]="getCustomFieldQueryModel(filter)" | ||||
|                           (selectionModelChange)="onCustomFieldQuerySelectionChange(filter, $event)" | ||||
|                           [useDropdown]="false" | ||||
|                         ></pngx-custom-fields-query-dropdown> | ||||
|                         @if (!isCustomFieldQueryValid(filter)) { | ||||
|                           <div class="text-danger small" i18n> | ||||
|                             Complete the custom field query configuration. | ||||
|                           </div> | ||||
|                         } | ||||
|                       } @else { | ||||
|                         <pngx-input-select | ||||
|                           [items]="getFilterSelectItems(filter.get('type').value)" | ||||
|                           [allowNull]="true" | ||||
|                           [multiple]="isSelectMultiple(filter.get('type').value)" | ||||
|                           formControlName="values" | ||||
|                         ></pngx-input-select> | ||||
|                       } | ||||
|                     </div> | ||||
|                     <button | ||||
|                       type="button" | ||||
|                       class="btn btn-link text-danger p-0" | ||||
|                       (click)="removeFilter(formGroup, filterIndex)" | ||||
|                     > | ||||
|                       <i-bs name="trash"></i-bs><span class="ms-1" i18n>Delete</span> | ||||
|                     </button> | ||||
|                   </div> | ||||
|                 </li> | ||||
|               } | ||||
|             </ul> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     } | ||||
|   </div> | ||||
| </ng-template> | ||||
|  | ||||
|   | ||||
| @@ -7,3 +7,7 @@ | ||||
| .accordion-button { | ||||
|     font-size: 1rem; | ||||
| } | ||||
|  | ||||
| :host ::ng-deep .filters .paperless-input-select.mb-3 { | ||||
|     margin-bottom: 0 !important; | ||||
| } | ||||
|   | ||||
| @@ -11,8 +11,14 @@ import { | ||||
| import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgSelectModule } from '@ng-select/ng-select' | ||||
| import { of } from 'rxjs' | ||||
| import { CustomFieldQueriesModel } from 'src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component' | ||||
| import { CustomFieldDataType } from 'src/app/data/custom-field' | ||||
| import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model' | ||||
| import { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query' | ||||
| import { | ||||
|   MATCHING_ALGORITHMS, | ||||
|   MATCH_AUTO, | ||||
|   MATCH_NONE, | ||||
| } from 'src/app/data/matching-model' | ||||
| import { Workflow } from 'src/app/data/workflow' | ||||
| import { | ||||
|   WorkflowAction, | ||||
| @@ -31,6 +37,7 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service | ||||
| import { MailRuleService } from 'src/app/services/rest/mail-rule.service' | ||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element' | ||||
| import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' | ||||
| import { NumberComponent } from '../../input/number/number.component' | ||||
| import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component' | ||||
| @@ -43,6 +50,7 @@ import { EditDialogMode } from '../edit-dialog.component' | ||||
| import { | ||||
|   DOCUMENT_SOURCE_OPTIONS, | ||||
|   SCHEDULE_DATE_FIELD_OPTIONS, | ||||
|   TriggerFilterType, | ||||
|   WORKFLOW_ACTION_OPTIONS, | ||||
|   WORKFLOW_TYPE_OPTIONS, | ||||
|   WorkflowEditDialogComponent, | ||||
| @@ -375,6 +383,562 @@ describe('WorkflowEditDialogComponent', () => { | ||||
|     expect(component.objectForm.get('actions').value[0].webhook).toBeNull() | ||||
|   }) | ||||
|  | ||||
|   it('should require matching pattern when algorithm is not none', () => { | ||||
|     const triggerGroup = new FormGroup({ | ||||
|       matching_algorithm: new FormControl(MATCH_AUTO), | ||||
|       match: new FormControl(''), | ||||
|     }) | ||||
|     expect(component.matchingPatternRequired(triggerGroup)).toBe(true) | ||||
|     triggerGroup.get('matching_algorithm').setValue(MATCHING_ALGORITHMS[0].id) | ||||
|     expect(component.matchingPatternRequired(triggerGroup)).toBe(true) | ||||
|     triggerGroup.get('matching_algorithm').setValue(MATCH_NONE) | ||||
|     expect(component.matchingPatternRequired(triggerGroup)).toBe(false) | ||||
|   }) | ||||
|  | ||||
|   it('should map filter builder values into trigger filters on save', () => { | ||||
|     component.object = undefined | ||||
|     component.addTrigger() | ||||
|     const triggerGroup = component.triggerFields.at(0) | ||||
|     component.addFilter(triggerGroup as FormGroup) | ||||
|     component.addFilter(triggerGroup as FormGroup) | ||||
|     component.addFilter(triggerGroup as FormGroup) | ||||
|  | ||||
|     const filters = component.getFiltersFormArray(triggerGroup as FormGroup) | ||||
|     expect(filters.length).toBe(3) | ||||
|  | ||||
|     filters.at(0).get('values').setValue([1]) | ||||
|     filters.at(1).get('values').setValue([2, 3]) | ||||
|     filters.at(2).get('values').setValue([4]) | ||||
|  | ||||
|     const addFilterOfType = (type: TriggerFilterType) => { | ||||
|       const newFilter = component.addFilter(triggerGroup as FormGroup) | ||||
|       newFilter.get('type').setValue(type) | ||||
|       return newFilter | ||||
|     } | ||||
|  | ||||
|     const correspondentIs = addFilterOfType(TriggerFilterType.CorrespondentIs) | ||||
|     correspondentIs.get('values').setValue(1) | ||||
|  | ||||
|     const correspondentNot = addFilterOfType(TriggerFilterType.CorrespondentNot) | ||||
|     correspondentNot.get('values').setValue([1]) | ||||
|  | ||||
|     const documentTypeIs = addFilterOfType(TriggerFilterType.DocumentTypeIs) | ||||
|     documentTypeIs.get('values').setValue(1) | ||||
|  | ||||
|     const documentTypeNot = addFilterOfType(TriggerFilterType.DocumentTypeNot) | ||||
|     documentTypeNot.get('values').setValue([1]) | ||||
|  | ||||
|     const storagePathIs = addFilterOfType(TriggerFilterType.StoragePathIs) | ||||
|     storagePathIs.get('values').setValue(1) | ||||
|  | ||||
|     const storagePathNot = addFilterOfType(TriggerFilterType.StoragePathNot) | ||||
|     storagePathNot.get('values').setValue([1]) | ||||
|  | ||||
|     const customFieldFilter = addFilterOfType( | ||||
|       TriggerFilterType.CustomFieldQuery | ||||
|     ) | ||||
|     const customFieldQuery = JSON.stringify(['AND', [[1, 'exact', 'test']]]) | ||||
|     customFieldFilter.get('values').setValue(customFieldQuery) | ||||
|  | ||||
|     const formValues = component['getFormValues']() | ||||
|  | ||||
|     expect(formValues.triggers[0].filter_has_tags).toEqual([1]) | ||||
|     expect(formValues.triggers[0].filter_has_all_tags).toEqual([2, 3]) | ||||
|     expect(formValues.triggers[0].filter_has_not_tags).toEqual([4]) | ||||
|     expect(formValues.triggers[0].filter_has_correspondent).toEqual(1) | ||||
|     expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([1]) | ||||
|     expect(formValues.triggers[0].filter_has_document_type).toEqual(1) | ||||
|     expect(formValues.triggers[0].filter_has_not_document_types).toEqual([1]) | ||||
|     expect(formValues.triggers[0].filter_has_storage_path).toEqual(1) | ||||
|     expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([1]) | ||||
|     expect(formValues.triggers[0].filter_custom_field_query).toEqual( | ||||
|       customFieldQuery | ||||
|     ) | ||||
|     expect(formValues.triggers[0].filters).toBeUndefined() | ||||
|   }) | ||||
|  | ||||
|   it('should ignore empty and null filter values when mapping filters', () => { | ||||
|     component.object = undefined | ||||
|     component.addTrigger() | ||||
|     const triggerGroup = component.triggerFields.at(0) as FormGroup | ||||
|  | ||||
|     const tagsFilter = component.addFilter(triggerGroup) | ||||
|     tagsFilter.get('type').setValue(TriggerFilterType.TagsAny) | ||||
|     tagsFilter.get('values').setValue([]) | ||||
|  | ||||
|     const correspondentFilter = component.addFilter(triggerGroup) | ||||
|     correspondentFilter.get('type').setValue(TriggerFilterType.CorrespondentIs) | ||||
|     correspondentFilter.get('values').setValue(null) | ||||
|  | ||||
|     const formValues = component['getFormValues']() | ||||
|  | ||||
|     expect(formValues.triggers[0].filter_has_tags).toEqual([]) | ||||
|     expect(formValues.triggers[0].filter_has_correspondent).toBeNull() | ||||
|   }) | ||||
|  | ||||
|   it('should derive single select filters from array values', () => { | ||||
|     component.object = undefined | ||||
|     component.addTrigger() | ||||
|     const triggerGroup = component.triggerFields.at(0) as FormGroup | ||||
|  | ||||
|     const addFilterOfType = (type: TriggerFilterType, value: any) => { | ||||
|       const filter = component.addFilter(triggerGroup) | ||||
|       filter.get('type').setValue(type) | ||||
|       filter.get('values').setValue(value) | ||||
|     } | ||||
|  | ||||
|     addFilterOfType(TriggerFilterType.CorrespondentIs, [5]) | ||||
|     addFilterOfType(TriggerFilterType.DocumentTypeIs, [6]) | ||||
|     addFilterOfType(TriggerFilterType.StoragePathIs, [7]) | ||||
|  | ||||
|     const formValues = component['getFormValues']() | ||||
|  | ||||
|     expect(formValues.triggers[0].filter_has_correspondent).toEqual(5) | ||||
|     expect(formValues.triggers[0].filter_has_document_type).toEqual(6) | ||||
|     expect(formValues.triggers[0].filter_has_storage_path).toEqual(7) | ||||
|   }) | ||||
|  | ||||
|   it('should convert multi-value filter values when aggregating filters', () => { | ||||
|     component.object = undefined | ||||
|     component.addTrigger() | ||||
|     const triggerGroup = component.triggerFields.at(0) as FormGroup | ||||
|  | ||||
|     const setFilter = (type: TriggerFilterType, value: number): void => { | ||||
|       const filter = component.addFilter(triggerGroup) as FormGroup | ||||
|       filter.get('type').setValue(type) | ||||
|       filter.get('values').setValue(value) | ||||
|     } | ||||
|  | ||||
|     setFilter(TriggerFilterType.TagsAll, 11) | ||||
|     setFilter(TriggerFilterType.TagsNone, 12) | ||||
|     setFilter(TriggerFilterType.CorrespondentNot, 13) | ||||
|     setFilter(TriggerFilterType.DocumentTypeNot, 14) | ||||
|     setFilter(TriggerFilterType.StoragePathNot, 15) | ||||
|  | ||||
|     const formValues = component['getFormValues']() | ||||
|  | ||||
|     expect(formValues.triggers[0].filter_has_all_tags).toEqual([11]) | ||||
|     expect(formValues.triggers[0].filter_has_not_tags).toEqual([12]) | ||||
|     expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([13]) | ||||
|     expect(formValues.triggers[0].filter_has_not_document_types).toEqual([14]) | ||||
|     expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([15]) | ||||
|   }) | ||||
|  | ||||
|   it('should reuse filter type options and update disabled state', () => { | ||||
|     component.object = undefined | ||||
|     component.addTrigger() | ||||
|     const triggerGroup = component.triggerFields.at(0) as FormGroup | ||||
|     component.addFilter(triggerGroup) | ||||
|  | ||||
|     const optionsFirst = component.getFilterTypeOptions(triggerGroup, 0) | ||||
|     const optionsSecond = component.getFilterTypeOptions(triggerGroup, 0) | ||||
|     expect(optionsFirst).toBe(optionsSecond) | ||||
|  | ||||
|     // to force disabled flag | ||||
|     component.addFilter(triggerGroup) | ||||
|     const filterArray = component.getFiltersFormArray(triggerGroup) | ||||
|     const firstFilter = filterArray.at(0) | ||||
|     firstFilter.get('type').setValue(TriggerFilterType.CorrespondentIs) | ||||
|  | ||||
|     component.addFilter(triggerGroup) | ||||
|     const updatedFilters = component.getFiltersFormArray(triggerGroup) | ||||
|     const secondFilter = updatedFilters.at(1) | ||||
|     const options = component.getFilterTypeOptions(triggerGroup, 1) | ||||
|     const correspondentIsOption = options.find( | ||||
|       (option) => option.id === TriggerFilterType.CorrespondentIs | ||||
|     ) | ||||
|     expect(correspondentIsOption.disabled).toBe(true) | ||||
|  | ||||
|     firstFilter.get('type').setValue(TriggerFilterType.DocumentTypeNot) | ||||
|     secondFilter.get('type').setValue(TriggerFilterType.TagsAll) | ||||
|     const postChangeOptions = component.getFilterTypeOptions(triggerGroup, 1) | ||||
|     const correspondentOptionAfter = postChangeOptions.find( | ||||
|       (option) => option.id === TriggerFilterType.CorrespondentIs | ||||
|     ) | ||||
|     expect(correspondentOptionAfter.disabled).toBe(false) | ||||
|   }) | ||||
|  | ||||
|   it('should keep multi-entry filter options enabled and allow duplicates', () => { | ||||
|     component.object = undefined | ||||
|     component.addTrigger() | ||||
|     const triggerGroup = component.triggerFields.at(0) as FormGroup | ||||
|  | ||||
|     component.filterDefinitions = [ | ||||
|       { | ||||
|         id: TriggerFilterType.TagsAny, | ||||
|         name: 'Any tags', | ||||
|         inputType: 'tags', | ||||
|         allowMultipleEntries: true, | ||||
|         allowMultipleValues: true, | ||||
|       } as any, | ||||
|       { | ||||
|         id: TriggerFilterType.CorrespondentIs, | ||||
|         name: 'Correspondent is', | ||||
|         inputType: 'select', | ||||
|         allowMultipleEntries: false, | ||||
|         allowMultipleValues: false, | ||||
|         selectItems: 'correspondents', | ||||
|       } as any, | ||||
|     ] | ||||
|  | ||||
|     const firstFilter = component.addFilter(triggerGroup) | ||||
|     firstFilter.get('type').setValue(TriggerFilterType.TagsAny) | ||||
|  | ||||
|     const secondFilter = component.addFilter(triggerGroup) | ||||
|     expect(secondFilter).not.toBeNull() | ||||
|  | ||||
|     const options = component.getFilterTypeOptions(triggerGroup, 1) | ||||
|     const multiEntryOption = options.find( | ||||
|       (option) => option.id === TriggerFilterType.TagsAny | ||||
|     ) | ||||
|  | ||||
|     expect(multiEntryOption.disabled).toBe(false) | ||||
|     expect(component.canAddFilter(triggerGroup)).toBe(true) | ||||
|   }) | ||||
|  | ||||
|   it('should return null when no filter definitions remain available', () => { | ||||
|     component.object = undefined | ||||
|     component.addTrigger() | ||||
|     const triggerGroup = component.triggerFields.at(0) as FormGroup | ||||
|  | ||||
|     component.filterDefinitions = [ | ||||
|       { | ||||
|         id: TriggerFilterType.TagsAny, | ||||
|         name: 'Any tags', | ||||
|         inputType: 'tags', | ||||
|         allowMultipleEntries: false, | ||||
|         allowMultipleValues: true, | ||||
|       } as any, | ||||
|       { | ||||
|         id: TriggerFilterType.CorrespondentIs, | ||||
|         name: 'Correspondent is', | ||||
|         inputType: 'select', | ||||
|         allowMultipleEntries: false, | ||||
|         allowMultipleValues: false, | ||||
|         selectItems: 'correspondents', | ||||
|       } as any, | ||||
|     ] | ||||
|  | ||||
|     const firstFilter = component.addFilter(triggerGroup) | ||||
|     firstFilter.get('type').setValue(TriggerFilterType.TagsAny) | ||||
|     const secondFilter = component.addFilter(triggerGroup) | ||||
|     secondFilter.get('type').setValue(TriggerFilterType.CorrespondentIs) | ||||
|  | ||||
|     expect(component.canAddFilter(triggerGroup)).toBe(false) | ||||
|     expect(component.addFilter(triggerGroup)).toBeNull() | ||||
|   }) | ||||
|  | ||||
|   it('should skip filter definitions without handlers when building form array', () => { | ||||
|     const originalDefinitions = component.filterDefinitions | ||||
|     component.filterDefinitions = [ | ||||
|       { | ||||
|         id: 999, | ||||
|         name: 'Unsupported', | ||||
|         inputType: 'text', | ||||
|         allowMultipleEntries: false, | ||||
|         allowMultipleValues: false, | ||||
|       } as any, | ||||
|     ] | ||||
|  | ||||
|     const trigger = { | ||||
|       filter_has_tags: [], | ||||
|       filter_has_all_tags: [], | ||||
|       filter_has_not_tags: [], | ||||
|       filter_has_not_correspondents: [], | ||||
|       filter_has_not_document_types: [], | ||||
|       filter_has_not_storage_paths: [], | ||||
|       filter_has_correspondent: null, | ||||
|       filter_has_document_type: null, | ||||
|       filter_has_storage_path: null, | ||||
|       filter_custom_field_query: null, | ||||
|     } as any | ||||
|  | ||||
|     const filters = component['buildFiltersFormArray'](trigger) | ||||
|     expect(filters.length).toBe(0) | ||||
|  | ||||
|     component.filterDefinitions = originalDefinitions | ||||
|   }) | ||||
|  | ||||
|   it('should return null when adding filter for unknown trigger form group', () => { | ||||
|     expect(component.addFilter(new FormGroup({}) as any)).toBeNull() | ||||
|   }) | ||||
|  | ||||
|   it('should ignore remove filter calls for unknown trigger form group', () => { | ||||
|     expect(() => | ||||
|       component.removeFilter(new FormGroup({}) as any, 0) | ||||
|     ).not.toThrow() | ||||
|   }) | ||||
|  | ||||
|   it('should teardown custom field query model when removing a custom field filter', () => { | ||||
|     component.object = undefined | ||||
|     component.addTrigger() | ||||
|     const triggerGroup = component.triggerFields.at(0) as FormGroup | ||||
|  | ||||
|     component.addFilter(triggerGroup) | ||||
|     const filters = component.getFiltersFormArray(triggerGroup) | ||||
|     const filterGroup = filters.at(0) as FormGroup | ||||
|     filterGroup.get('type').setValue(TriggerFilterType.CustomFieldQuery) | ||||
|  | ||||
|     const model = component.getCustomFieldQueryModel(filterGroup) | ||||
|     expect(model).toBeDefined() | ||||
|     expect( | ||||
|       component['getStoredCustomFieldQueryModel'](filterGroup as any) | ||||
|     ).toBe(model) | ||||
|  | ||||
|     component.removeFilter(triggerGroup, 0) | ||||
|     expect( | ||||
|       component['getStoredCustomFieldQueryModel'](filterGroup as any) | ||||
|     ).toBeNull() | ||||
|   }) | ||||
|  | ||||
|   it('should return readable filter names', () => { | ||||
|     expect(component.getFilterName(TriggerFilterType.TagsAny)).toBe( | ||||
|       'Has any of these tags' | ||||
|     ) | ||||
|     expect(component.getFilterName(999 as any)).toBe('') | ||||
|   }) | ||||
|  | ||||
|   it('should build filter form array from existing trigger filters', () => { | ||||
|     const trigger = workflow.triggers[0] | ||||
|     trigger.filter_has_tags = [1] | ||||
|     trigger.filter_has_all_tags = [2, 3] | ||||
|     trigger.filter_has_not_tags = [4] | ||||
|     trigger.filter_has_correspondent = 5 as any | ||||
|     trigger.filter_has_not_correspondents = [6] as any | ||||
|     trigger.filter_has_document_type = 7 as any | ||||
|     trigger.filter_has_not_document_types = [8] as any | ||||
|     trigger.filter_has_storage_path = 9 as any | ||||
|     trigger.filter_has_not_storage_paths = [10] as any | ||||
|     trigger.filter_custom_field_query = JSON.stringify([ | ||||
|       'AND', | ||||
|       [[1, 'exact', 'value']], | ||||
|     ]) as any | ||||
|  | ||||
|     component.object = workflow | ||||
|     component.ngOnInit() | ||||
|     const triggerGroup = component.triggerFields.at(0) as FormGroup | ||||
|     const filters = component.getFiltersFormArray(triggerGroup) | ||||
|     expect(filters.length).toBe(10) | ||||
|     const customFieldFilter = filters.at(9) as FormGroup | ||||
|     expect(customFieldFilter.get('type').value).toBe( | ||||
|       TriggerFilterType.CustomFieldQuery | ||||
|     ) | ||||
|     const model = component.getCustomFieldQueryModel(customFieldFilter) | ||||
|     expect(model.isValid()).toBe(true) | ||||
|   }) | ||||
|  | ||||
|   it('should expose select metadata helpers', () => { | ||||
|     expect(component.isSelectMultiple(TriggerFilterType.CorrespondentNot)).toBe( | ||||
|       true | ||||
|     ) | ||||
|     expect(component.isSelectMultiple(TriggerFilterType.CorrespondentIs)).toBe( | ||||
|       false | ||||
|     ) | ||||
|  | ||||
|     component.correspondents = [{ id: 1, name: 'C1' } as any] | ||||
|     component.documentTypes = [{ id: 2, name: 'DT' } as any] | ||||
|     component.storagePaths = [{ id: 3, name: 'SP' } as any] | ||||
|  | ||||
|     expect( | ||||
|       component.getFilterSelectItems(TriggerFilterType.CorrespondentIs) | ||||
|     ).toEqual(component.correspondents) | ||||
|     expect( | ||||
|       component.getFilterSelectItems(TriggerFilterType.DocumentTypeIs) | ||||
|     ).toEqual(component.documentTypes) | ||||
|     expect( | ||||
|       component.getFilterSelectItems(TriggerFilterType.StoragePathIs) | ||||
|     ).toEqual(component.storagePaths) | ||||
|     expect(component.getFilterSelectItems(TriggerFilterType.TagsAll)).toEqual( | ||||
|       [] | ||||
|     ) | ||||
|  | ||||
|     expect( | ||||
|       component.isCustomFieldQueryFilter(TriggerFilterType.CustomFieldQuery) | ||||
|     ).toBe(true) | ||||
|   }) | ||||
|  | ||||
|   it('should return empty select items when definition is missing', () => { | ||||
|     const originalDefinitions = component.filterDefinitions | ||||
|     component.filterDefinitions = [] | ||||
|  | ||||
|     expect( | ||||
|       component.getFilterSelectItems(TriggerFilterType.CorrespondentIs) | ||||
|     ).toEqual([]) | ||||
|  | ||||
|     component.filterDefinitions = originalDefinitions | ||||
|   }) | ||||
|  | ||||
|   it('should return empty select items when definition has unknown source', () => { | ||||
|     const originalDefinitions = component.filterDefinitions | ||||
|     component.filterDefinitions = [ | ||||
|       { | ||||
|         id: TriggerFilterType.CorrespondentIs, | ||||
|         name: 'Correspondent is', | ||||
|         inputType: 'select', | ||||
|         allowMultipleEntries: false, | ||||
|         allowMultipleValues: false, | ||||
|         selectItems: 'unknown', | ||||
|       } as any, | ||||
|     ] | ||||
|  | ||||
|     expect( | ||||
|       component.getFilterSelectItems(TriggerFilterType.CorrespondentIs) | ||||
|     ).toEqual([]) | ||||
|  | ||||
|     component.filterDefinitions = originalDefinitions | ||||
|   }) | ||||
|  | ||||
|   it('should handle custom field query selection change and validation states', () => { | ||||
|     const formGroup = new FormGroup({ | ||||
|       values: new FormControl(null), | ||||
|     }) | ||||
|     const model = new CustomFieldQueriesModel() | ||||
|  | ||||
|     const changeSpy = jest.spyOn( | ||||
|       component as any, | ||||
|       'onCustomFieldQueryModelChanged' | ||||
|     ) | ||||
|  | ||||
|     component.onCustomFieldQuerySelectionChange(formGroup, model) | ||||
|     expect(changeSpy).toHaveBeenCalledWith(formGroup, model) | ||||
|  | ||||
|     expect(component.isCustomFieldQueryValid(formGroup)).toBe(true) | ||||
|     component['setCustomFieldQueryModel'](formGroup as any, model as any) | ||||
|  | ||||
|     const validSpy = jest.spyOn(model, 'isValid').mockReturnValue(false) | ||||
|     const emptySpy = jest.spyOn(model, 'isEmpty').mockReturnValue(false) | ||||
|     expect(component.isCustomFieldQueryValid(formGroup)).toBe(false) | ||||
|     expect(validSpy).toHaveBeenCalled() | ||||
|  | ||||
|     validSpy.mockReturnValue(true) | ||||
|     emptySpy.mockReturnValue(true) | ||||
|     expect(component.isCustomFieldQueryValid(formGroup)).toBe(true) | ||||
|  | ||||
|     emptySpy.mockReturnValue(false) | ||||
|     expect(component.isCustomFieldQueryValid(formGroup)).toBe(true) | ||||
|  | ||||
|     component['clearCustomFieldQueryModel'](formGroup as any) | ||||
|   }) | ||||
|  | ||||
|   it('should recover from invalid custom field query json and update control on changes', () => { | ||||
|     const filterGroup = new FormGroup({ | ||||
|       values: new FormControl('not-json'), | ||||
|     }) | ||||
|  | ||||
|     component['ensureCustomFieldQueryModel'](filterGroup, 'not-json') | ||||
|  | ||||
|     const model = component['getStoredCustomFieldQueryModel']( | ||||
|       filterGroup as any | ||||
|     ) | ||||
|     expect(model).toBeDefined() | ||||
|     expect(model.queries.length).toBeGreaterThan(0) | ||||
|  | ||||
|     const valuesControl = filterGroup.get('values') | ||||
|     expect(valuesControl.value).toBeNull() | ||||
|  | ||||
|     const expression = new CustomFieldQueryExpression([ | ||||
|       CustomFieldQueryLogicalOperator.And, | ||||
|       [[1, 'exact', 'value']], | ||||
|     ]) | ||||
|     model.queries = [expression] | ||||
|  | ||||
|     jest.spyOn(model, 'isValid').mockReturnValue(true) | ||||
|     jest.spyOn(model, 'isEmpty').mockReturnValue(false) | ||||
|  | ||||
|     model.changed.next(model) | ||||
|  | ||||
|     expect(valuesControl.value).toEqual(JSON.stringify(expression.serialize())) | ||||
|  | ||||
|     component['clearCustomFieldQueryModel'](filterGroup as any) | ||||
|   }) | ||||
|  | ||||
|   it('should handle custom field query model change edge cases', () => { | ||||
|     const groupWithoutControl = new FormGroup({}) | ||||
|     const dummyModel = { | ||||
|       isValid: jest.fn().mockReturnValue(true), | ||||
|       isEmpty: jest.fn().mockReturnValue(false), | ||||
|     } | ||||
|  | ||||
|     expect(() => | ||||
|       component['onCustomFieldQueryModelChanged']( | ||||
|         groupWithoutControl as any, | ||||
|         dummyModel as any | ||||
|       ) | ||||
|     ).not.toThrow() | ||||
|  | ||||
|     const groupWithControl = new FormGroup({ | ||||
|       values: new FormControl('initial'), | ||||
|     }) | ||||
|     const emptyModel = { | ||||
|       isValid: jest.fn().mockReturnValue(true), | ||||
|       isEmpty: jest.fn().mockReturnValue(true), | ||||
|     } | ||||
|  | ||||
|     component['onCustomFieldQueryModelChanged']( | ||||
|       groupWithControl as any, | ||||
|       emptyModel as any | ||||
|     ) | ||||
|  | ||||
|     expect(groupWithControl.get('values').value).toBeNull() | ||||
|   }) | ||||
|  | ||||
|   it('should normalize filter values for single and multi selects', () => { | ||||
|     expect( | ||||
|       component['normalizeFilterValue'](TriggerFilterType.TagsAny) | ||||
|     ).toEqual([]) | ||||
|     expect( | ||||
|       component['normalizeFilterValue'](TriggerFilterType.TagsAny, 5) | ||||
|     ).toEqual([5]) | ||||
|     expect( | ||||
|       component['normalizeFilterValue'](TriggerFilterType.TagsAny, [5, 6]) | ||||
|     ).toEqual([5, 6]) | ||||
|     expect( | ||||
|       component['normalizeFilterValue'](TriggerFilterType.CorrespondentIs, [7]) | ||||
|     ).toEqual(7) | ||||
|     expect( | ||||
|       component['normalizeFilterValue'](TriggerFilterType.CorrespondentIs, 8) | ||||
|     ).toEqual(8) | ||||
|     const customFieldJson = JSON.stringify(['AND', [[1, 'exact', 'test']]]) | ||||
|     expect( | ||||
|       component['normalizeFilterValue']( | ||||
|         TriggerFilterType.CustomFieldQuery, | ||||
|         customFieldJson | ||||
|       ) | ||||
|     ).toEqual(customFieldJson) | ||||
|  | ||||
|     const customFieldObject = ['AND', [[1, 'exact', 'other']]] | ||||
|     expect( | ||||
|       component['normalizeFilterValue']( | ||||
|         TriggerFilterType.CustomFieldQuery, | ||||
|         customFieldObject | ||||
|       ) | ||||
|     ).toEqual(JSON.stringify(customFieldObject)) | ||||
|  | ||||
|     expect( | ||||
|       component['normalizeFilterValue']( | ||||
|         TriggerFilterType.CustomFieldQuery, | ||||
|         false | ||||
|       ) | ||||
|     ).toBeNull() | ||||
|   }) | ||||
|  | ||||
|   it('should add and remove filter form groups', () => { | ||||
|     component['changeDetector'] = { detectChanges: jest.fn() } as any | ||||
|     component.object = undefined | ||||
|     component.addTrigger() | ||||
|     const triggerGroup = component.triggerFields.at(0) as FormGroup | ||||
|  | ||||
|     component.addFilter(triggerGroup) | ||||
|  | ||||
|     component.removeFilter(triggerGroup, 0) | ||||
|     expect(component.getFiltersFormArray(triggerGroup).length).toBe(0) | ||||
|  | ||||
|     component.addFilter(triggerGroup) | ||||
|     const filterArrayAfterAdd = component.getFiltersFormArray(triggerGroup) | ||||
|     filterArrayAfterAdd.at(0).get('type').setValue(TriggerFilterType.TagsAll) | ||||
|     expect(component.getFiltersFormArray(triggerGroup).length).toBe(1) | ||||
|   }) | ||||
|  | ||||
|   it('should remove selected custom field from the form group', () => { | ||||
|     const formGroup = new FormGroup({ | ||||
|       assign_custom_fields: new FormControl([1, 2, 3]), | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import { | ||||
| import { NgTemplateOutlet } from '@angular/common' | ||||
| import { Component, OnInit, inject } from '@angular/core' | ||||
| import { | ||||
|   AbstractControl, | ||||
|   FormArray, | ||||
|   FormControl, | ||||
|   FormGroup, | ||||
| @@ -14,7 +15,7 @@ import { | ||||
| } from '@angular/forms' | ||||
| import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { first } from 'rxjs' | ||||
| import { Subscription, first, takeUntil } from 'rxjs' | ||||
| import { Correspondent } from 'src/app/data/correspondent' | ||||
| import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' | ||||
| import { DocumentType } from 'src/app/data/document-type' | ||||
| @@ -45,7 +46,12 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { WorkflowService } from 'src/app/services/rest/workflow.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element' | ||||
| import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' | ||||
| import { | ||||
|   CustomFieldQueriesModel, | ||||
|   CustomFieldsQueryDropdownComponent, | ||||
| } from '../../custom-fields-query-dropdown/custom-fields-query-dropdown.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' | ||||
| @@ -135,10 +141,235 @@ export const WORKFLOW_ACTION_OPTIONS = [ | ||||
|   }, | ||||
| ] | ||||
|  | ||||
| export enum TriggerFilterType { | ||||
|   TagsAny = 'tags_any', | ||||
|   TagsAll = 'tags_all', | ||||
|   TagsNone = 'tags_none', | ||||
|   CorrespondentIs = 'correspondent_is', | ||||
|   CorrespondentNot = 'correspondent_not', | ||||
|   DocumentTypeIs = 'document_type_is', | ||||
|   DocumentTypeNot = 'document_type_not', | ||||
|   StoragePathIs = 'storage_path_is', | ||||
|   StoragePathNot = 'storage_path_not', | ||||
|   CustomFieldQuery = 'custom_field_query', | ||||
| } | ||||
|  | ||||
| interface TriggerFilterDefinition { | ||||
|   id: TriggerFilterType | ||||
|   name: string | ||||
|   inputType: 'tags' | 'select' | 'customFieldQuery' | ||||
|   allowMultipleEntries: boolean | ||||
|   allowMultipleValues: boolean | ||||
|   selectItems?: 'correspondents' | 'documentTypes' | 'storagePaths' | ||||
|   disabled?: boolean | ||||
| } | ||||
|  | ||||
| type TriggerFilterOption = TriggerFilterDefinition & { | ||||
|   disabled?: boolean | ||||
| } | ||||
|  | ||||
| type TriggerFilterAggregate = { | ||||
|   filter_has_tags: number[] | ||||
|   filter_has_all_tags: number[] | ||||
|   filter_has_not_tags: number[] | ||||
|   filter_has_not_correspondents: number[] | ||||
|   filter_has_not_document_types: number[] | ||||
|   filter_has_not_storage_paths: number[] | ||||
|   filter_has_correspondent: number | null | ||||
|   filter_has_document_type: number | null | ||||
|   filter_has_storage_path: number | null | ||||
|   filter_custom_field_query: string | null | ||||
| } | ||||
|  | ||||
| interface FilterHandler { | ||||
|   apply: (aggregate: TriggerFilterAggregate, values: any) => void | ||||
|   extract: (trigger: WorkflowTrigger) => any | ||||
|   hasValue: (value: any) => boolean | ||||
| } | ||||
|  | ||||
| const CUSTOM_FIELD_QUERY_MODEL_KEY = Symbol('customFieldQueryModel') | ||||
| const CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY = Symbol( | ||||
|   'customFieldQuerySubscription' | ||||
| ) | ||||
|  | ||||
| type CustomFieldFilterGroup = FormGroup & { | ||||
|   [CUSTOM_FIELD_QUERY_MODEL_KEY]?: CustomFieldQueriesModel | ||||
|   [CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?: Subscription | ||||
| } | ||||
|  | ||||
| const TRIGGER_FILTER_DEFINITIONS: TriggerFilterDefinition[] = [ | ||||
|   { | ||||
|     id: TriggerFilterType.TagsAny, | ||||
|     name: $localize`Has any of these tags`, | ||||
|     inputType: 'tags', | ||||
|     allowMultipleEntries: false, | ||||
|     allowMultipleValues: true, | ||||
|   }, | ||||
|   { | ||||
|     id: TriggerFilterType.TagsAll, | ||||
|     name: $localize`Has all of these tags`, | ||||
|     inputType: 'tags', | ||||
|     allowMultipleEntries: false, | ||||
|     allowMultipleValues: true, | ||||
|   }, | ||||
|   { | ||||
|     id: TriggerFilterType.TagsNone, | ||||
|     name: $localize`Does not have these tags`, | ||||
|     inputType: 'tags', | ||||
|     allowMultipleEntries: false, | ||||
|     allowMultipleValues: true, | ||||
|   }, | ||||
|   { | ||||
|     id: TriggerFilterType.CorrespondentIs, | ||||
|     name: $localize`Has correspondent`, | ||||
|     inputType: 'select', | ||||
|     allowMultipleEntries: false, | ||||
|     allowMultipleValues: false, | ||||
|     selectItems: 'correspondents', | ||||
|   }, | ||||
|   { | ||||
|     id: TriggerFilterType.CorrespondentNot, | ||||
|     name: $localize`Does not have correspondents`, | ||||
|     inputType: 'select', | ||||
|     allowMultipleEntries: false, | ||||
|     allowMultipleValues: true, | ||||
|     selectItems: 'correspondents', | ||||
|   }, | ||||
|   { | ||||
|     id: TriggerFilterType.DocumentTypeIs, | ||||
|     name: $localize`Has document type`, | ||||
|     inputType: 'select', | ||||
|     allowMultipleEntries: false, | ||||
|     allowMultipleValues: false, | ||||
|     selectItems: 'documentTypes', | ||||
|   }, | ||||
|   { | ||||
|     id: TriggerFilterType.DocumentTypeNot, | ||||
|     name: $localize`Does not have document types`, | ||||
|     inputType: 'select', | ||||
|     allowMultipleEntries: false, | ||||
|     allowMultipleValues: true, | ||||
|     selectItems: 'documentTypes', | ||||
|   }, | ||||
|   { | ||||
|     id: TriggerFilterType.StoragePathIs, | ||||
|     name: $localize`Has storage path`, | ||||
|     inputType: 'select', | ||||
|     allowMultipleEntries: false, | ||||
|     allowMultipleValues: false, | ||||
|     selectItems: 'storagePaths', | ||||
|   }, | ||||
|   { | ||||
|     id: TriggerFilterType.StoragePathNot, | ||||
|     name: $localize`Does not have storage paths`, | ||||
|     inputType: 'select', | ||||
|     allowMultipleEntries: false, | ||||
|     allowMultipleValues: true, | ||||
|     selectItems: 'storagePaths', | ||||
|   }, | ||||
|   { | ||||
|     id: TriggerFilterType.CustomFieldQuery, | ||||
|     name: $localize`Matches custom field query`, | ||||
|     inputType: 'customFieldQuery', | ||||
|     allowMultipleEntries: false, | ||||
|     allowMultipleValues: false, | ||||
|   }, | ||||
| ] | ||||
|  | ||||
| const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter( | ||||
|   (a) => a.id !== MATCH_AUTO | ||||
| ) | ||||
|  | ||||
| const FILTER_HANDLERS: Record<TriggerFilterType, FilterHandler> = { | ||||
|   [TriggerFilterType.TagsAny]: { | ||||
|     apply: (aggregate, values) => { | ||||
|       aggregate.filter_has_tags = Array.isArray(values) ? [...values] : [values] | ||||
|     }, | ||||
|     extract: (trigger) => trigger.filter_has_tags, | ||||
|     hasValue: (value) => Array.isArray(value) && value.length > 0, | ||||
|   }, | ||||
|   [TriggerFilterType.TagsAll]: { | ||||
|     apply: (aggregate, values) => { | ||||
|       aggregate.filter_has_all_tags = Array.isArray(values) | ||||
|         ? [...values] | ||||
|         : [values] | ||||
|     }, | ||||
|     extract: (trigger) => trigger.filter_has_all_tags, | ||||
|     hasValue: (value) => Array.isArray(value) && value.length > 0, | ||||
|   }, | ||||
|   [TriggerFilterType.TagsNone]: { | ||||
|     apply: (aggregate, values) => { | ||||
|       aggregate.filter_has_not_tags = Array.isArray(values) | ||||
|         ? [...values] | ||||
|         : [values] | ||||
|     }, | ||||
|     extract: (trigger) => trigger.filter_has_not_tags, | ||||
|     hasValue: (value) => Array.isArray(value) && value.length > 0, | ||||
|   }, | ||||
|   [TriggerFilterType.CorrespondentIs]: { | ||||
|     apply: (aggregate, values) => { | ||||
|       aggregate.filter_has_correspondent = Array.isArray(values) | ||||
|         ? (values[0] ?? null) | ||||
|         : values | ||||
|     }, | ||||
|     extract: (trigger) => trigger.filter_has_correspondent, | ||||
|     hasValue: (value) => value !== null && value !== undefined, | ||||
|   }, | ||||
|   [TriggerFilterType.CorrespondentNot]: { | ||||
|     apply: (aggregate, values) => { | ||||
|       aggregate.filter_has_not_correspondents = Array.isArray(values) | ||||
|         ? [...values] | ||||
|         : [values] | ||||
|     }, | ||||
|     extract: (trigger) => trigger.filter_has_not_correspondents, | ||||
|     hasValue: (value) => Array.isArray(value) && value.length > 0, | ||||
|   }, | ||||
|   [TriggerFilterType.DocumentTypeIs]: { | ||||
|     apply: (aggregate, values) => { | ||||
|       aggregate.filter_has_document_type = Array.isArray(values) | ||||
|         ? (values[0] ?? null) | ||||
|         : values | ||||
|     }, | ||||
|     extract: (trigger) => trigger.filter_has_document_type, | ||||
|     hasValue: (value) => value !== null && value !== undefined, | ||||
|   }, | ||||
|   [TriggerFilterType.DocumentTypeNot]: { | ||||
|     apply: (aggregate, values) => { | ||||
|       aggregate.filter_has_not_document_types = Array.isArray(values) | ||||
|         ? [...values] | ||||
|         : [values] | ||||
|     }, | ||||
|     extract: (trigger) => trigger.filter_has_not_document_types, | ||||
|     hasValue: (value) => Array.isArray(value) && value.length > 0, | ||||
|   }, | ||||
|   [TriggerFilterType.StoragePathIs]: { | ||||
|     apply: (aggregate, values) => { | ||||
|       aggregate.filter_has_storage_path = Array.isArray(values) | ||||
|         ? (values[0] ?? null) | ||||
|         : values | ||||
|     }, | ||||
|     extract: (trigger) => trigger.filter_has_storage_path, | ||||
|     hasValue: (value) => value !== null && value !== undefined, | ||||
|   }, | ||||
|   [TriggerFilterType.StoragePathNot]: { | ||||
|     apply: (aggregate, values) => { | ||||
|       aggregate.filter_has_not_storage_paths = Array.isArray(values) | ||||
|         ? [...values] | ||||
|         : [values] | ||||
|     }, | ||||
|     extract: (trigger) => trigger.filter_has_not_storage_paths, | ||||
|     hasValue: (value) => Array.isArray(value) && value.length > 0, | ||||
|   }, | ||||
|   [TriggerFilterType.CustomFieldQuery]: { | ||||
|     apply: (aggregate, values) => { | ||||
|       aggregate.filter_custom_field_query = values as string | ||||
|     }, | ||||
|     extract: (trigger) => trigger.filter_custom_field_query, | ||||
|     hasValue: (value) => | ||||
|       typeof value === 'string' && value !== null && value.trim().length > 0, | ||||
|   }, | ||||
| } | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-workflow-edit-dialog', | ||||
|   templateUrl: './workflow-edit-dialog.component.html', | ||||
| @@ -153,6 +384,7 @@ const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter( | ||||
|     TextAreaComponent, | ||||
|     TagsComponent, | ||||
|     CustomFieldsValuesComponent, | ||||
|     CustomFieldsQueryDropdownComponent, | ||||
|     PermissionsGroupComponent, | ||||
|     PermissionsUserComponent, | ||||
|     ConfirmButtonComponent, | ||||
| @@ -170,6 +402,8 @@ export class WorkflowEditDialogComponent | ||||
| { | ||||
|   public WorkflowTriggerType = WorkflowTriggerType | ||||
|   public WorkflowActionType = WorkflowActionType | ||||
|   public TriggerFilterType = TriggerFilterType | ||||
|   public filterDefinitions = TRIGGER_FILTER_DEFINITIONS | ||||
|  | ||||
|   private correspondentService: CorrespondentService | ||||
|   private documentTypeService: DocumentTypeService | ||||
| @@ -189,6 +423,11 @@ export class WorkflowEditDialogComponent | ||||
|  | ||||
|   private allowedActionTypes = [] | ||||
|  | ||||
|   private readonly triggerFilterOptionsMap = new WeakMap< | ||||
|     FormArray, | ||||
|     TriggerFilterOption[] | ||||
|   >() | ||||
|  | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.service = inject(WorkflowService) | ||||
| @@ -390,6 +629,416 @@ export class WorkflowEditDialogComponent | ||||
|     return this.objectForm.get('actions') as FormArray | ||||
|   } | ||||
|  | ||||
|   protected override getFormValues(): any { | ||||
|     const formValues = super.getFormValues() | ||||
|  | ||||
|     if (formValues?.triggers?.length) { | ||||
|       formValues.triggers = formValues.triggers.map( | ||||
|         (trigger: any, index: number) => { | ||||
|           const triggerFormGroup = this.triggerFields.at(index) as FormGroup | ||||
|           const filters = this.getFiltersFormArray(triggerFormGroup) | ||||
|  | ||||
|           const aggregate: TriggerFilterAggregate = { | ||||
|             filter_has_tags: [], | ||||
|             filter_has_all_tags: [], | ||||
|             filter_has_not_tags: [], | ||||
|             filter_has_not_correspondents: [], | ||||
|             filter_has_not_document_types: [], | ||||
|             filter_has_not_storage_paths: [], | ||||
|             filter_has_correspondent: null, | ||||
|             filter_has_document_type: null, | ||||
|             filter_has_storage_path: null, | ||||
|             filter_custom_field_query: null, | ||||
|           } | ||||
|  | ||||
|           for (const control of filters.controls) { | ||||
|             const type = control.get('type').value as TriggerFilterType | ||||
|             const values = control.get('values').value | ||||
|  | ||||
|             if (values === null || values === undefined) { | ||||
|               continue | ||||
|             } | ||||
|  | ||||
|             if (Array.isArray(values) && values.length === 0) { | ||||
|               continue | ||||
|             } | ||||
|  | ||||
|             const handler = FILTER_HANDLERS[type] | ||||
|             handler?.apply(aggregate, values) | ||||
|           } | ||||
|  | ||||
|           trigger.filter_has_tags = aggregate.filter_has_tags | ||||
|           trigger.filter_has_all_tags = aggregate.filter_has_all_tags | ||||
|           trigger.filter_has_not_tags = aggregate.filter_has_not_tags | ||||
|           trigger.filter_has_not_correspondents = | ||||
|             aggregate.filter_has_not_correspondents | ||||
|           trigger.filter_has_not_document_types = | ||||
|             aggregate.filter_has_not_document_types | ||||
|           trigger.filter_has_not_storage_paths = | ||||
|             aggregate.filter_has_not_storage_paths | ||||
|           trigger.filter_has_correspondent = | ||||
|             aggregate.filter_has_correspondent ?? null | ||||
|           trigger.filter_has_document_type = | ||||
|             aggregate.filter_has_document_type ?? null | ||||
|           trigger.filter_has_storage_path = | ||||
|             aggregate.filter_has_storage_path ?? null | ||||
|           trigger.filter_custom_field_query = | ||||
|             aggregate.filter_custom_field_query ?? null | ||||
|  | ||||
|           delete trigger.filters | ||||
|  | ||||
|           return trigger | ||||
|         } | ||||
|       ) | ||||
|     } | ||||
|  | ||||
|     return formValues | ||||
|   } | ||||
|  | ||||
|   public matchingPatternRequired(formGroup: FormGroup): boolean { | ||||
|     return formGroup.get('matching_algorithm').value !== MATCH_NONE | ||||
|   } | ||||
|  | ||||
|   private createFilterFormGroup( | ||||
|     type: TriggerFilterType, | ||||
|     initialValue?: any | ||||
|   ): FormGroup { | ||||
|     const group = new FormGroup({ | ||||
|       type: new FormControl(type), | ||||
|       values: new FormControl(this.normalizeFilterValue(type, initialValue)), | ||||
|     }) | ||||
|  | ||||
|     group.get('type').valueChanges.subscribe((newType: TriggerFilterType) => { | ||||
|       if (newType === TriggerFilterType.CustomFieldQuery) { | ||||
|         this.ensureCustomFieldQueryModel(group) | ||||
|       } else { | ||||
|         this.clearCustomFieldQueryModel(group) | ||||
|         group.get('values').setValue(this.getDefaultFilterValue(newType), { | ||||
|           emitEvent: false, | ||||
|         }) | ||||
|       } | ||||
|     }) | ||||
|  | ||||
|     if (type === TriggerFilterType.CustomFieldQuery) { | ||||
|       this.ensureCustomFieldQueryModel(group, initialValue) | ||||
|     } | ||||
|  | ||||
|     return group | ||||
|   } | ||||
|  | ||||
|   private buildFiltersFormArray(trigger: WorkflowTrigger): FormArray { | ||||
|     const filters = new FormArray([]) | ||||
|  | ||||
|     for (const definition of this.filterDefinitions) { | ||||
|       const handler = FILTER_HANDLERS[definition.id] | ||||
|       if (!handler) { | ||||
|         continue | ||||
|       } | ||||
|  | ||||
|       const value = handler.extract(trigger) | ||||
|       if (!handler.hasValue(value)) { | ||||
|         continue | ||||
|       } | ||||
|  | ||||
|       filters.push(this.createFilterFormGroup(definition.id, value)) | ||||
|     } | ||||
|  | ||||
|     return filters | ||||
|   } | ||||
|  | ||||
|   getFiltersFormArray(formGroup: FormGroup): FormArray { | ||||
|     return formGroup.get('filters') as FormArray | ||||
|   } | ||||
|  | ||||
|   getFilterTypeOptions(formGroup: FormGroup, filterIndex: number) { | ||||
|     const filters = this.getFiltersFormArray(formGroup) | ||||
|     const options = this.getFilterTypeOptionsForArray(filters) | ||||
|     const currentType = filters.at(filterIndex).get('type') | ||||
|       .value as TriggerFilterType | ||||
|     const usedTypes = new Set( | ||||
|       filters.controls.map( | ||||
|         (control) => control.get('type').value as TriggerFilterType | ||||
|       ) | ||||
|     ) | ||||
|  | ||||
|     for (const option of options) { | ||||
|       if (option.allowMultipleEntries) { | ||||
|         option.disabled = false | ||||
|         continue | ||||
|       } | ||||
|  | ||||
|       option.disabled = usedTypes.has(option.id) && option.id !== currentType | ||||
|     } | ||||
|  | ||||
|     return options | ||||
|   } | ||||
|  | ||||
|   canAddFilter(formGroup: FormGroup): boolean { | ||||
|     const filters = this.getFiltersFormArray(formGroup) | ||||
|     const usedTypes = new Set( | ||||
|       filters.controls.map( | ||||
|         (control) => control.get('type').value as TriggerFilterType | ||||
|       ) | ||||
|     ) | ||||
|  | ||||
|     return this.filterDefinitions.some((definition) => { | ||||
|       if (definition.allowMultipleEntries) { | ||||
|         return true | ||||
|       } | ||||
|       return !usedTypes.has(definition.id) | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   addFilter(triggerFormGroup: FormGroup): FormGroup | null { | ||||
|     const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup) | ||||
|     if (triggerIndex === -1) { | ||||
|       return null | ||||
|     } | ||||
|  | ||||
|     const filters = this.getFiltersFormArray(triggerFormGroup) | ||||
|  | ||||
|     const availableDefinition = this.filterDefinitions.find((definition) => { | ||||
|       if (definition.allowMultipleEntries) { | ||||
|         return true | ||||
|       } | ||||
|       return !filters.controls.some( | ||||
|         (control) => control.get('type').value === definition.id | ||||
|       ) | ||||
|     }) | ||||
|  | ||||
|     if (!availableDefinition) { | ||||
|       return null | ||||
|     } | ||||
|  | ||||
|     filters.push(this.createFilterFormGroup(availableDefinition.id)) | ||||
|     triggerFormGroup.markAsDirty() | ||||
|     triggerFormGroup.markAsTouched() | ||||
|  | ||||
|     return filters.at(-1) as FormGroup | ||||
|   } | ||||
|  | ||||
|   removeFilter(triggerFormGroup: FormGroup, filterIndex: number) { | ||||
|     const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup) | ||||
|     if (triggerIndex === -1) { | ||||
|       return | ||||
|     } | ||||
|  | ||||
|     const filters = this.getFiltersFormArray(triggerFormGroup) | ||||
|     const filterGroup = filters.at(filterIndex) as FormGroup | ||||
|     if (filterGroup?.get('type').value === TriggerFilterType.CustomFieldQuery) { | ||||
|       this.clearCustomFieldQueryModel(filterGroup) | ||||
|     } | ||||
|     filters.removeAt(filterIndex) | ||||
|     triggerFormGroup.markAsDirty() | ||||
|     triggerFormGroup.markAsTouched() | ||||
|   } | ||||
|  | ||||
|   getFilterDefinition( | ||||
|     type: TriggerFilterType | ||||
|   ): TriggerFilterDefinition | undefined { | ||||
|     return this.filterDefinitions.find((definition) => definition.id === type) | ||||
|   } | ||||
|  | ||||
|   getFilterName(type: TriggerFilterType): string { | ||||
|     return this.getFilterDefinition(type)?.name ?? '' | ||||
|   } | ||||
|  | ||||
|   isTagsFilter(type: TriggerFilterType): boolean { | ||||
|     return this.getFilterDefinition(type)?.inputType === 'tags' | ||||
|   } | ||||
|  | ||||
|   isCustomFieldQueryFilter(type: TriggerFilterType): boolean { | ||||
|     return this.getFilterDefinition(type)?.inputType === 'customFieldQuery' | ||||
|   } | ||||
|  | ||||
|   isMultiValueFilter(type: TriggerFilterType): boolean { | ||||
|     switch (type) { | ||||
|       case TriggerFilterType.TagsAny: | ||||
|       case TriggerFilterType.TagsAll: | ||||
|       case TriggerFilterType.TagsNone: | ||||
|       case TriggerFilterType.CorrespondentNot: | ||||
|       case TriggerFilterType.DocumentTypeNot: | ||||
|       case TriggerFilterType.StoragePathNot: | ||||
|         return true | ||||
|       default: | ||||
|         return false | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   isSelectMultiple(type: TriggerFilterType): boolean { | ||||
|     return !this.isTagsFilter(type) && this.isMultiValueFilter(type) | ||||
|   } | ||||
|  | ||||
|   getFilterSelectItems(type: TriggerFilterType) { | ||||
|     const definition = this.getFilterDefinition(type) | ||||
|     if (!definition || definition.inputType !== 'select') { | ||||
|       return [] | ||||
|     } | ||||
|  | ||||
|     switch (definition.selectItems) { | ||||
|       case 'correspondents': | ||||
|         return this.correspondents | ||||
|       case 'documentTypes': | ||||
|         return this.documentTypes | ||||
|       case 'storagePaths': | ||||
|         return this.storagePaths | ||||
|       default: | ||||
|         return [] | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   getCustomFieldQueryModel(control: AbstractControl): CustomFieldQueriesModel { | ||||
|     return this.ensureCustomFieldQueryModel(control as FormGroup) | ||||
|   } | ||||
|  | ||||
|   onCustomFieldQuerySelectionChange( | ||||
|     control: AbstractControl, | ||||
|     model: CustomFieldQueriesModel | ||||
|   ) { | ||||
|     this.onCustomFieldQueryModelChanged(control as FormGroup, model) | ||||
|   } | ||||
|  | ||||
|   isCustomFieldQueryValid(control: AbstractControl): boolean { | ||||
|     const model = this.getStoredCustomFieldQueryModel(control as FormGroup) | ||||
|     if (!model) { | ||||
|       return true | ||||
|     } | ||||
|  | ||||
|     return model.isEmpty() || model.isValid() | ||||
|   } | ||||
|  | ||||
|   private getFilterTypeOptionsForArray( | ||||
|     filters: FormArray | ||||
|   ): TriggerFilterOption[] { | ||||
|     let cached = this.triggerFilterOptionsMap.get(filters) | ||||
|     if (!cached) { | ||||
|       cached = this.filterDefinitions.map((definition) => ({ | ||||
|         ...definition, | ||||
|         disabled: false, | ||||
|       })) | ||||
|       this.triggerFilterOptionsMap.set(filters, cached) | ||||
|     } | ||||
|     return cached | ||||
|   } | ||||
|  | ||||
|   private ensureCustomFieldQueryModel( | ||||
|     filterGroup: FormGroup, | ||||
|     initialValue?: any | ||||
|   ): CustomFieldQueriesModel { | ||||
|     const existingModel = this.getStoredCustomFieldQueryModel(filterGroup) | ||||
|     if (existingModel) { | ||||
|       return existingModel | ||||
|     } | ||||
|  | ||||
|     const model = new CustomFieldQueriesModel() | ||||
|     this.setCustomFieldQueryModel(filterGroup, model) | ||||
|  | ||||
|     const rawValue = | ||||
|       typeof initialValue === 'string' | ||||
|         ? initialValue | ||||
|         : (filterGroup.get('values').value as string) | ||||
|  | ||||
|     if (rawValue) { | ||||
|       try { | ||||
|         const parsed = JSON.parse(rawValue) | ||||
|         const expression = new CustomFieldQueryExpression(parsed) | ||||
|         model.queries = [expression] | ||||
|       } catch { | ||||
|         model.clear(false) | ||||
|         model.addInitialAtom() | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const subscription = model.changed | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe(() => { | ||||
|         this.onCustomFieldQueryModelChanged(filterGroup, model) | ||||
|       }) | ||||
|     filterGroup[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?.unsubscribe() | ||||
|     filterGroup[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY] = subscription | ||||
|  | ||||
|     this.onCustomFieldQueryModelChanged(filterGroup, model) | ||||
|  | ||||
|     return model | ||||
|   } | ||||
|  | ||||
|   private clearCustomFieldQueryModel(filterGroup: FormGroup) { | ||||
|     const group = filterGroup as CustomFieldFilterGroup | ||||
|     group[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?.unsubscribe() | ||||
|     delete group[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY] | ||||
|     delete group[CUSTOM_FIELD_QUERY_MODEL_KEY] | ||||
|   } | ||||
|  | ||||
|   private getStoredCustomFieldQueryModel( | ||||
|     filterGroup: FormGroup | ||||
|   ): CustomFieldQueriesModel | null { | ||||
|     return ( | ||||
|       (filterGroup as CustomFieldFilterGroup)[CUSTOM_FIELD_QUERY_MODEL_KEY] ?? | ||||
|       null | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   private setCustomFieldQueryModel( | ||||
|     filterGroup: FormGroup, | ||||
|     model: CustomFieldQueriesModel | ||||
|   ) { | ||||
|     const group = filterGroup as CustomFieldFilterGroup | ||||
|     group[CUSTOM_FIELD_QUERY_MODEL_KEY] = model | ||||
|   } | ||||
|  | ||||
|   private onCustomFieldQueryModelChanged( | ||||
|     filterGroup: FormGroup, | ||||
|     model: CustomFieldQueriesModel | ||||
|   ) { | ||||
|     const control = filterGroup.get('values') | ||||
|     if (!control) { | ||||
|       return | ||||
|     } | ||||
|  | ||||
|     if (!model.isValid()) { | ||||
|       control.setValue(null, { emitEvent: false }) | ||||
|       return | ||||
|     } | ||||
|  | ||||
|     if (model.isEmpty()) { | ||||
|       control.setValue(null, { emitEvent: false }) | ||||
|       return | ||||
|     } | ||||
|  | ||||
|     const serialized = JSON.stringify(model.queries[0].serialize()) | ||||
|     control.setValue(serialized, { emitEvent: false }) | ||||
|   } | ||||
|  | ||||
|   private getDefaultFilterValue(type: TriggerFilterType) { | ||||
|     if (type === TriggerFilterType.CustomFieldQuery) { | ||||
|       return null | ||||
|     } | ||||
|     return this.isMultiValueFilter(type) ? [] : null | ||||
|   } | ||||
|  | ||||
|   private normalizeFilterValue(type: TriggerFilterType, value?: any) { | ||||
|     if (value === undefined || value === null) { | ||||
|       return this.getDefaultFilterValue(type) | ||||
|     } | ||||
|  | ||||
|     if (type === TriggerFilterType.CustomFieldQuery) { | ||||
|       if (typeof value === 'string') { | ||||
|         return value | ||||
|       } | ||||
|       return value ? JSON.stringify(value) : null | ||||
|     } | ||||
|  | ||||
|     if (this.isMultiValueFilter(type)) { | ||||
|       return Array.isArray(value) ? [...value] : [value] | ||||
|     } | ||||
|  | ||||
|     if (Array.isArray(value)) { | ||||
|       return value.length > 0 ? value[0] : null | ||||
|     } | ||||
|  | ||||
|     return value | ||||
|   } | ||||
|  | ||||
|   private createTriggerField( | ||||
|     trigger: WorkflowTrigger, | ||||
|     emitEvent: boolean = false | ||||
| @@ -405,13 +1054,7 @@ export class WorkflowEditDialogComponent | ||||
|         matching_algorithm: new FormControl(trigger.matching_algorithm), | ||||
|         match: new FormControl(trigger.match), | ||||
|         is_insensitive: new FormControl(trigger.is_insensitive), | ||||
|         filter_has_tags: new FormControl(trigger.filter_has_tags), | ||||
|         filter_has_correspondent: new FormControl( | ||||
|           trigger.filter_has_correspondent | ||||
|         ), | ||||
|         filter_has_document_type: new FormControl( | ||||
|           trigger.filter_has_document_type | ||||
|         ), | ||||
|         filters: this.buildFiltersFormArray(trigger), | ||||
|         schedule_offset_days: new FormControl(trigger.schedule_offset_days), | ||||
|         schedule_is_recurring: new FormControl(trigger.schedule_is_recurring), | ||||
|         schedule_recurring_interval_days: new FormControl( | ||||
| @@ -534,8 +1177,15 @@ export class WorkflowEditDialogComponent | ||||
|       filter_path: null, | ||||
|       filter_mailrule: null, | ||||
|       filter_has_tags: [], | ||||
|       filter_has_all_tags: [], | ||||
|       filter_has_not_tags: [], | ||||
|       filter_has_not_correspondents: [], | ||||
|       filter_has_not_document_types: [], | ||||
|       filter_has_not_storage_paths: [], | ||||
|       filter_custom_field_query: null, | ||||
|       filter_has_correspondent: null, | ||||
|       filter_has_document_type: null, | ||||
|       filter_has_storage_path: null, | ||||
|       matching_algorithm: MATCH_NONE, | ||||
|       match: '', | ||||
|       is_insensitive: true, | ||||
|   | ||||
| @@ -1,5 +1,9 @@ | ||||
| <div class="modal-header"> | ||||
|     <h4 class="modal-title" id="modal-basic-title">{{title}}</h4> | ||||
|     <h4 class="modal-title" id="modal-basic-title" i18n>{ | ||||
|       documentIds.length, | ||||
|       plural, | ||||
|       =1 {Email Document} other {Email {{documentIds.length}} Documents} | ||||
|     }</h4> | ||||
|     <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button> | ||||
| </div> | ||||
| <div class="modal-body"> | ||||
| @@ -22,11 +26,14 @@ | ||||
|             <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"> | ||||
|         <button type="submit" class="btn btn-outline-primary" (click)="emailDocuments()" [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 class="text-light fst-italic small mt-2"> | ||||
|         <ng-container i18n>Some email servers may reject messages with large attachments.</ng-container> | ||||
|     </div> | ||||
| </div> | ||||
|   | ||||
| @@ -36,31 +36,59 @@ describe('EmailDocumentDialogComponent', () => { | ||||
|     documentService = TestBed.inject(DocumentService) | ||||
|     toastService = TestBed.inject(ToastService) | ||||
|     component = fixture.componentInstance | ||||
|     component.documentIds = [1] | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should set hasArchiveVersion and useArchiveVersion', () => { | ||||
|     expect(component.hasArchiveVersion).toBeTruthy() | ||||
|     expect(component.useArchiveVersion).toBeTruthy() | ||||
|  | ||||
|     component.hasArchiveVersion = false | ||||
|     expect(component.hasArchiveVersion).toBeFalsy() | ||||
|     expect(component.useArchiveVersion).toBeFalsy() | ||||
|   }) | ||||
|  | ||||
|   it('should support sending document via email, showing error if needed', () => { | ||||
|   it('should support sending single document via email, showing error if needed', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     component.documentIds = [1] | ||||
|     component.emailAddress = 'hello@paperless-ngx.com' | ||||
|     component.emailSubject = 'Hello' | ||||
|     component.emailMessage = 'World' | ||||
|     jest | ||||
|       .spyOn(documentService, 'emailDocument') | ||||
|       .spyOn(documentService, 'emailDocuments') | ||||
|       .mockReturnValue(throwError(() => new Error('Unable to email document'))) | ||||
|     component.emailDocument() | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     component.emailDocuments() | ||||
|     expect(toastErrorSpy).toHaveBeenCalledWith( | ||||
|       'Error emailing document', | ||||
|       expect.any(Error) | ||||
|     ) | ||||
|  | ||||
|     jest.spyOn(documentService, 'emailDocument').mockReturnValue(of(true)) | ||||
|     component.emailDocument() | ||||
|     expect(toastSuccessSpy).toHaveBeenCalled() | ||||
|     jest.spyOn(documentService, 'emailDocuments').mockReturnValue(of(true)) | ||||
|     component.emailDocuments() | ||||
|     expect(toastSuccessSpy).toHaveBeenCalledWith('Email sent') | ||||
|   }) | ||||
|  | ||||
|   it('should support sending multiple documents via email, showing appropriate messages', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     component.documentIds = [1, 2, 3] | ||||
|     component.emailAddress = 'hello@paperless-ngx.com' | ||||
|     component.emailSubject = 'Hello' | ||||
|     component.emailMessage = 'World' | ||||
|     jest | ||||
|       .spyOn(documentService, 'emailDocuments') | ||||
|       .mockReturnValue(throwError(() => new Error('Unable to email documents'))) | ||||
|     component.emailDocuments() | ||||
|     expect(toastErrorSpy).toHaveBeenCalledWith( | ||||
|       'Error emailing documents', | ||||
|       expect.any(Error) | ||||
|     ) | ||||
|  | ||||
|     jest.spyOn(documentService, 'emailDocuments').mockReturnValue(of(true)) | ||||
|     component.emailDocuments() | ||||
|     expect(toastSuccessSpy).toHaveBeenCalledWith('Email sent') | ||||
|   }) | ||||
|  | ||||
|   it('should close the dialog', () => { | ||||
|   | ||||
| @@ -18,10 +18,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission | ||||
|   private toastService = inject(ToastService) | ||||
|  | ||||
|   @Input() | ||||
|   title = $localize`Email Document` | ||||
|  | ||||
|   @Input() | ||||
|   documentId: number | ||||
|   documentIds: number[] | ||||
|  | ||||
|   private _hasArchiveVersion: boolean = true | ||||
|  | ||||
| @@ -46,11 +43,11 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission | ||||
|     this.loading = false | ||||
|   } | ||||
|  | ||||
|   public emailDocument() { | ||||
|   public emailDocuments() { | ||||
|     this.loading = true | ||||
|     this.documentService | ||||
|       .emailDocument( | ||||
|         this.documentId, | ||||
|       .emailDocuments( | ||||
|         this.documentIds, | ||||
|         this.emailAddress, | ||||
|         this.emailSubject, | ||||
|         this.emailMessage, | ||||
| @@ -67,7 +64,11 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission | ||||
|         }, | ||||
|         error: (e) => { | ||||
|           this.loading = false | ||||
|           this.toastService.showError($localize`Error emailing document`, e) | ||||
|           const errorMessage = | ||||
|             this.documentIds.length > 1 | ||||
|               ? $localize`Error emailing documents` | ||||
|               : $localize`Error emailing document` | ||||
|           this.toastService.showError(errorMessage, e) | ||||
|         }, | ||||
|       }) | ||||
|   } | ||||
|   | ||||
| @@ -30,7 +30,7 @@ | ||||
|       } | ||||
|       <div class="list-group-item"> | ||||
|         <div class="input-group input-group-sm"> | ||||
|           <input class="form-control" type="text" [(ngModel)]="filterText" [placeholder]="filterPlaceholder" (keyup.enter)="listFilterEnter()" #listFilterTextInput> | ||||
|           <input class="form-control" type="text" spellcheck="false" [(ngModel)]="filterText" [placeholder]="filterPlaceholder" (keyup.enter)="listFilterEnter()" #listFilterTextInput> | ||||
|         </div> | ||||
|       </div> | ||||
|       @if (selectionModel.items) { | ||||
|   | ||||
| @@ -136,6 +136,13 @@ export class FilterableDropdownSelectionModel { | ||||
|         this.getDocumentCount(a.id) < this.getDocumentCount(b.id) | ||||
|       ) { | ||||
|         return 1 | ||||
|       } | ||||
|  | ||||
|       // Preserve hierarchical order when provided (e.g., Tags) | ||||
|       const ao = (a as any)['orderIndex'] | ||||
|       const bo = (b as any)['orderIndex'] | ||||
|       if (ao !== undefined && bo !== undefined) { | ||||
|         return ao - bo | ||||
|       } else { | ||||
|         return a.name.localeCompare(b.name) | ||||
|       } | ||||
|   | ||||
| @@ -15,12 +15,17 @@ | ||||
|       <i-bs width="1em" height="1em" name="x"></i-bs> | ||||
|     } | ||||
|   </div> | ||||
|   <div class="me-1"> | ||||
|     @if (isTag) { | ||||
|       <pngx-tag [tag]="item" [clickable]="false"></pngx-tag> | ||||
|     } @else { | ||||
|       <small>{{item.name}}</small> | ||||
|   <div class="me-1 name-cell" [style.--depth]="isTag ? getDepth() + 1 : 1"> | ||||
|     @if (isTag && getDepth() > 0) { | ||||
|       <div class="indicator"></div> | ||||
|     } | ||||
|     <div> | ||||
|       @if (isTag) { | ||||
|         <pngx-tag [tag]="item" [clickable]="false"></pngx-tag> | ||||
|       } @else { | ||||
|         <small>{{item.name}}</small> | ||||
|       } | ||||
|     </div> | ||||
|   </div> | ||||
|   @if (!hideCount) { | ||||
|     <div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{currentCount}}</div> | ||||
|   | ||||
| @@ -2,3 +2,19 @@ | ||||
|   min-width: 1em; | ||||
|   min-height: 1em; | ||||
| } | ||||
|  | ||||
| .name-cell { | ||||
|   padding-left: calc(calc(var(--depth) - 2) * 1rem); | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|  | ||||
|   .indicator { | ||||
|     display: inline-block; | ||||
|     width: .8rem; | ||||
|     height: .8rem; | ||||
|     border-left: 1px solid var(--bs-secondary); | ||||
|     border-bottom: 1px solid var(--bs-secondary); | ||||
|     margin-right: .25rem; | ||||
|     margin-left: .5rem; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { Component, EventEmitter, Input, Output } from '@angular/core' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { MatchingModel } from 'src/app/data/matching-model' | ||||
| import { Tag } from 'src/app/data/tag' | ||||
| import { TagComponent } from '../../tag/tag.component' | ||||
|  | ||||
| export enum ToggleableItemState { | ||||
| @@ -45,6 +46,10 @@ export class ToggleableDropdownButtonComponent { | ||||
|     return 'is_inbox_tag' in this.item | ||||
|   } | ||||
|  | ||||
|   getDepth(): number { | ||||
|     return (this.item as Tag).depth ?? 0 | ||||
|   } | ||||
|  | ||||
|   get currentCount(): number { | ||||
|     return this.count ?? this.item.document_count | ||||
|   } | ||||
|   | ||||
| @@ -1,19 +1,18 @@ | ||||
| <div class="mb-3"> | ||||
|   @if (title) { | ||||
|     <label [for]="inputId">{{title}}</label> | ||||
|     <label class="form-label" [for]="inputId">{{title}}</label> | ||||
|   } | ||||
|  | ||||
|   <div class="input-group" [class.is-invalid]="error"> | ||||
|     <span class="input-group-text" [style.background-color]="value">   </span> | ||||
|     <button type="button" class="input-group-text" [style.background-color]="value" (click)="colorPicker.toggle()">   </button> | ||||
|  | ||||
|     <ng-template #popContent> | ||||
|       <div style="min-width: 200px;" class="pb-3"> | ||||
|         <color-slider [color]="value" (onChangeComplete)="colorChanged($event.color.hex)"></color-slider> | ||||
|       </div> | ||||
|  | ||||
|     </ng-template> | ||||
|  | ||||
|     <input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" placement="bottom" popoverClass="shadow"> | ||||
|     <input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" #colorPicker="ngbPopover" placement="bottom" popoverClass="shadow"> | ||||
|  | ||||
|     <button class="btn btn-outline-secondary" type="button" (click)="randomize()"> | ||||
|       <i-bs name="dice5"></i-bs> | ||||
|   | ||||
| @@ -42,8 +42,8 @@ describe('ColorComponent', () => { | ||||
|   }) | ||||
|  | ||||
|   it('should set swatch color', () => { | ||||
|     const swatch: HTMLSpanElement = fixture.nativeElement.querySelector( | ||||
|       'span.input-group-text' | ||||
|     const swatch: HTMLButtonElement = fixture.nativeElement.querySelector( | ||||
|       'button.input-group-text' | ||||
|     ) | ||||
|     expect(swatch.style.backgroundColor).toEqual('') | ||||
|     component.value = '#ff0000' | ||||
|   | ||||
| @@ -68,6 +68,11 @@ | ||||
|           [allowNull]="true" | ||||
|           [horizontal]="true"></pngx-input-select> | ||||
|         } | ||||
|         @case (CustomFieldDataType.LongText) { | ||||
|           <pngx-input-textarea [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)" | ||||
|           [title]="getCustomField(fieldId)?.name" | ||||
|           class="flex-grow-1"></pngx-input-textarea> | ||||
|         } | ||||
|       } | ||||
|       <button type="button" class="btn btn-link text-danger" (click)="removeSelectedField.next(fieldId)"> | ||||
|         <i-bs name="trash"></i-bs> | ||||
|   | ||||
| @@ -24,6 +24,7 @@ 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 { TextAreaComponent } from '../textarea/textarea.component' | ||||
| import { UrlComponent } from '../url/url.component' | ||||
|  | ||||
| @Component({ | ||||
| @@ -51,6 +52,7 @@ import { UrlComponent } from '../url/url.component' | ||||
|     ReactiveFormsModule, | ||||
|     RouterModule, | ||||
|     NgxBootstrapIconsModule, | ||||
|     TextAreaComponent, | ||||
|   ], | ||||
| }) | ||||
| export class CustomFieldsValuesComponent extends AbstractInputComponent<Object> { | ||||
|   | ||||
| @@ -59,7 +59,7 @@ export class DateComponent | ||||
|   @Output() | ||||
|   filterDocuments = new EventEmitter<NgbDateStruct[]>() | ||||
|  | ||||
|   public readonly today: string = new Date().toISOString().split('T')[0] | ||||
|   public readonly today: string = new Date().toLocaleDateString('en-CA') | ||||
|  | ||||
|   getSuggestions() { | ||||
|     return this.suggestions == null | ||||
|   | ||||
| @@ -14,7 +14,7 @@ | ||||
|     </div> | ||||
| </div> | ||||
| <div class="mt-2 align-items-center bg-light p-2"> | ||||
|     <div class="d-flex flex-wrap flex-row gap-2 w-100" | ||||
|     <div class="d-flex flex-wrap flex-row gap-2 w-100" style="min-height: 1em;" | ||||
|         cdkDropList #unselectedList="cdkDropList" | ||||
|         cdkDropListOrientation="mixed" | ||||
|         (cdkDropListDropped)="drop($event)" | ||||
|   | ||||
| @@ -1,66 +1,68 @@ | ||||
| <div class="mb-3 paperless-input-select" [class.disabled]="disabled"> | ||||
|   <div class="row"> | ||||
|     <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal"> | ||||
|       @if (title) { | ||||
|         <label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label> | ||||
|       } | ||||
|       @if (removable) { | ||||
|         <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> | ||||
|           <i-bs  name="x"></i-bs> <ng-container i18n>Remove</ng-container> | ||||
|     @if (title || removable) { | ||||
|       <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal"> | ||||
|         @if (title) { | ||||
|           <label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label> | ||||
|         } | ||||
|         @if (removable) { | ||||
|           <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> | ||||
|             <i-bs  name="x"></i-bs> <ng-container i18n>Remove</ng-container> | ||||
|             </button> | ||||
|         } | ||||
|       </div> | ||||
|     } | ||||
|     <div [class.col-md-9]="horizontal"> | ||||
|       <div [class.input-group]="allowCreateNew || showFilter" [class.is-invalid]="error"> | ||||
|         <ng-select name="inputId" [(ngModel)]="value" | ||||
|           [disabled]="disabled" | ||||
|           [style.color]="textColor" | ||||
|           [style.background]="backgroundColor" | ||||
|           [class.private]="isPrivate" | ||||
|           [clearable]="allowNull" | ||||
|           [items]="items" | ||||
|           [addTag]="allowCreateNew && addItemRef" | ||||
|           addTagText="Add item" | ||||
|           i18n-addTagText="Used for both types, correspondents, storage paths" | ||||
|           [placeholder]="placeholder" | ||||
|           [notFoundText]="notFoundText" | ||||
|           [multiple]="multiple" | ||||
|           [bindLabel]="bindLabel" | ||||
|           bindValue="id" | ||||
|           (change)="onChange(value)" | ||||
|           (search)="onSearch($event)" | ||||
|           (focus)="clearLastSearchTerm()" | ||||
|           (clear)="clearLastSearchTerm()" | ||||
|           (blur)="onBlur()"> | ||||
|           <ng-template ng-option-tmp let-item="item"> | ||||
|               <span [title]="item[bindLabel]">{{item[bindLabel]}}</span> | ||||
|           </ng-template> | ||||
|         </ng-select> | ||||
|         @if (allowCreateNew && !hideAddButton) { | ||||
|           <button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled"> | ||||
|             <i-bs width="1.2em" height="1.2em" name="plus"></i-bs> | ||||
|           </button> | ||||
|         } | ||||
|         @if (showFilter) { | ||||
|           <button class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}"> | ||||
|             <i-bs width="1.2em" height="1.2em" name="filter"></i-bs> | ||||
|           </button> | ||||
|         } | ||||
|       </div> | ||||
|       <div [class.col-md-9]="horizontal"> | ||||
|         <div [class.input-group]="allowCreateNew || showFilter" [class.is-invalid]="error"> | ||||
|           <ng-select name="inputId" [(ngModel)]="value" | ||||
|             [disabled]="disabled" | ||||
|             [style.color]="textColor" | ||||
|             [style.background]="backgroundColor" | ||||
|             [class.private]="isPrivate" | ||||
|             [clearable]="allowNull" | ||||
|             [items]="items" | ||||
|             [addTag]="allowCreateNew && addItemRef" | ||||
|             addTagText="Add item" | ||||
|             i18n-addTagText="Used for both types, correspondents, storage paths" | ||||
|             [placeholder]="placeholder" | ||||
|             [notFoundText]="notFoundText" | ||||
|             [multiple]="multiple" | ||||
|             [bindLabel]="bindLabel" | ||||
|             bindValue="id" | ||||
|             (change)="onChange(value)" | ||||
|             (search)="onSearch($event)" | ||||
|             (focus)="clearLastSearchTerm()" | ||||
|             (clear)="clearLastSearchTerm()" | ||||
|             (blur)="onBlur()"> | ||||
|             <ng-template ng-option-tmp let-item="item"> | ||||
|                 <span [title]="item[bindLabel]">{{item[bindLabel]}}</span> | ||||
|             </ng-template> | ||||
|           </ng-select> | ||||
|           @if (allowCreateNew && !hideAddButton) { | ||||
|             <button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled"> | ||||
|               <i-bs width="1.2em" height="1.2em" name="plus"></i-bs> | ||||
|             </button> | ||||
|           } | ||||
|           @if (showFilter) { | ||||
|             <button class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}"> | ||||
|               <i-bs width="1.2em" height="1.2em" name="filter"></i-bs> | ||||
|             </button> | ||||
|           } | ||||
|         </div> | ||||
|         <div class="invalid-feedback"> | ||||
|           {{error}} | ||||
|         </div> | ||||
|         @if (hint) { | ||||
|           <small class="form-text text-muted">{{hint}}</small> | ||||
|         } | ||||
|         @if (getSuggestions().length > 0) { | ||||
|           <small> | ||||
|             <span i18n>Suggestions:</span>  | ||||
|             @for (s of getSuggestions(); track s) { | ||||
|               <a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>  | ||||
|             } | ||||
|           </small> | ||||
|         } | ||||
|       <div class="invalid-feedback"> | ||||
|         {{error}} | ||||
|       </div> | ||||
|       @if (hint) { | ||||
|         <small class="form-text text-muted">{{hint}}</small> | ||||
|       } | ||||
|       @if (getSuggestions().length > 0) { | ||||
|         <small> | ||||
|           <span i18n>Suggestions:</span>  | ||||
|           @for (s of getSuggestions(); track s) { | ||||
|             <a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>  | ||||
|           } | ||||
|         </small> | ||||
|       } | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
|   | ||||
| @@ -1,19 +1,22 @@ | ||||
| <div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0"> | ||||
|   <div class="row"> | ||||
|     <div class="d-flex align-items-center" [class.col-md-3]="horizontal"> | ||||
|       <label class="form-label" [class.mb-md-0]="horizontal" for="tags">{{title}}</label> | ||||
|     </div> | ||||
|     @if (title) { | ||||
|       <div class="d-flex align-items-center" [class.col-md-3]="horizontal"> | ||||
|         <label class="form-label" [class.mb-md-0]="horizontal" for="tags">{{title}}</label> | ||||
|       </div> | ||||
|     } | ||||
|     <div class="position-relative" [class.col-md-9]="horizontal"> | ||||
|       <div class="input-group flex-nowrap"> | ||||
|         <ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value" | ||||
|           [disabled]="disabled" | ||||
|           [multiple]="true" | ||||
|           [multiple]="multiple" | ||||
|           [closeOnSelect]="false" | ||||
|           [clearSearchOnAdd]="true" | ||||
|           [hideSelected]="tags.length > 0" | ||||
|           [addTag]="allowCreate ? createTagRef : false" | ||||
|           addTagText="Add tag" | ||||
|           i18n-addTagText | ||||
|           (add)="onAdd($event)" | ||||
|           (change)="onChange(value)"> | ||||
|  | ||||
|           <ng-template ng-label-tmp let-item="item"> | ||||
| @@ -25,9 +28,20 @@ | ||||
|             </button> | ||||
|           </ng-template> | ||||
|           <ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm"> | ||||
|             <div class="tag-wrap"> | ||||
|             <div class="tag-option-row d-flex align-items-center"> | ||||
|               @if (item.id && tags) { | ||||
|                 <pngx-tag class="me-2" [tag]="getTag(item.id)"></pngx-tag> | ||||
|                 @if (getTag(item.id)?.parent) { | ||||
|                   <i-bs name="list-nested" class="me-1"></i-bs> | ||||
|                   <span class="hierarchy-reveal d-flex align-items-center"> | ||||
|                     <span class="parents d-flex align-items-center"> | ||||
|                       @for (p of getParentChain(item.id); track p.id) { | ||||
|                         <span class="badge me-1" [style.background]="p.color" [style.color]="p.text_color">{{p.name}}</span> | ||||
|                         <i-bs name="chevron-right" width=".8em" height=".8em" class="me-1"></i-bs> | ||||
|                       } | ||||
|                     </span> | ||||
|                   </span> | ||||
|                 } | ||||
|                 <pngx-tag class="current-tag d-flex" [tag]="getTag(item.id)"></pngx-tag> | ||||
|               } | ||||
|             </div> | ||||
|           </ng-template> | ||||
|   | ||||
| @@ -20,3 +20,33 @@ | ||||
|       } | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Dropdown hierarchy reveal for ng-select options | ||||
| ::ng-deep .ng-dropdown-panel .ng-option { | ||||
|   overflow-x: scroll; | ||||
|  | ||||
|   .tag-option-row { | ||||
|     font-size: 1rem; | ||||
|     width: max-content; | ||||
|   } | ||||
|  | ||||
|   .hierarchy-reveal { | ||||
|     overflow: hidden; | ||||
|     max-width: 0; | ||||
|     transition: max-width 200ms ease; | ||||
|   } | ||||
|  | ||||
|   .parents .badge { | ||||
|     white-space: nowrap; | ||||
|   } | ||||
| } | ||||
|  | ||||
| ::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-reveal, | ||||
| ::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-reveal { | ||||
|   max-width: 1000px; | ||||
| } | ||||
|  | ||||
| ::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-indicator, | ||||
| ::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-indicator { | ||||
|   background: transparent; | ||||
| } | ||||
|   | ||||
| @@ -177,4 +177,59 @@ describe('TagsComponent', () => { | ||||
|     component.onFilterDocuments() | ||||
|     expect(emitSpy).toHaveBeenCalledWith([tags[2]]) | ||||
|   }) | ||||
|  | ||||
|   it('should remove all descendants from selection', () => { | ||||
|     const c: Tag = { id: 4, name: 'c' } | ||||
|     const b: Tag = { id: 3, name: 'b', children: [c] } | ||||
|     const a: Tag = { id: 2, name: 'a' } | ||||
|     const root: Tag = { id: 1, name: 'root', children: [a, b] } | ||||
|  | ||||
|     const inputIDs = [2, 3, 4, 99] | ||||
|     const result = (component as any).removeChildren(inputIDs, root) | ||||
|     expect(result).toEqual([99]) | ||||
|   }) | ||||
|  | ||||
|   it('should append all parents recursively', () => { | ||||
|     const root: Tag = { id: 1, name: 'root' } | ||||
|     const mid: Tag = { id: 2, name: 'mid', parent: 1 } | ||||
|     const leaf: Tag = { id: 3, name: 'leaf', parent: 2 } | ||||
|     component.tags = [root, mid, leaf] | ||||
|  | ||||
|     component.value = [] | ||||
|     component.onAdd(leaf) | ||||
|     expect(component.value).toEqual([2, 1]) | ||||
|  | ||||
|     // Calling onAdd on a root should not change value | ||||
|     component.onAdd(root) | ||||
|     expect(component.value).toEqual([2, 1]) | ||||
|   }) | ||||
|  | ||||
|   it('should return ancestors from root to parent using getParentChain', () => { | ||||
|     const root: Tag = { id: 1, name: 'root' } | ||||
|     const mid: Tag = { id: 2, name: 'mid', parent: 1 } | ||||
|     const leaf: Tag = { id: 3, name: 'leaf', parent: 2 } | ||||
|     component.tags = [root, mid, leaf] | ||||
|  | ||||
|     expect(component.getParentChain(3).map((t) => t.id)).toEqual([1, 2]) | ||||
|     expect(component.getParentChain(2).map((t) => t.id)).toEqual([1]) | ||||
|     expect(component.getParentChain(1).map((t) => t.id)).toEqual([]) | ||||
|     // Non-existent id | ||||
|     expect(component.getParentChain(999).map((t) => t.id)).toEqual([]) | ||||
|   }) | ||||
|  | ||||
|   it('should handle cyclic parents via guard in getParentChain', () => { | ||||
|     const one: Tag = { id: 1, name: 'one', parent: 2 } | ||||
|     const two: Tag = { id: 2, name: 'two', parent: 1 } | ||||
|     component.tags = [one, two] | ||||
|  | ||||
|     const chain = component.getParentChain(1) | ||||
|     // Guard avoids infinite loop; chain contains both nodes once | ||||
|     expect(chain.map((t) => t.id)).toEqual([1, 2]) | ||||
|   }) | ||||
|  | ||||
|   it('should stop when parent does not exist in getParentChain', () => { | ||||
|     const lone: Tag = { id: 5, name: 'lone', parent: 999 } | ||||
|     component.tags = [lone] | ||||
|     expect(component.getParentChain(5)).toEqual([]) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -100,6 +100,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor { | ||||
|   @Input() | ||||
|   horizontal: boolean = false | ||||
|  | ||||
|   @Input() | ||||
|   multiple: boolean = true | ||||
|  | ||||
|   @Output() | ||||
|   filterDocuments = new EventEmitter<Tag[]>() | ||||
|  | ||||
| @@ -124,13 +127,40 @@ export class TagsComponent implements OnInit, ControlValueAccessor { | ||||
|  | ||||
|     let index = this.value.indexOf(tagID) | ||||
|     if (index > -1) { | ||||
|       const tag = this.getTag(tagID) | ||||
|  | ||||
|       // remove tag | ||||
|       let oldValue = this.value | ||||
|       oldValue.splice(index, 1) | ||||
|  | ||||
|       // remove children | ||||
|       oldValue = this.removeChildren(oldValue, tag) | ||||
|  | ||||
|       this.value = [...oldValue] | ||||
|       this.onChange(this.value) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private removeChildren(tagIDs: number[], tag: Tag) { | ||||
|     if (tag.children?.length) { | ||||
|       const childIDs = tag.children.map((child) => child.id) | ||||
|       tagIDs = tagIDs.filter((id) => !childIDs.includes(id)) | ||||
|       for (const child of tag.children) { | ||||
|         tagIDs = this.removeChildren(tagIDs, child) | ||||
|       } | ||||
|     } | ||||
|     return tagIDs | ||||
|   } | ||||
|  | ||||
|   public onAdd(tag: Tag) { | ||||
|     if (tag.parent) { | ||||
|       // add all parents recursively | ||||
|       const parent = this.getTag(tag.parent) | ||||
|       this.value = [...this.value, parent.id] | ||||
|       this.onAdd(parent) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   createTag(name: string = null, add: boolean = false) { | ||||
|     var modal = this.modalService.open(TagEditDialogComponent, { | ||||
|       backdrop: 'static', | ||||
| @@ -166,6 +196,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor { | ||||
|  | ||||
|   addTag(id) { | ||||
|     this.value = [...this.value, id] | ||||
|     this.onAdd(this.getTag(id)) | ||||
|     this.onChange(this.value) | ||||
|   } | ||||
|  | ||||
| @@ -180,4 +211,20 @@ export class TagsComponent implements OnInit, ControlValueAccessor { | ||||
|       this.tags.filter((t) => this.value.includes(t.id)) | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   getParentChain(id: number): Tag[] { | ||||
|     // Returns ancestors from root → immediate parent for a tag id | ||||
|     const chain: Tag[] = [] | ||||
|     let current = this.getTag(id) | ||||
|     const guard = new Set<number>() | ||||
|     while (current?.parent) { | ||||
|       if (guard.has(current.parent)) break | ||||
|       guard.add(current.parent) | ||||
|       const parent = this.getTag(current.parent) | ||||
|       if (!parent) break | ||||
|       chain.unshift(parent) | ||||
|       current = parent | ||||
|     } | ||||
|     return chain | ||||
|   } | ||||
| } | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user