mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Compare commits
	
		
			276 Commits
		
	
	
		
			8505fa3e54
			...
			dependabot
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 4918793d0a | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 9aee063347 | ||
|   | 7fe411bb1a | ||
|   | 34b5f4c565 | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | 3808a4e14a | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 3bd4135aba | ||
|   | b60fb8ed82 | ||
|   | 3f32ed319a | ||
|   | 03e6d58f86 | ||
|   | c197487374 | ||
|   | d718d7d29f | ||
|   | ce112cda0e | ||
|   | d904aaef60 | ||
|   | 35bc673648 | ||
|   | d0bd111eab | ||
|   | cd81f750b4 | ||
|   | 48d21da13b | ||
|   | 701aafce06 | ||
|   | cbe8bc35d6 | ||
|   | 1c4fa7237c | ||
|   | 63dab0ab09 | ||
|   | 276dc31abe | ||
|   | a11a2ec13f | ||
| ![github-actions[bot]](/assets/img/avatar_default.png)  | df9136e7d4 | ||
|   | 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 | 
| @@ -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" | ||||
| } | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/DISCUSSION_TEMPLATE/support.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/DISCUSSION_TEMPLATE/support.yml
									
									
									
									
										vendored
									
									
								
							| @@ -51,5 +51,5 @@ body: | ||||
|     id: logs | ||||
|     attributes: | ||||
|       label: Relevant logs or output | ||||
|       description: If you have logs, errors that might help, paste it here. | ||||
|       description: If you have logs, errors that might help, paste it here. For example other containers or services (database, redis, etc). | ||||
|       render: bash | ||||
|   | ||||
							
								
								
									
										10
									
								
								.github/ISSUE_TEMPLATE/bug-report.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/ISSUE_TEMPLATE/bug-report.yml
									
									
									
									
										vendored
									
									
								
							| @@ -6,8 +6,8 @@ body: | ||||
|   - type: markdown | ||||
|     attributes: | ||||
|       value: | | ||||
|         ### ⚠️ Please remember: issues are for *bugs* | ||||
|         That is, something you believe affects every single user of Paperless-ngx, not just you. If you're not sure, start with one of the other options below. | ||||
|         ### ⚠️ Please remember: issues are for *bugs* only! ⚠️ | ||||
|         That is, something you believe affects every single user of Paperless-ngx (and the demo, for example), not just you. If you are not sure, start with one of the other options below. | ||||
|  | ||||
|         Also, note that **Paperless-ngx does not perform OCR or archive file creation itself**, those are handled by other tools. Problems with OCR or archive versions of specific files should likely be raised 'upstream', see https://github.com/ocrmypdf/OCRmyPDF/issues or https://github.com/tesseract-ocr/tesseract/issues | ||||
|   - type: markdown | ||||
| @@ -59,6 +59,12 @@ body: | ||||
|       label: Browser logs | ||||
|       description: Logs from the web browser related to your issue, if needed | ||||
|       render: bash | ||||
|   - type: textarea | ||||
|     id: logs_services | ||||
|     attributes: | ||||
|       label: Services logs | ||||
|       description: Logs from other services (or containers) related to your issue, if needed. For example, the database or redis logs. | ||||
|       render: bash | ||||
|   - type: input | ||||
|     id: version | ||||
|     attributes: | ||||
|   | ||||
							
								
								
									
										113
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										113
									
								
								.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 }} | ||||
| @@ -136,15 +181,14 @@ jobs: | ||||
|             pytest | ||||
|       - name: Upload backend test results to Codecov | ||||
|         if: always() | ||||
|         uses: codecov/test-results-action@v1 | ||||
|         uses: codecov/codecov-action@v5 | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: backend-python-${{ matrix.python-version }} | ||||
|           files: junit.xml | ||||
|           report_type: test_results | ||||
|       - 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 +202,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 +235,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' | ||||
| @@ -217,16 +261,15 @@ jobs: | ||||
|       - name: Run Jest unit tests | ||||
|         run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }} | ||||
|       - name: Upload frontend test results to Codecov | ||||
|         uses: codecov/test-results-action@v1 | ||||
|         if: always() | ||||
|         uses: codecov/codecov-action@v5 | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: frontend-node-${{ matrix.node-version }} | ||||
|           directory: src-ui/ | ||||
|           report_type: test_results | ||||
|       - 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 +284,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 +327,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 +355,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 +402,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 +472,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 +492,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 +577,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 +618,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 +655,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; | ||||
|   | ||||
							
								
								
									
										9
									
								
								.github/workflows/repo-maintenance.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.github/workflows/repo-maintenance.yml
									
									
									
									
										vendored
									
									
								
							| @@ -15,7 +15,7 @@ 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 | ||||
| @@ -57,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) { | ||||
| @@ -114,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) { | ||||
| @@ -206,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) { | ||||
| @@ -241,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 | ||||
|  | ||||
| @@ -147,7 +135,7 @@ community members. That said, in an effort to keep the repository organized and | ||||
| - 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 | ||||
|  | ||||
|   | ||||
| @@ -4,7 +4,7 @@ | ||||
| # 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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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/) | ||||
| @@ -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,508 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## paperless-ngx 2.19.3 | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| -   Fix: remove unnecessary permission requirements for new email endpoint [@shamoon](https://github.com/shamoon) ([#11215](https://github.com/paperless-ngx/paperless-ngx/pull/11215)) | ||||
| -   Fix: refactor nested sorting in filterable dropdowns [@shamoon](https://github.com/shamoon) ([#11214](https://github.com/paperless-ngx/paperless-ngx/pull/11214)) | ||||
| -   Fix: add root tag filtering for tag list page consistency, fix toggle all [@shamoon](https://github.com/shamoon) ([#11208](https://github.com/paperless-ngx/paperless-ngx/pull/11208)) | ||||
| -   Fix: support ConsumableDocument in email attachments [@shamoon](https://github.com/shamoon) ([#11196](https://github.com/paperless-ngx/paperless-ngx/pull/11196)) | ||||
| -   Fix: add missing import for ConfirmButtonComponent in user-edit-dialog [@shamoon](https://github.com/shamoon) ([#11167](https://github.com/paperless-ngx/paperless-ngx/pull/11167)) | ||||
| -   Fix: resolve migration warning in 2.19.2 [@shamoon](https://github.com/shamoon) ([#11157](https://github.com/paperless-ngx/paperless-ngx/pull/11157)) | ||||
|  | ||||
| ### Changes | ||||
|  | ||||
| -   Change: make workflow action only title draggable [@shamoon](https://github.com/shamoon) ([#11209](https://github.com/paperless-ngx/paperless-ngx/pull/11209)) | ||||
| -   Change: change workflowrun to softdeletemodel [@shamoon](https://github.com/shamoon) ([#11194](https://github.com/paperless-ngx/paperless-ngx/pull/11194)) | ||||
|  | ||||
| ### Dependencies | ||||
|  | ||||
| -   Chore(deps): Bump django from 5.2.6 to 5.2.7 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11200](https://github.com/paperless-ngx/paperless-ngx/pull/11200)) | ||||
|  | ||||
| ### All App Changes | ||||
|  | ||||
| <details> | ||||
| <summary>9 changes</summary> | ||||
|  | ||||
| -   Chore(deps): Bump django from 5.2.6 to 5.2.7 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11200](https://github.com/paperless-ngx/paperless-ngx/pull/11200)) | ||||
| -   Fix: remove unnecessary permission requirements for new email endpoint [@shamoon](https://github.com/shamoon) ([#11215](https://github.com/paperless-ngx/paperless-ngx/pull/11215)) | ||||
| -   Fix: refactor nested sorting in filterable dropdowns [@shamoon](https://github.com/shamoon) ([#11214](https://github.com/paperless-ngx/paperless-ngx/pull/11214)) | ||||
| -   Fix: add root tag filtering for tag list page consistency, fix toggle all [@shamoon](https://github.com/shamoon) ([#11208](https://github.com/paperless-ngx/paperless-ngx/pull/11208)) | ||||
| -   Change: make workflow action only title draggable [@shamoon](https://github.com/shamoon) ([#11209](https://github.com/paperless-ngx/paperless-ngx/pull/11209)) | ||||
| -   Change: change workflowrun to softdeletemodel [@shamoon](https://github.com/shamoon) ([#11194](https://github.com/paperless-ngx/paperless-ngx/pull/11194)) | ||||
| -   Chore: Minor migration optimization for workflow titles [@stumpylog](https://github.com/stumpylog) ([#11197](https://github.com/paperless-ngx/paperless-ngx/pull/11197)) | ||||
| -   Fix: support ConsumableDocument in email attachments [@shamoon](https://github.com/shamoon) ([#11196](https://github.com/paperless-ngx/paperless-ngx/pull/11196)) | ||||
| -   Fix: add missing import for ConfirmButtonComponent in user-edit-dialog [@shamoon](https://github.com/shamoon) ([#11167](https://github.com/paperless-ngx/paperless-ngx/pull/11167)) | ||||
| -   Fix: resolve migration warning in 2.19.2 [@shamoon](https://github.com/shamoon) ([#11157](https://github.com/paperless-ngx/paperless-ngx/pull/11157)) | ||||
| </details> | ||||
|  | ||||
| ## paperless-ngx 2.19.2 | ||||
|  | ||||
| ### Features / Enhancements | ||||
|  | ||||
| -   Fixhancement: display loading status for tags instead of 'Private' [@shamoon](https://github.com/shamoon) ([#11140](https://github.com/paperless-ngx/paperless-ngx/pull/11140)) | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| -   Fix: Remove edit requirement for bulk email, show based on setting [@shamoon](https://github.com/shamoon) ([#11149](https://github.com/paperless-ngx/paperless-ngx/pull/11149)) | ||||
| -   Fix: handle undefined IDs in getOriginalObject [@shamoon](https://github.com/shamoon) ([#11147](https://github.com/paperless-ngx/paperless-ngx/pull/11147)) | ||||
|  | ||||
| ### All App Changes | ||||
|  | ||||
| <details> | ||||
| <summary>3 changes</summary> | ||||
|  | ||||
| -   Fix: Remove edit requirement for bulk email, show based on setting [@shamoon](https://github.com/shamoon) ([#11149](https://github.com/paperless-ngx/paperless-ngx/pull/11149)) | ||||
| -   Fix: handle undefined IDs in getOriginalObject [@shamoon](https://github.com/shamoon) ([#11147](https://github.com/paperless-ngx/paperless-ngx/pull/11147)) | ||||
| -   Fixhancement: display loading status for tags instead of 'Private' [@shamoon](https://github.com/shamoon) ([#11140](https://github.com/paperless-ngx/paperless-ngx/pull/11140)) | ||||
| </details> | ||||
|  | ||||
| ## 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 +5926,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 +6727,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 | ||||
|   | ||||
| @@ -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** | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										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} | ||||
|   | ||||
| @@ -374,7 +374,7 @@ fi | ||||
| # of the provided folder | ||||
| if [[ -n $DATABASE_FOLDER ]] ; then | ||||
| 	if [[ "$DATABASE_BACKEND" == "postgres" ]] ; then | ||||
| 		sed -i "s#- pgdata:/var/lib/postgresql/data#- $DATABASE_FOLDER:/var/lib/postgresql/data#g" docker-compose.yml | ||||
| 		sed -i "s#- pgdata:/var/lib/postgresql#- $DATABASE_FOLDER:/var/lib/postgresql#g" docker-compose.yml | ||||
| 		sed -i "/^\s*pgdata:/d" docker-compose.yml | ||||
| 	elif [[ "$DATABASE_BACKEND" == "mariadb" ]]; then | ||||
| 		sed -i "s#- dbdata:/var/lib/mysql#- $DATABASE_FOLDER:/var/lib/mysql#g" docker-compose.yml | ||||
|   | ||||
| @@ -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.3" | ||||
| 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.10.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() | ||||
|   | ||||
							
								
								
									
										1931
									
								
								src-ui/messages.xlf
									
									
									
									
									
								
							
							
						
						
									
										1931
									
								
								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.3", | ||||
|   "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> | ||||
|   | ||||
| @@ -358,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> | ||||
|   | ||||
| @@ -36,6 +36,7 @@ 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' | ||||
| @@ -60,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> | ||||
| @@ -225,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])) | ||||
| @@ -241,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', () => { | ||||
| @@ -269,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', () => { | ||||
| @@ -281,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() | ||||
| @@ -335,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, { | ||||
|   | ||||
| @@ -57,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' | ||||
| @@ -184,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 | ||||
|     ) | ||||
|   } | ||||
|  | ||||
| @@ -550,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,15 +108,16 @@ | ||||
|                 <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}} | ||||
|                       @if (showSidebarCounts && !slimSidebarEnabled) { | ||||
|                         <span><span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span></span> | ||||
|                       } | ||||
|                     </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> | ||||
|                     } | ||||
| @@ -146,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 { | ||||
|   | ||||
| @@ -145,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 { | ||||
| @@ -287,6 +287,9 @@ export class AppFrameComponent | ||||
|   } | ||||
|  | ||||
|   get showSidebarCounts(): boolean { | ||||
|     return this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) | ||||
|     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 | ||||
|   } | ||||
| @@ -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> | ||||
|           } | ||||
|   | ||||
| @@ -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), | ||||
|   | ||||
| @@ -14,6 +14,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 { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' | ||||
| import { PasswordComponent } from '../../input/password/password.component' | ||||
| import { SelectComponent } from '../../input/select/select.component' | ||||
| import { TextComponent } from '../../input/text/text.component' | ||||
| @@ -28,6 +29,7 @@ import { PermissionsSelectComponent } from '../../permissions-select/permissions | ||||
|     SelectComponent, | ||||
|     TextComponent, | ||||
|     PasswordComponent, | ||||
|     ConfirmButtonComponent, | ||||
|     FormsModule, | ||||
|     ReactiveFormsModule, | ||||
|   ], | ||||
|   | ||||
| @@ -77,9 +77,11 @@ | ||||
|               </div> | ||||
|               <div ngbAccordion [closeOthers]="true" cdkDropList (cdkDropListDropped)="onActionDrop($event)"> | ||||
|                 @for (action of object?.actions; track action; let i = $index){ | ||||
|                   <div ngbAccordionItem cdkDrag [formGroup]="actionFields.controls[i]"> | ||||
|                     <div ngbAccordionHeader> | ||||
|                       <button ngbAccordionButton>{{i + 1}}. {{getActionTypeOptionName(actionFields.controls[i].value.type)}} | ||||
|                   <div ngbAccordionItem [formGroup]="actionFields.controls[i]"> | ||||
|                     <div ngbAccordionHeader cdkDrag> | ||||
|                       <button ngbAccordionButton> | ||||
|                         <i-bs name="grip-vertical" class="ms-n3 pe-1"></i-bs> | ||||
|                         {{i + 1}}. {{getActionTypeOptionName(actionFields.controls[i].value.type)}} | ||||
|                         @if(action.id) { | ||||
|                           <span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{action.id}}</span> | ||||
|                         } | ||||
| @@ -156,30 +158,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,11 @@ | ||||
| .accordion-button { | ||||
|     font-size: 1rem; | ||||
| } | ||||
|  | ||||
| :host ::ng-deep .filters .paperless-input-select.mb-3 { | ||||
|     margin-bottom: 0 !important; | ||||
| } | ||||
|  | ||||
| .ms-n3 { | ||||
|     margin-left: -1rem !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) { | ||||
|   | ||||
| @@ -564,6 +564,167 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('keeps children with their parent when parent has document count', () => { | ||||
|     const parent: Tag = { | ||||
|       id: 10, | ||||
|       name: 'Parent Tag', | ||||
|       orderIndex: 0, | ||||
|       document_count: 2, | ||||
|     } | ||||
|     const child: Tag = { | ||||
|       id: 11, | ||||
|       name: 'Child Tag', | ||||
|       parent: parent.id, | ||||
|       orderIndex: 1, | ||||
|       document_count: 0, | ||||
|     } | ||||
|     const otherRoot: Tag = { | ||||
|       id: 20, | ||||
|       name: 'Other Tag', | ||||
|       orderIndex: 2, | ||||
|       document_count: 0, | ||||
|     } | ||||
|  | ||||
|     component.selectionModel.items = [parent, child, otherRoot] | ||||
|     component.selectionModel = selectionModel | ||||
|     component.documentCounts = [ | ||||
|       { id: parent.id, document_count: 2 }, | ||||
|       { id: otherRoot.id, document_count: 0 }, | ||||
|     ] | ||||
|     selectionModel.apply() | ||||
|  | ||||
|     expect(component.selectionModel.items).toEqual([ | ||||
|       nullItem, | ||||
|       parent, | ||||
|       child, | ||||
|       otherRoot, | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('keeps selected branches ahead of document-based ordering', () => { | ||||
|     const selectedRoot: Tag = { | ||||
|       id: 30, | ||||
|       name: 'Selected Root', | ||||
|       orderIndex: 0, | ||||
|       document_count: 0, | ||||
|     } | ||||
|     const otherRoot: Tag = { | ||||
|       id: 40, | ||||
|       name: 'Other Root', | ||||
|       orderIndex: 1, | ||||
|       document_count: 2, | ||||
|     } | ||||
|  | ||||
|     component.selectionModel.items = [selectedRoot, otherRoot] | ||||
|     component.selectionModel = selectionModel | ||||
|     selectionModel.set(selectedRoot.id, ToggleableItemState.Selected) | ||||
|     component.documentCounts = [ | ||||
|       { id: selectedRoot.id, document_count: 0 }, | ||||
|       { id: otherRoot.id, document_count: 2 }, | ||||
|     ] | ||||
|     selectionModel.apply() | ||||
|  | ||||
|     expect(component.selectionModel.items).toEqual([ | ||||
|       nullItem, | ||||
|       selectedRoot, | ||||
|       otherRoot, | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('uses fallback document counts when selection data is missing', () => { | ||||
|     const fallbackRoot: Tag = { | ||||
|       id: 50, | ||||
|       name: 'Fallback Root', | ||||
|       orderIndex: 0, | ||||
|       document_count: 3, | ||||
|     } | ||||
|     const fallbackChild: Tag = { | ||||
|       id: 51, | ||||
|       name: 'Fallback Child', | ||||
|       parent: fallbackRoot.id, | ||||
|       orderIndex: 1, | ||||
|       document_count: 0, | ||||
|     } | ||||
|     const otherRoot: Tag = { | ||||
|       id: 60, | ||||
|       name: 'Other Root', | ||||
|       orderIndex: 2, | ||||
|       document_count: 0, | ||||
|     } | ||||
|  | ||||
|     component.selectionModel = selectionModel | ||||
|     selectionModel.items = [fallbackRoot, fallbackChild, otherRoot] | ||||
|     component.documentCounts = [{ id: otherRoot.id, document_count: 0 }] | ||||
|  | ||||
|     selectionModel.apply() | ||||
|  | ||||
|     expect(selectionModel.items).toEqual([ | ||||
|       nullItem, | ||||
|       fallbackRoot, | ||||
|       fallbackChild, | ||||
|       otherRoot, | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('handles special and non-numeric ids when promoting branches', () => { | ||||
|     const rootWithDocs: Tag = { | ||||
|       id: 70, | ||||
|       name: 'Root With Docs', | ||||
|       orderIndex: 0, | ||||
|       document_count: 1, | ||||
|     } | ||||
|     const miscItem: any = { id: 'misc', name: 'Misc Item' } | ||||
|  | ||||
|     component.selectionModel = selectionModel | ||||
|     selectionModel.intersection = Intersection.Exclude | ||||
|     selectionModel.items = [rootWithDocs, miscItem as any] | ||||
|     component.documentCounts = [{ id: rootWithDocs.id, document_count: 1 }] | ||||
|  | ||||
|     selectionModel.apply() | ||||
|  | ||||
|     expect(selectionModel.items.map((item) => item.id)).toEqual([ | ||||
|       NEGATIVE_NULL_FILTER_VALUE, | ||||
|       rootWithDocs.id, | ||||
|       'misc', | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('memoizes root document counts between lookups', () => { | ||||
|     const memoRoot: Tag = { id: 80, name: 'Memo Root' } | ||||
|     selectionModel.items = [memoRoot] | ||||
|     selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }] | ||||
|  | ||||
|     const getRootDocCount = (selectionModel as any).createRootDocCounter() | ||||
|  | ||||
|     expect(getRootDocCount(memoRoot.id)).toEqual(9) | ||||
|     selectionModel.documentCounts = [] | ||||
|     expect(getRootDocCount(memoRoot.id)).toEqual(9) | ||||
|   }) | ||||
|  | ||||
|   it('falls back to model stored document counts if selection data missing entry', () => { | ||||
|     const rootWithoutSelection: Tag = { | ||||
|       id: 90, | ||||
|       name: 'Fallback Root', | ||||
|       document_count: 4, | ||||
|     } | ||||
|     selectionModel.items = [rootWithoutSelection] | ||||
|     selectionModel.documentCounts = [] | ||||
|  | ||||
|     const getRootDocCount = (selectionModel as any).createRootDocCounter() | ||||
|  | ||||
|     expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4) | ||||
|   }) | ||||
|  | ||||
|   it('defaults to zero document count when neither selection nor model provide it', () => { | ||||
|     const rootWithoutCounts: Tag = { id: 91, name: 'Fallback Zero Root' } | ||||
|     selectionModel.items = [rootWithoutCounts] | ||||
|     selectionModel.documentCounts = [] | ||||
|  | ||||
|     const getRootDocCount = (selectionModel as any).createRootDocCounter() | ||||
|  | ||||
|     expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0) | ||||
|   }) | ||||
|  | ||||
|   it('should set support create, keep open model and call createRef method', fakeAsync(() => { | ||||
|     component.selectionModel.items = items | ||||
|     component.icon = 'tag-fill' | ||||
|   | ||||
| @@ -32,6 +32,14 @@ export interface ChangedItems { | ||||
|   itemsToRemove: MatchingModel[] | ||||
| } | ||||
|  | ||||
| type BranchSummary = { | ||||
|   items: MatchingModel[] | ||||
|   firstIndex: number | ||||
|   special: boolean | ||||
|   selected: boolean | ||||
|   hasDocs: boolean | ||||
| } | ||||
|  | ||||
| export enum LogicalOperator { | ||||
|   And = 'and', | ||||
|   Or = 'or', | ||||
| @@ -114,6 +122,13 @@ export class FilterableDropdownSelectionModel { | ||||
|           b.id == NEGATIVE_NULL_FILTER_VALUE) | ||||
|       ) { | ||||
|         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 if ( | ||||
|         this.getNonTemporary(a.id) == ToggleableItemState.NotSelected && | ||||
|         this.getNonTemporary(b.id) != ToggleableItemState.NotSelected | ||||
| @@ -140,6 +155,10 @@ export class FilterableDropdownSelectionModel { | ||||
|         return a.name.localeCompare(b.name) | ||||
|       } | ||||
|     }) | ||||
|  | ||||
|     if (this._documentCounts.length) { | ||||
|       this.promoteBranchesWithDocumentCounts() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private selectionStates = new Map<number, ToggleableItemState>() | ||||
| @@ -373,6 +392,180 @@ export class FilterableDropdownSelectionModel { | ||||
|     return this._documentCounts.find((c) => c.id === id)?.document_count | ||||
|   } | ||||
|  | ||||
|   private promoteBranchesWithDocumentCounts() { | ||||
|     const parentById = this.buildParentById() | ||||
|     const findRootId = this.createRootFinder(parentById) | ||||
|     const getRootDocCount = this.createRootDocCounter() | ||||
|     const summaries = this.buildBranchSummaries(findRootId, getRootDocCount) | ||||
|     const orderedBranches = this.orderBranchesByPriority(summaries) | ||||
|  | ||||
|     this._items = orderedBranches.flatMap((summary) => summary.items) | ||||
|   } | ||||
|  | ||||
|   private buildParentById(): Map<number, number | null> { | ||||
|     const parentById = new Map<number, number | null>() | ||||
|  | ||||
|     for (const item of this._items) { | ||||
|       if (typeof item?.id === 'number') { | ||||
|         const parentValue = (item as any)['parent'] | ||||
|         parentById.set( | ||||
|           item.id, | ||||
|           typeof parentValue === 'number' ? parentValue : null | ||||
|         ) | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return parentById | ||||
|   } | ||||
|  | ||||
|   private createRootFinder( | ||||
|     parentById: Map<number, number | null> | ||||
|   ): (id: number) => number { | ||||
|     const rootMemo = new Map<number, number>() | ||||
|  | ||||
|     const findRootId = (id: number): number => { | ||||
|       const cached = rootMemo.get(id) | ||||
|       if (cached !== undefined) { | ||||
|         return cached | ||||
|       } | ||||
|  | ||||
|       const parentId = parentById.get(id) | ||||
|       if (parentId === undefined || parentId === null) { | ||||
|         rootMemo.set(id, id) | ||||
|         return id | ||||
|       } | ||||
|  | ||||
|       const rootId = findRootId(parentId) | ||||
|       rootMemo.set(id, rootId) | ||||
|       return rootId | ||||
|     } | ||||
|  | ||||
|     return findRootId | ||||
|   } | ||||
|  | ||||
|   private createRootDocCounter(): (rootId: number) => number { | ||||
|     const docCountMemo = new Map<number, number>() | ||||
|  | ||||
|     return (rootId: number): number => { | ||||
|       const cached = docCountMemo.get(rootId) | ||||
|       if (cached !== undefined) { | ||||
|         return cached | ||||
|       } | ||||
|  | ||||
|       const explicit = this.getDocumentCount(rootId) | ||||
|       if (typeof explicit === 'number') { | ||||
|         docCountMemo.set(rootId, explicit) | ||||
|         return explicit | ||||
|       } | ||||
|  | ||||
|       const rootItem = this._items.find((i) => i.id === rootId) | ||||
|       const fallback = | ||||
|         typeof (rootItem as any)?.['document_count'] === 'number' | ||||
|           ? (rootItem as any)['document_count'] | ||||
|           : 0 | ||||
|  | ||||
|       docCountMemo.set(rootId, fallback) | ||||
|       return fallback | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private buildBranchSummaries( | ||||
|     findRootId: (id: number) => number, | ||||
|     getRootDocCount: (rootId: number) => number | ||||
|   ): Map<string, BranchSummary> { | ||||
|     const summaries = new Map<string, BranchSummary>() | ||||
|  | ||||
|     for (const [index, item] of this._items.entries()) { | ||||
|       const { key, special, rootId } = this.describeBranchItem( | ||||
|         item, | ||||
|         index, | ||||
|         findRootId | ||||
|       ) | ||||
|  | ||||
|       let summary = summaries.get(key) | ||||
|       if (!summary) { | ||||
|         summary = { | ||||
|           items: [], | ||||
|           firstIndex: index, | ||||
|           special, | ||||
|           selected: false, | ||||
|           hasDocs: | ||||
|             special || rootId === null ? false : getRootDocCount(rootId) > 0, | ||||
|         } | ||||
|         summaries.set(key, summary) | ||||
|       } | ||||
|  | ||||
|       summary.items.push(item) | ||||
|  | ||||
|       if (this.shouldMarkSummarySelected(summary, item)) { | ||||
|         summary.selected = true | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return summaries | ||||
|   } | ||||
|  | ||||
|   private describeBranchItem( | ||||
|     item: MatchingModel, | ||||
|     index: number, | ||||
|     findRootId: (id: number) => number | ||||
|   ): { key: string; special: boolean; rootId: number | null } { | ||||
|     if (item?.id === null) { | ||||
|       return { key: 'null', special: true, rootId: null } | ||||
|     } | ||||
|  | ||||
|     if (item?.id === NEGATIVE_NULL_FILTER_VALUE) { | ||||
|       return { key: 'neg-null', special: true, rootId: null } | ||||
|     } | ||||
|  | ||||
|     if (typeof item?.id === 'number') { | ||||
|       const rootId = findRootId(item.id) | ||||
|       return { key: `root-${rootId}`, special: false, rootId } | ||||
|     } | ||||
|  | ||||
|     return { key: `misc-${index}`, special: false, rootId: null } | ||||
|   } | ||||
|  | ||||
|   private shouldMarkSummarySelected( | ||||
|     summary: BranchSummary, | ||||
|     item: MatchingModel | ||||
|   ): boolean { | ||||
|     if (summary.special) { | ||||
|       return false | ||||
|     } | ||||
|  | ||||
|     if (typeof item?.id !== 'number') { | ||||
|       return false | ||||
|     } | ||||
|  | ||||
|     return this.getNonTemporary(item.id) !== ToggleableItemState.NotSelected | ||||
|   } | ||||
|  | ||||
|   private orderBranchesByPriority( | ||||
|     summaries: Map<string, BranchSummary> | ||||
|   ): BranchSummary[] { | ||||
|     return Array.from(summaries.values()).sort((a, b) => { | ||||
|       const rankDiff = this.branchRank(a) - this.branchRank(b) | ||||
|       if (rankDiff !== 0) { | ||||
|         return rankDiff | ||||
|       } | ||||
|       if (a.hasDocs !== b.hasDocs) { | ||||
|         return a.hasDocs ? -1 : 1 | ||||
|       } | ||||
|       return a.firstIndex - b.firstIndex | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   private branchRank(summary: BranchSummary): number { | ||||
|     if (summary.special) { | ||||
|       return -1 | ||||
|     } | ||||
|     if (summary.selected) { | ||||
|       return 0 | ||||
|     } | ||||
|     return 1 | ||||
|   } | ||||
|  | ||||
|   init(map: Map<number, ToggleableItemState>) { | ||||
|     this.temporarySelectionStates = map | ||||
|     this.apply() | ||||
|   | ||||
| @@ -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> { | ||||
|   | ||||
| @@ -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 | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import { | ||||
|   NG_VALUE_ACCESSOR, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' | ||||
| import { AbstractInputComponent } from '../abstract-input' | ||||
|  | ||||
| @@ -18,7 +19,12 @@ import { AbstractInputComponent } from '../abstract-input' | ||||
|   selector: 'pngx-input-textarea', | ||||
|   templateUrl: './textarea.component.html', | ||||
|   styleUrls: ['./textarea.component.scss'], | ||||
|   imports: [FormsModule, ReactiveFormsModule, SafeHtmlPipe], | ||||
|   imports: [ | ||||
|     FormsModule, | ||||
|     ReactiveFormsModule, | ||||
|     SafeHtmlPipe, | ||||
|     NgxBootstrapIconsModule, | ||||
|   ], | ||||
| }) | ||||
| export class TextAreaComponent extends AbstractInputComponent<string> { | ||||
|   @Input() | ||||
|   | ||||
| @@ -0,0 +1,107 @@ | ||||
| <pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer> | ||||
| <div class="modal-header"> | ||||
|   <h4 class="modal-title">{{ title }}</h4> | ||||
|   <button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button> | ||||
| </div> | ||||
| <div class="modal-body"> | ||||
|   <div class="btn-toolbar mb-2"> | ||||
|     <div class="btn-group me-3"> | ||||
|       <button class="btn btn-sm btn-secondary" (click)="selectAll()" title="Select all pages" i18n-title> | ||||
|         <i-bs name="check-all"></i-bs> | ||||
|       </button> | ||||
|       <button class="btn btn-sm btn-secondary" (click)="deselectAll()" [disabled]="!hasSelection()" title="Deselect all pages" i18n-title> | ||||
|         <i-bs name="x"></i-bs> | ||||
|       </button> | ||||
|     </div> | ||||
|     <div class="btn-group"> | ||||
|       <button class="btn btn-sm btn-secondary" (click)="rotateSelected(-90)" [disabled]="!hasSelection()" title="Rotate selected pages counter-clockwise" i18n-title> | ||||
|         <i-bs name="arrow-counterclockwise"></i-bs> | ||||
|       </button> | ||||
|       <button class="btn btn-sm btn-secondary" (click)="rotateSelected(90)" [disabled]="!hasSelection()" title="Rotate selected pages clockwise" i18n-title> | ||||
|         <i-bs name="arrow-clockwise"></i-bs> | ||||
|       </button> | ||||
|       <button class="btn btn-sm btn-danger" (click)="deleteSelected()" [disabled]="!hasSelection()" title="Delete selected pages" i18n-title> | ||||
|         <i-bs name="trash"></i-bs> | ||||
|       </button> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div cdkDropList (cdkDropListDropped)="drop($event)" cdkDropListOrientation="mixed" class="d-flex flex-wrap row-cols-2 row-cols-md-5"> | ||||
|     @for (p of pages; track p.page; let i = $index) { | ||||
|       <div class="page-item rounded p-2" cdkDrag (click)="toggleSelection(i)" [class.selected]="p.selected"> | ||||
|         <div class="btn-toolbar hover-actions z-10"> | ||||
|           <div class="btn-group me-2"> | ||||
|             <button class="btn btn-sm btn-dark" (click)="rotate(i, true); $event.stopPropagation()" title="Rotate page counter-clockwise" i18n-title> | ||||
|               <i-bs name="arrow-counterclockwise"></i-bs> | ||||
|             </button> | ||||
|             <button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page clockwise" i18n-title> | ||||
|               <i-bs name="arrow-clockwise"></i-bs> | ||||
|             </button> | ||||
|           </div> | ||||
|           <div class="btn-group"> | ||||
|             <button class="btn btn-sm btn-dark text-danger" (click)="remove(i); $event.stopPropagation()" title="Delete page" i18n-title> | ||||
|               <i-bs name="trash"></i-bs> | ||||
|             </button> | ||||
|             <button class="btn btn-sm btn-dark" (click)="toggleSplit(i); $event.stopPropagation()" title="Add / remove document split here" i18n-title> | ||||
|               <i-bs name="scissors"></i-bs> | ||||
|             </button> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="border-end border-bottom bg-light py-1 px-2 document-check z-10"> | ||||
|           <div class="form-check"> | ||||
|             <input type="checkbox" class="form-check-input" id="page{{i}}" [checked]="p.selected" (click)="toggleSelection(i); $event.stopPropagation()"> | ||||
|             <label class="form-check-label" for="page{{i}}"></label> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="pdf-viewer-container w-100" [class.selected]="p.selected"> | ||||
|           @defer (on viewport) { | ||||
|             @if (!p.loaded) { | ||||
|               <div class="placeholder-glow w-100 h-100 z-10"> | ||||
|                 <span class="placeholder w-100 h-100"></span> | ||||
|               </div> | ||||
|             } | ||||
|             <pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [original-size]="false" [show-all]="false" [render-text]="false" (page-rendered)="p.loaded = true"></pdf-viewer> | ||||
|           } @placeholder { | ||||
|             <div class="placeholder-glow w-100 h-100 z-10"> | ||||
|               <span class="placeholder w-100 h-100"></span> | ||||
|             </div> | ||||
|           } | ||||
|         </div> | ||||
|         @if (p.splitAfter) { | ||||
|           <div class="split-after rounded position-absolute top-0 end-0 bg-dark text-uppercase text-center h-100 px-1 small fw-bold">— <span i18n>Split here</span> —</div> | ||||
|         } | ||||
|       </div> | ||||
|     } | ||||
|   </div> | ||||
| </div> | ||||
| <div class="modal-footer"> | ||||
|   <div class="d-flex flex-column flex-md-row w-100 gap-3 align-items-center"> | ||||
|     <div class="btn-group" role="group"> | ||||
|       <input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Create" id="editModeCreate" name="editmode"> | ||||
|       <label for="editModeCreate" class="btn btn-outline-primary btn-sm"> | ||||
|         <i-bs name="plus"></i-bs> | ||||
|         <span class="form-check-label ms-1" i18n>Create new document(s)</span> | ||||
|       </label> | ||||
|       <input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Update" id="editModeUpdate" name="editmode" [disabled]="hasSplit()"> | ||||
|       <label for="editModeUpdate" class="btn btn-outline-primary btn-sm"> | ||||
|         <i-bs name="pencil"></i-bs> | ||||
|         <span class="form-check-label ms-2" i18n>Update existing document</span> | ||||
|       </label> | ||||
|     </div> | ||||
|     @if (editMode === PdfEditorEditMode.Create) { | ||||
|       <div class="form-group d-flex"> | ||||
|         <div class="form-check"> | ||||
|           <input class="form-check-input" type="checkbox" id="copyMeta" [(ngModel)]="includeMetadata"> | ||||
|           <label class="form-check-label" for="copyMeta" i18n>Copy metadata</label> | ||||
|         </div> | ||||
|         <div class="form-check ms-3"> | ||||
|           <input class="form-check-input" type="checkbox" id="deleteOriginal" [(ngModel)]="deleteOriginal"> | ||||
|           <label class="form-check-label" for="deleteOriginal" i18n>Delete original</label> | ||||
|         </div> | ||||
|       </div> | ||||
|     } | ||||
|     <div class="form-group ms-md-auto"> | ||||
|       <button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button> | ||||
|       <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user