mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Compare commits
	
		
			192 Commits
		
	
	
		
			95ed997717
			...
			feature-tr
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 46345376b0 | ||
|   | 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 | 
| @@ -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" | ||||
| } | ||||
|   | ||||
							
								
								
									
										101
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										101
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -17,18 +17,59 @@ env: | ||||
|   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 | ||||
| @@ -40,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 | ||||
| @@ -90,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 | ||||
| @@ -142,13 +183,11 @@ jobs: | ||||
|         if: always() | ||||
|         uses: codecov/test-results-action@v1 | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: backend-python-${{ matrix.python-version }} | ||||
|           files: junit.xml | ||||
|       - name: Upload backend coverage to Codecov | ||||
|         uses: codecov/codecov-action@v5 | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: backend-python-${{ matrix.python-version }} | ||||
|           files: coverage.xml | ||||
|       - name: Stop containers | ||||
| @@ -162,13 +201,13 @@ jobs: | ||||
|     needs: | ||||
|       - pre-commit | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/checkout@v5 | ||||
|       - name: Install pnpm | ||||
|         uses: pnpm/action-setup@v4 | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v4 | ||||
|         uses: actions/setup-node@v5 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -195,13 +234,13 @@ jobs: | ||||
|         shard-index: [1, 2, 3, 4] | ||||
|         shard-count: [4] | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/checkout@v5 | ||||
|       - name: Install pnpm | ||||
|         uses: pnpm/action-setup@v4 | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v4 | ||||
|         uses: actions/setup-node@v5 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -224,13 +263,11 @@ jobs: | ||||
|         uses: codecov/test-results-action@v1 | ||||
|         if: always() | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: frontend-node-${{ matrix.node-version }} | ||||
|           directory: src-ui/ | ||||
|       - name: Upload frontend coverage to Codecov | ||||
|         uses: codecov/codecov-action@v5 | ||||
|         with: | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|           flags: frontend-node-${{ matrix.node-version }} | ||||
|           directory: src-ui/coverage/ | ||||
|   tests-frontend-e2e: | ||||
| @@ -245,13 +282,13 @@ jobs: | ||||
|         shard-index: [1, 2] | ||||
|         shard-count: [2] | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/checkout@v5 | ||||
|       - name: Install pnpm | ||||
|         uses: pnpm/action-setup@v4 | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v4 | ||||
|         uses: actions/setup-node@v5 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -288,13 +325,13 @@ jobs: | ||||
|       - tests-frontend | ||||
|       - tests-frontend-e2e | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/checkout@v5 | ||||
|       - name: Install pnpm | ||||
|         uses: pnpm/action-setup@v4 | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v4 | ||||
|         uses: actions/setup-node@v5 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -316,7 +353,7 @@ jobs: | ||||
|   build-docker-image: | ||||
|     name: Build Docker image for ${{ github.ref_name }} | ||||
|     runs-on: ubuntu-24.04 | ||||
|     if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || startsWith(github.ref, 'refs/heads/fix-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v')) | ||||
|     if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || startsWith(github.ref, 'refs/heads/fix-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/l10n_')) | ||||
|     concurrency: | ||||
|       group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }} | ||||
|       cancel-in-progress: true | ||||
| @@ -363,7 +400,7 @@ jobs: | ||||
|             type=semver,pattern={{version}} | ||||
|             type=semver,pattern={{major}}.{{minor}} | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v4 | ||||
|         uses: actions/checkout@v5 | ||||
|       # If https://github.com/docker/buildx/issues/1044 is resolved, | ||||
|       # the append input with a native arm64 arch could be used to | ||||
|       # significantly speed up building | ||||
| @@ -433,10 +470,10 @@ jobs: | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v4 | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         uses: actions/setup-python@v6 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
| @@ -453,12 +490,12 @@ jobs: | ||||
|           sudo apt-get update -qq | ||||
|           sudo apt-get install -qq --no-install-recommends gettext liblept5 | ||||
|       - name: Download frontend artifact | ||||
|         uses: actions/download-artifact@v4 | ||||
|         uses: actions/download-artifact@v5 | ||||
|         with: | ||||
|           name: frontend-compiled | ||||
|           path: src/documents/static/frontend/ | ||||
|       - name: Download documentation artifact | ||||
|         uses: actions/download-artifact@v4 | ||||
|         uses: actions/download-artifact@v5 | ||||
|         with: | ||||
|           name: documentation | ||||
|           path: docs/_build/html/ | ||||
| @@ -538,7 +575,7 @@ jobs: | ||||
|     if: github.ref_type == 'tag' && (startsWith(github.ref_name, 'v') || contains(github.ref_name, '-beta.rc')) | ||||
|     steps: | ||||
|       - name: Download release artifact | ||||
|         uses: actions/download-artifact@v4 | ||||
|         uses: actions/download-artifact@v5 | ||||
|         with: | ||||
|           name: release | ||||
|           path: ./ | ||||
| @@ -579,12 +616,12 @@ jobs: | ||||
|     if: needs.publish-release.outputs.prerelease == 'false' | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v4 | ||||
|         uses: actions/checkout@v5 | ||||
|         with: | ||||
|           ref: main | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         uses: actions/setup-python@v6 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
| @@ -616,7 +653,7 @@ jobs: | ||||
|           git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA" | ||||
|           git push origin ${{ needs.publish-release.outputs.version }}-changelog | ||||
|       - name: Create Pull Request | ||||
|         uses: actions/github-script@v7 | ||||
|         uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             const { repo, owner } = context.repo; | ||||
|   | ||||
							
								
								
									
										11
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
								
							| @@ -6,10 +6,9 @@ | ||||
| # This workflow will not trigger runs on forked repos. | ||||
| name: Cleanup Image Tags | ||||
| on: | ||||
|   delete: | ||||
|   push: | ||||
|     paths: | ||||
|       - ".github/workflows/cleanup-tags.yml" | ||||
|   workflow_dispatch: | ||||
|   schedule: | ||||
|     - cron: '0 0 * * 0' | ||||
| concurrency: | ||||
|   group: registry-tags-cleanup | ||||
|   cancel-in-progress: false | ||||
| @@ -28,7 +27,7 @@ jobs: | ||||
|     steps: | ||||
|       - name: Clean temporary images | ||||
|         if: "${{ env.TOKEN != '' }}" | ||||
|         uses: stumpylog/image-cleaner-action/ephemeral@v0.10.0 | ||||
|         uses: stumpylog/image-cleaner-action/ephemeral@v0.11.0 | ||||
|         with: | ||||
|           token: "${{ env.TOKEN }}" | ||||
|           owner: "${{ github.repository_owner }}" | ||||
| @@ -54,7 +53,7 @@ jobs: | ||||
|     steps: | ||||
|       - name: Clean untagged images | ||||
|         if: "${{ env.TOKEN != '' }}" | ||||
|         uses: stumpylog/image-cleaner-action/untagged@v0.10.0 | ||||
|         uses: stumpylog/image-cleaner-action/untagged@v0.11.0 | ||||
|         with: | ||||
|           token: "${{ env.TOKEN }}" | ||||
|           owner: "${{ github.repository_owner }}" | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							| @@ -34,7 +34,7 @@ jobs: | ||||
|         # Learn more about CodeQL language support at https://git.io/codeql-language-support | ||||
|     steps: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v4 | ||||
|         uses: actions/checkout@v5 | ||||
|       # Initializes the CodeQL tools for scanning. | ||||
|       - name: Initialize CodeQL | ||||
|         uses: github/codeql-action/init@v3 | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/crowdin.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/crowdin.yml
									
									
									
									
										vendored
									
									
								
							| @@ -13,7 +13,7 @@ jobs: | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v4 | ||||
|         uses: actions/checkout@v5 | ||||
|         with: | ||||
|           token: ${{ secrets.PNGX_BOT_PAT }} | ||||
|       - name: crowdin action | ||||
|   | ||||
							
								
								
									
										10
									
								
								.github/workflows/pr-bot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/pr-bot.yml
									
									
									
									
										vendored
									
									
								
							| @@ -12,7 +12,7 @@ jobs: | ||||
|     steps: | ||||
|       - name: Label PR by file path or branch name | ||||
|         # see .github/labeler.yml for the labeler config | ||||
|         uses: actions/labeler@v5 | ||||
|         uses: actions/labeler@v6 | ||||
|         with: | ||||
|           repo-token: ${{ secrets.GITHUB_TOKEN }} | ||||
|       - name: Label by size | ||||
| @@ -26,7 +26,7 @@ jobs: | ||||
|           fail_if_xl: 'false' | ||||
|           excluded_files: /\.lock$/ /\.txt$/ ^src-ui/pnpm-lock\.yaml$ ^src-ui/messages\.xlf$ ^src/locale/en_US/LC_MESSAGES/django\.po$ | ||||
|       - name: Label by PR title | ||||
|         uses: actions/github-script@v7 | ||||
|         uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             const pr = context.payload.pull_request; | ||||
| @@ -37,7 +37,7 @@ jobs: | ||||
|               labels.push('bug'); | ||||
|             } else if (/^feature/i.test(title)) { | ||||
|               labels.push('enhancement'); | ||||
|             } else if (!/^(dependabot)/i.test(title)) { | ||||
|             } else if (!/^(dependabot)/i.test(title) && !/^(chore)/i.test(title)) { | ||||
|               labels.push('enhancement'); // Default fallback | ||||
|             } | ||||
|  | ||||
| @@ -52,7 +52,7 @@ jobs: | ||||
|             } | ||||
|       - name: Label bot-generated PRs | ||||
|         if: ${{ contains(github.actor, 'dependabot') || contains(github.actor, 'crowdin-bot') }} | ||||
|         uses: actions/github-script@v7 | ||||
|         uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             const pr = context.payload.pull_request; | ||||
| @@ -77,7 +77,7 @@ jobs: | ||||
|             } | ||||
|       - name: Welcome comment | ||||
|         if: ${{ !contains(github.actor, 'bot') }} | ||||
|         uses: actions/github-script@v7 | ||||
|         uses: actions/github-script@v8 | ||||
|         with: | ||||
|           script: | | ||||
|             const pr = context.payload.pull_request; | ||||
|   | ||||
							
								
								
									
										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/)|(^src/documents/tests/samples/)" | ||||
|         additional_dependencies: [tomli] | ||||
|         exclude_types: | ||||
|           - pofile | ||||
|           - json | ||||
| @@ -49,9 +49,9 @@ 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.13.2 | ||||
|     hooks: | ||||
|       - id: ruff | ||||
|       - id: ruff-check | ||||
|       - id: ruff-format | ||||
|   - repo: https://github.com/tox-dev/pyproject-fmt | ||||
|     rev: "v2.6.0" | ||||
| @@ -59,7 +59,7 @@ repos: | ||||
|       - 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,7 +72,7 @@ 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 | ||||
|   | ||||
| @@ -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. | ||||
|   | ||||
							
								
								
									
										20
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -5,7 +5,7 @@ | ||||
| # Purpose: Compiles the frontend | ||||
| # Notes: | ||||
| #  - Does PNPM stuff with Typescript and such | ||||
| FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-slim AS compile-frontend | ||||
| FROM --platform=$BUILDPLATFORM docker.io/node:20-trixie-slim AS compile-frontend | ||||
|  | ||||
| COPY ./src-ui /src/src-ui | ||||
|  | ||||
| @@ -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.8.4-python3.12-bookworm-slim AS s6-overlay-base | ||||
| FROM ghcr.io/astral-sh/uv:0.8.22-python3.12-bookworm-slim AS s6-overlay-base | ||||
|  | ||||
| WORKDIR /usr/src/s6 | ||||
|  | ||||
| @@ -170,20 +170,8 @@ RUN set -eux \ | ||||
|     && apt-get update \ | ||||
|     && apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES} \ | ||||
|     && echo "Installing pre-built updates" \ | ||||
|       && curl --fail --silent --no-progress-meter --show-error --location --remote-name-all --parallel --parallel-max 4 \ | ||||
|         https://github.com/paperless-ngx/builder/releases/download/qpdf-${QPDF_VERSION}/libqpdf29_${QPDF_VERSION}-1_${TARGETARCH}.deb \ | ||||
|         https://github.com/paperless-ngx/builder/releases/download/qpdf-${QPDF_VERSION}/qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \ | ||||
|         https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \ | ||||
|         https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \ | ||||
|         https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg-1_all.deb \ | ||||
|         https://github.com/paperless-ngx/builder/releases/download/jbig2enc-${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ | ||||
|       && echo "Installing qpdf ${QPDF_VERSION}" \ | ||||
|         && dpkg --install ./libqpdf29_${QPDF_VERSION}-1_${TARGETARCH}.deb \ | ||||
|         && dpkg --install ./qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \ | ||||
|       && echo "Installing Ghostscript ${GS_VERSION}" \ | ||||
|         && dpkg --install ./libgs10-common_${GS_VERSION}.dfsg-1_all.deb \ | ||||
|         && dpkg --install ./libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \ | ||||
|         && dpkg --install ./ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \ | ||||
|       && curl --fail --silent --no-progress-meter --show-error --location --remote-name-all \ | ||||
|         https://github.com/paperless-ngx/builder/releases/download/jbig2enc-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ | ||||
|       && echo "Installing jbig2enc" \ | ||||
|         && dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ | ||||
|       && echo "Configuring imagemagick" \ | ||||
|   | ||||
							
								
								
									
										319
									
								
								dev.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										319
									
								
								dev.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,319 @@ | ||||
| adduser 3.134 | ||||
| apt 2.6.1 | ||||
| base-files 12.4+deb12u11 | ||||
| base-passwd 3.6.1 | ||||
| bash 5.2.15-2+b8 | ||||
| bsdutils 1:2.38.1-5+deb12u3 | ||||
| ca-certificates 20230311+deb12u1 | ||||
| coreutils 9.1-1 | ||||
| curl 7.88.1-10+deb12u12 | ||||
| dash 0.5.12-2 | ||||
| debconf 1.5.82 | ||||
| debian-archive-keyring 2023.3+deb12u2 | ||||
| debianutils 5.7-0.5~deb12u1 | ||||
| diffutils 1:3.8-4 | ||||
| dirmngr 2.2.40-1.1 | ||||
| dpkg 1.21.22 | ||||
| e2fsprogs 1.47.0-2 | ||||
| file 1:5.44-3 | ||||
| findutils 4.9.0-4 | ||||
| fontconfig 2.14.1-4 | ||||
| fontconfig-config 2.14.1-4 | ||||
| fonts-liberation 1:1.07.4-11 | ||||
| fonts-urw-base35 20200910-7 | ||||
| gcc-12-base 12.2.0-14+deb12u1 | ||||
| gettext 0.21-12 | ||||
| gettext-base 0.21-12 | ||||
| ghostscript 10.03.1~dfsg-1 | ||||
| gnupg 2.2.40-1.1 | ||||
| gnupg-l10n 2.2.40-1.1 | ||||
| gnupg-utils 2.2.40-1.1 | ||||
| gosu 1.14-1+b10 | ||||
| gpg 2.2.40-1.1 | ||||
| gpg-agent 2.2.40-1.1 | ||||
| gpg-wks-client 2.2.40-1.1 | ||||
| gpg-wks-server 2.2.40-1.1 | ||||
| gpgconf 2.2.40-1.1 | ||||
| gpgsm 2.2.40-1.1 | ||||
| gpgv 2.2.40-1.1 | ||||
| grep 3.8-5 | ||||
| gzip 1.12-1 | ||||
| hicolor-icon-theme 0.17-2 | ||||
| hostname 3.23+nmu1 | ||||
| icc-profiles-free 2.0.1+dfsg-1.1 | ||||
| imagemagick 8:6.9.11.60+dfsg-1.6+deb12u3 | ||||
| imagemagick-6-common 8:6.9.11.60+dfsg-1.6+deb12u3 | ||||
| imagemagick-6.q16 8:6.9.11.60+dfsg-1.6+deb12u3 | ||||
| init-system-helpers 1.65.2 | ||||
| jbig2dec 0.19-3 | ||||
| jbig2enc 0.30-1 | ||||
| libacl1 2.3.1-3 | ||||
| libaom3 3.6.0-1+deb12u1 | ||||
| libapt-pkg6.0 2.6.1 | ||||
| libarchive13 3.6.2-1+deb12u2 | ||||
| libassuan0 2.5.5-5 | ||||
| libattr1 1:2.5.1-4 | ||||
| libaudit-common 1:3.0.9-1 | ||||
| libaudit1 1:3.0.9-1 | ||||
| libavahi-client3 0.8-10+deb12u1 | ||||
| libavahi-common-data 0.8-10+deb12u1 | ||||
| libavahi-common3 0.8-10+deb12u1 | ||||
| libavcodec59 7:5.1.6-0+deb12u1 | ||||
| libavformat59 7:5.1.6-0+deb12u1 | ||||
| libavutil57 7:5.1.6-0+deb12u1 | ||||
| libblkid1 2.38.1-5+deb12u3 | ||||
| libbluray2 1:1.3.4-1 | ||||
| libbrotli1 1.0.9-2+b6 | ||||
| libbsd0 0.11.7-2 | ||||
| libbz2-1.0 1.0.8-5+b1 | ||||
| libc-bin 2.36-9+deb12u10 | ||||
| libc6 2.36-9+deb12u10 | ||||
| libcairo-gobject2 1.16.0-7 | ||||
| libcairo2 1.16.0-7 | ||||
| libcap-ng0 0.8.3-1+b3 | ||||
| libcap2 1:2.66-4+deb12u1 | ||||
| libchromaprint1 1.5.1-2+b1 | ||||
| libcjson1 1.7.15-1+deb12u2 | ||||
| libcodec2-1.0 1.0.5-1 | ||||
| libcom-err2 1.47.0-2 | ||||
| libconfig-inifiles-perl 3.000003-2 | ||||
| libcrypt1 1:4.4.33-2 | ||||
| libcups2 2.4.2-3+deb12u8 | ||||
| libcurl4 7.88.1-10+deb12u12 | ||||
| libdatrie1 0.2.13-2+b1 | ||||
| libdav1d6 1.0.0-2+deb12u1 | ||||
| libdb5.3 5.3.28+dfsg2-1 | ||||
| libdbus-1-3 1.14.10-1~deb12u1 | ||||
| libde265-0 1.0.11-1+deb12u2 | ||||
| libdebconfclient0 0.270 | ||||
| libdeflate0 1.14-1 | ||||
| libdrm-common 2.4.114-1 | ||||
| libdrm2 2.4.114-1+b1 | ||||
| libedit2 3.1-20221030-2 | ||||
| libexpat1 2.5.0-1+deb12u1 | ||||
| libext2fs2 1.47.0-2 | ||||
| libffi8 3.4.4-1 | ||||
| libfftw3-double3 3.3.10-1 | ||||
| libfontconfig1 2.14.1-4 | ||||
| libfontenc1 1:1.1.4-1 | ||||
| libfreetype6 2.12.1+dfsg-5+deb12u4 | ||||
| libfribidi0 1.0.8-2.1 | ||||
| libgcc-s1 12.2.0-14+deb12u1 | ||||
| libgcrypt20 1.10.1-3 | ||||
| libgdbm-compat4 1.23-3 | ||||
| libgdbm6 1.23-3 | ||||
| libgdk-pixbuf-2.0-0 2.42.10+dfsg-1+deb12u2 | ||||
| libgdk-pixbuf2.0-common 2.42.10+dfsg-1+deb12u2 | ||||
| libgif7 5.2.1-2.5 | ||||
| libglib2.0-0 2.74.6-2+deb12u6 | ||||
| libgme0 0.6.3-6 | ||||
| libgmp10 2:6.2.1+dfsg1-1.1 | ||||
| libgnutls30 3.7.9-2+deb12u5 | ||||
| libgomp1 12.2.0-14+deb12u1 | ||||
| libgpg-error0 1.46-1 | ||||
| libgraphite2-3 1.3.14-1 | ||||
| libgs-common 10.0.0~dfsg-11+deb12u7 | ||||
| libgs10 10.03.1~dfsg-1 | ||||
| libgs10-common 10.03.1~dfsg-1 | ||||
| libgsm1 1.0.22-1 | ||||
| libgssapi-krb5-2 1.20.1-2+deb12u3 | ||||
| libharfbuzz0b 6.0.0+dfsg-3 | ||||
| libheif1 1.15.1-1+deb12u1 | ||||
| libhogweed6 3.8.1-2 | ||||
| libhwy1 1.0.3-3+deb12u1 | ||||
| libice6 2:1.0.10-1 | ||||
| libicu72 72.1-3+deb12u1 | ||||
| libidn12 1.41-1 | ||||
| libidn2-0 2.3.3-1+b1 | ||||
| libijs-0.35 0.35-15 | ||||
| libimagequant0 2.17.0-1 | ||||
| libjbig0 2.1-6.1 | ||||
| libjbig2dec0 0.19-3 | ||||
| libjpeg62-turbo 1:2.1.5-2 | ||||
| libjxl0.7 0.7.0-10+deb12u1 | ||||
| libk5crypto3 1.20.1-2+deb12u3 | ||||
| libkeyutils1 1.6.3-2 | ||||
| libkrb5-3 1.20.1-2+deb12u3 | ||||
| libkrb5support0 1.20.1-2+deb12u3 | ||||
| libksba8 1.6.3-2 | ||||
| liblcms2-2 2.14-2 | ||||
| libldap-2.5-0 2.5.13+dfsg-5 | ||||
| liblept5 1.82.0-3+b3 | ||||
| liblerc4 4.0.0+ds-2 | ||||
| liblqr-1-0 0.4.2-2.1 | ||||
| libltdl7 2.4.7-7~deb12u1 | ||||
| liblz4-1 1.9.4-1 | ||||
| liblzma5 5.4.1-1 | ||||
| libmagic-mgc 1:5.44-3 | ||||
| libmagic1 1:5.44-3 | ||||
| libmagickcore-6.q16-6 8:6.9.11.60+dfsg-1.6+deb12u3 | ||||
| libmagickwand-6.q16-6 8:6.9.11.60+dfsg-1.6+deb12u3 | ||||
| libmariadb3 1:10.11.11-0+deb12u1 | ||||
| libmbedcrypto7 2.28.3-1 | ||||
| libmd0 1.0.4-2 | ||||
| libmfx1 22.5.4-1 | ||||
| libmount1 2.38.1-5+deb12u3 | ||||
| libmp3lame0 3.100-6 | ||||
| libmpg123-0 1.31.2-1+deb12u1 | ||||
| libncurses6 6.4-4 | ||||
| libncursesw6 6.4-4 | ||||
| libnettle8 3.8.1-2 | ||||
| libnghttp2-14 1.52.0-1+deb12u2 | ||||
| libnorm1 1.5.9+dfsg-2 | ||||
| libnpth0 1.6-3 | ||||
| libnsl2 1.3.0-2 | ||||
| libnspr4 2:4.35-1 | ||||
| libnss3 2:3.87.1-1+deb12u1 | ||||
| libnuma1 2.0.16-1 | ||||
| libogg0 1.3.5-3 | ||||
| libopenjp2-7 2.5.0-2+deb12u1 | ||||
| libopenmpt0 0.6.9-1 | ||||
| libopus0 1.3.1-3 | ||||
| libp11-kit0 0.24.1-2 | ||||
| libpam-modules 1.5.2-6+deb12u1 | ||||
| libpam-modules-bin 1.5.2-6+deb12u1 | ||||
| libpam-runtime 1.5.2-6+deb12u1 | ||||
| libpam0g 1.5.2-6+deb12u1 | ||||
| libpango-1.0-0 1.50.12+ds-1 | ||||
| libpangocairo-1.0-0 1.50.12+ds-1 | ||||
| libpangoft2-1.0-0 1.50.12+ds-1 | ||||
| libpaper1 1.1.29 | ||||
| libpcre2-8-0 10.42-1 | ||||
| libperl5.36 5.36.0-7+deb12u2 | ||||
| libpgm-5.3-0 5.3.128~dfsg-2 | ||||
| libpixman-1-0 0.42.2-1 | ||||
| libpng16-16 1.6.39-2 | ||||
| libpoppler126 22.12.0-2+deb12u1 | ||||
| libpq5 15.13-0+deb12u1 | ||||
| libpsl5 0.21.2-1 | ||||
| libqpdf29 11.9.0-1 | ||||
| librabbitmq4 0.11.0-1+deb12u1 | ||||
| librav1e0 0.5.1-6 | ||||
| libreadline8 8.2-1.3 | ||||
| librist4 0.2.7+dfsg-1 | ||||
| librsvg2-2 2.54.7+dfsg-1~deb12u1 | ||||
| librtmp1 2.4+20151223.gitfa8646d.1-2+b2 | ||||
| libsasl2-2 2.1.28+dfsg-10 | ||||
| libsasl2-modules-db 2.1.28+dfsg-10 | ||||
| libseccomp2 2.5.4-1+deb12u1 | ||||
| libselinux1 3.4-1+b6 | ||||
| libsemanage-common 3.4-1 | ||||
| libsemanage2 3.4-1+b5 | ||||
| libsepol2 3.4-2.1 | ||||
| libshine3 3.1.1-2 | ||||
| libsm6 2:1.2.3-1 | ||||
| libsmartcols1 2.38.1-5+deb12u3 | ||||
| libsnappy1v5 1.1.9-3 | ||||
| libsodium23 1.0.18-1 | ||||
| libsoxr0 0.1.3-4 | ||||
| libspeex1 1.2.1-2 | ||||
| libsqlite3-0 3.40.1-2+deb12u1 | ||||
| libsrt1.5-gnutls 1.5.1-1+deb12u1 | ||||
| libss2 1.47.0-2 | ||||
| libssh-gcrypt-4 0.10.6-0+deb12u1 | ||||
| libssh2-1 1.10.0-3+b1 | ||||
| libssl3 3.0.17-1~deb12u1 | ||||
| libstdc++6 12.2.0-14+deb12u1 | ||||
| libsvtav1enc1 1.4.1+dfsg-1 | ||||
| libswresample4 7:5.1.6-0+deb12u1 | ||||
| libsystemd0 252.38-1~deb12u1 | ||||
| libtasn1-6 4.19.0-2+deb12u1 | ||||
| libtesseract5 5.3.0-2 | ||||
| libthai-data 0.1.29-1 | ||||
| libthai0 0.1.29-1 | ||||
| libtheora0 1.1.1+dfsg.1-16.1+b1 | ||||
| libtiff6 4.5.0-6+deb12u2 | ||||
| libtinfo6 6.4-4 | ||||
| libtirpc-common 1.3.3+ds-1 | ||||
| libtirpc3 1.3.3+ds-1 | ||||
| libtwolame0 0.4.0-2 | ||||
| libudev1 252.38-1~deb12u1 | ||||
| libudfread0 1.1.2-1 | ||||
| libunistring2 1.0-2 | ||||
| libuuid1 2.38.1-5+deb12u3 | ||||
| libv4l-0 1.22.1-5+b2 | ||||
| libv4lconvert0 1.22.1-5+b2 | ||||
| libva-drm2 2.17.0-1 | ||||
| libva-x11-2 2.17.0-1 | ||||
| libva2 2.17.0-1 | ||||
| libvdpau1 1.5-2 | ||||
| libvorbis0a 1.3.7-1 | ||||
| libvorbisenc2 1.3.7-1 | ||||
| libvorbisfile3 1.3.7-1 | ||||
| libvpx7 1.12.0-1+deb12u4 | ||||
| libwebp7 1.2.4-0.2+deb12u1 | ||||
| libwebpdemux2 1.2.4-0.2+deb12u1 | ||||
| libwebpmux3 1.2.4-0.2+deb12u1 | ||||
| libx11-6 2:1.8.4-2+deb12u2 | ||||
| libx11-data 2:1.8.4-2+deb12u2 | ||||
| libx11-xcb1 2:1.8.4-2+deb12u2 | ||||
| libx264-164 2:0.164.3095+gitbaee400-3 | ||||
| libx265-199 3.5-2+b1 | ||||
| libxau6 1:1.0.9-1 | ||||
| libxcb-dri3-0 1.15-1 | ||||
| libxcb-render0 1.15-1 | ||||
| libxcb-shm0 1.15-1 | ||||
| libxcb1 1.15-1 | ||||
| libxdmcp6 1:1.1.2-3 | ||||
| libxext6 2:1.3.4-1+b1 | ||||
| libxfixes3 1:6.0.0-2 | ||||
| libxml2 2.9.14+dfsg-1.3~deb12u2 | ||||
| libxrender1 1:0.9.10-1.1 | ||||
| libxslt1.1 1.1.35-1+deb12u1 | ||||
| libxt6 1:1.2.1-1.1 | ||||
| libxvidcore4 2:1.3.7-1 | ||||
| libxxhash0 0.8.1-1 | ||||
| libzbar0 0.23.92-7+deb12u1 | ||||
| libzmq5 4.3.4-6 | ||||
| libzstd1 1.5.4+dfsg2-5 | ||||
| libzvbi-common 0.2.41-1 | ||||
| libzvbi0 0.2.41-1 | ||||
| login 1:4.13+dfsg1-1+deb12u1 | ||||
| logsave 1.47.0-2 | ||||
| mariadb-client 1:10.11.11-0+deb12u1 | ||||
| mariadb-client-core 1:10.11.11-0+deb12u1 | ||||
| mariadb-common 1:10.11.11-0+deb12u1 | ||||
| mawk 1.3.4.20200120-3.1 | ||||
| media-types 10.0.0 | ||||
| mount 2.38.1-5+deb12u3 | ||||
| mysql-common 5.8+1.1.0 | ||||
| ncurses-base 6.4-4 | ||||
| ncurses-bin 6.4-4 | ||||
| netbase 6.4 | ||||
| ocl-icd-libopencl1 2.3.1-1 | ||||
| openssl 3.0.17-1~deb12u1 | ||||
| passwd 1:4.13+dfsg1-1+deb12u1 | ||||
| perl 5.36.0-7+deb12u2 | ||||
| perl-base 5.36.0-7+deb12u2 | ||||
| perl-modules-5.36 5.36.0-7+deb12u2 | ||||
| pinentry-curses 1.2.1-1 | ||||
| pngquant 2.17.0-1 | ||||
| poppler-data 0.4.12-1 | ||||
| poppler-utils 22.12.0-2+deb12u1 | ||||
| postgresql-client 15+248 | ||||
| postgresql-client-15 15.13-0+deb12u1 | ||||
| postgresql-client-common 248 | ||||
| qpdf 11.9.0-1 | ||||
| readline-common 8.2-1.3 | ||||
| sed 4.9-1 | ||||
| sensible-utils 0.0.17+nmu1 | ||||
| shared-mime-info 2.2-1 | ||||
| sysvinit-utils 3.06-4 | ||||
| tar 1.34+dfsg-1.2+deb12u1 | ||||
| tesseract-ocr 5.3.0-2 | ||||
| tesseract-ocr-deu 1:4.1.0-2 | ||||
| tesseract-ocr-eng 1:4.1.0-2 | ||||
| tesseract-ocr-fra 1:4.1.0-2 | ||||
| tesseract-ocr-ita 1:4.1.0-2 | ||||
| tesseract-ocr-osd 1:4.1.0-2 | ||||
| tesseract-ocr-spa 1:4.1.0-2 | ||||
| tzdata 2025b-0+deb12u1 | ||||
| ucf 3.0043+nmu1+deb12u1 | ||||
| unpaper 7.0.0-0.1 | ||||
| usr-is-merged 37~deb12u1 | ||||
| util-linux 2.38.1-5+deb12u3 | ||||
| util-linux-extra 2.38.1-5+deb12u3 | ||||
| x11-common 1:7.7+23 | ||||
| xfonts-encodings 1:1.0.4-2.2 | ||||
| xfonts-utils 1:7.7+6 | ||||
| zlib1g 1:1.2.13.dfsg-1 | ||||
| @@ -4,7 +4,7 @@ | ||||
| # correct networking for the tests | ||||
| services: | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:8.20 | ||||
|     image: docker.io/gotenberg/gotenberg:8.23 | ||||
|     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.23 | ||||
|     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,7 +32,7 @@ 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 | ||||
|   | ||||
| @@ -35,7 +35,7 @@ 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 | ||||
| @@ -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.23 | ||||
|     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/postgres:17 | ||||
|     image: docker.io/library/postgres:18 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - pgdata:/var/lib/postgresql/data | ||||
|   | ||||
| @@ -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.23 | ||||
|     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. | ||||
|   | ||||
| @@ -11,7 +11,6 @@ for command in decrypt_documents \ | ||||
| 	mail_fetcher \ | ||||
| 	document_create_classifier \ | ||||
| 	document_index \ | ||||
| 	document_llmindex \ | ||||
| 	document_renamer \ | ||||
| 	document_retagger \ | ||||
| 	document_thumbnails \ | ||||
|   | ||||
| @@ -1,14 +0,0 @@ | ||||
| #!/command/with-contenv /usr/bin/bash | ||||
| # shellcheck shell=bash | ||||
|  | ||||
| set -e | ||||
|  | ||||
| cd "${PAPERLESS_SRC_DIR}" | ||||
|  | ||||
| if [[ $(id -u) == 0 ]]; then | ||||
| 	s6-setuidgid paperless python3 manage.py document_llmindex "$@" | ||||
| elif [[ $(id -un) == "paperless" ]]; then | ||||
| 	python3 manage.py document_llmindex "$@" | ||||
| else | ||||
| 	echo "Unknown user." | ||||
| fi | ||||
| @@ -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,281 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## 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 +5699,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 +6500,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 | ||||
|  | ||||
|   | ||||
| @@ -170,11 +170,11 @@ Available options are `postgresql` and `mariadb`. | ||||
|  | ||||
|     !!! 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. | ||||
|         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} | ||||
|  | ||||
| @@ -184,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} | ||||
|  | ||||
| @@ -196,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`. | ||||
| @@ -1282,6 +1282,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} | ||||
| @@ -1735,6 +1759,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 | ||||
| @@ -1776,67 +1805,3 @@ password. All of these options come from their similarly-named [Django settings] | ||||
| #### [`PAPERLESS_EMAIL_USE_SSL=<bool>`](#PAPERLESS_EMAIL_USE_SSL) {#PAPERLESS_EMAIL_USE_SSL} | ||||
|  | ||||
| : Defaults to false. | ||||
|  | ||||
| ## AI {#ai} | ||||
|  | ||||
| #### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED} | ||||
|  | ||||
| : Enables the AI features in Paperless. This includes the AI-based | ||||
| suggestions. This setting is required to be set to true in order to use the AI features. | ||||
|  | ||||
|     Defaults to false. | ||||
|  | ||||
| #### [`PAPERLESS_AI_LLM_EMBEDDING_BACKEND=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_BACKEND) {#PAPERLESS_AI_LLM_EMBEDDING_BACKEND} | ||||
|  | ||||
| : The embedding backend to use for RAG. This can be either "openai" or "huggingface". | ||||
|  | ||||
|     Defaults to None. | ||||
|  | ||||
| #### [`PAPERLESS_AI_LLM_EMBEDDING_MODEL=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_MODEL) {#PAPERLESS_AI_LLM_EMBEDDING_MODEL} | ||||
|  | ||||
| : The model to use for the embedding backend for RAG. This can be set to any of the embedding models supported by the current embedding backend. If not supplied, defaults to "text-embedding-3-small" for OpenAI and "sentence-transformers/all-MiniLM-L6-v2" for Huggingface. | ||||
|  | ||||
|     Defaults to None. | ||||
|  | ||||
| #### [`PAPERLESS_AI_BACKEND=<str>`](#PAPERLESS_AI_BACKEND) {#PAPERLESS_AI_BACKEND} | ||||
|  | ||||
| : The AI backend to use. This can be either "openai" or "ollama". If set to "ollama", the AI | ||||
| features will be run locally on your machine. If set to "openai", the AI features will be run | ||||
| using the OpenAI API. This setting is required to be set to use the AI features. | ||||
|  | ||||
|     Defaults to None. | ||||
|  | ||||
|     !!! note | ||||
|  | ||||
|         The OpenAI API is a paid service. You will need to set up an OpenAI account and | ||||
|         will be charged for usage incurred by Paperless-ngx features and your document data | ||||
|         will (of course) be sent to the OpenAI API. Paperless-ngx does not endorse the use of the | ||||
|         OpenAI API in any way. | ||||
|  | ||||
|         Refer to the OpenAI terms of service, and use at your own risk. | ||||
|  | ||||
| #### [`PAPERLESS_AI_LLM_MODEL=<str>`](#PAPERLESS_AI_LLM_MODEL) {#PAPERLESS_AI_LLM_MODEL} | ||||
|  | ||||
| : The model to use for the AI backend, i.e. "gpt-3.5-turbo", "gpt-4" or any of the models supported by the | ||||
| current backend. If not supplied, defaults to "gpt-3.5-turbo" for OpenAI and "llama3" for Ollama. | ||||
|  | ||||
|     Defaults to None. | ||||
|  | ||||
| #### [`PAPERLESS_AI_LLM_API_KEY=<str>`](#PAPERLESS_AI_LLM_API_KEY) {#PAPERLESS_AI_LLM_API_KEY} | ||||
|  | ||||
| : The API key to use for the AI backend. This is required for the OpenAI backend only. | ||||
|  | ||||
|     Defaults to None. | ||||
|  | ||||
| #### [`PAPERLESS_AI_LLM_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_ENDPOINT) {#PAPERLESS_AI_LLM_ENDPOINT} | ||||
|  | ||||
| : The endpoint / url to use for the AI backend. This is required for the Ollama backend only. | ||||
|  | ||||
|     Defaults to None. | ||||
|  | ||||
| #### [`PAPERLESS_AI_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_AI_LLM_INDEX_TASK_CRON) {#PAPERLESS_AI_LLM_INDEX_TASK_CRON} | ||||
|  | ||||
| : Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if | ||||
| AI is enabled and the LLM embedding backend is set. | ||||
|  | ||||
|     Defaults to `10 2 * * *`, once per day. | ||||
|   | ||||
| @@ -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** | ||||
|   | ||||
| @@ -25,12 +25,11 @@ physical documents into a searchable online archive so you can keep, well, _less | ||||
| ## Features | ||||
|  | ||||
| -   **Organize and index** your scanned documents with tags, correspondents, types, and more. | ||||
| -   _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way, unless you explicitly choose to do so. | ||||
| -   _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way. | ||||
| -   Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images. | ||||
| -   Utilizes the open-source Tesseract engine to recognize more than 100 languages. | ||||
| -   Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals. | ||||
| -   Uses machine-learning to automatically add tags, correspondents and document types to your documents. | ||||
| -   **New**: Paperless-ngx can now leverage AI (Large Language Models or LLMs) for document suggestions. This is an optional feature that can be enabled (and is disabled by default). | ||||
| -   Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more. | ||||
| -   Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents. | ||||
| -   **Beautiful, modern web application** that features: | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										134
									
								
								docs/usage.md
									
									
									
									
									
								
							
							
						
						
									
										134
									
								
								docs/usage.md
									
									
									
									
									
								
							| @@ -92,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 | ||||
| @@ -251,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. | ||||
| @@ -264,28 +278,6 @@ Once setup, navigating to the email settings page in Paperless-ngx will allow yo | ||||
| You can also submit a document using the REST API, see [POSTing documents](api.md#file-uploads) | ||||
| for details. | ||||
|  | ||||
| ## Document Suggestions | ||||
|  | ||||
| Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user. | ||||
|  | ||||
| ## AI Features | ||||
|  | ||||
| Paperless-ngx includes several features that use AI to enhance the document management experience. These features are optional and can be enabled or disabled in the settings. If you are using the AI features, you may want to also enable the "LLM index" feature, which supports Retrieval-Augmented Generation (RAG) designed to improve the quality of AI responses. The LLM index feature is not enabled by default and requires additional configuration. | ||||
|  | ||||
| !!! warning | ||||
|  | ||||
|     Remember that Paperless-ngx will send document content to the AI provider you have configured, so consider the privacy implications of using these features, especially if using a remote model (e.g. OpenAI), instead of the default local model. | ||||
|  | ||||
| The AI features work by creating an embedding of the text content and metadata of documents, which is then used for various tasks such as similarity search and question answering. This uses the FAISS vector store. | ||||
|  | ||||
| ### AI-Enhanced Suggestions | ||||
|  | ||||
| If enabled, Paperless-ngx can use an AI LLM model to suggest document titles, dates, tags, correspondents and document types for documents. This feature will always be "opt-in" and does not disable the existing classifier-based suggestion system. Currently, both remote (via the OpenAI API) and local (via Ollama) models are supported, see [configuration](configuration.md#ai) for details. | ||||
|  | ||||
| ### Document Chat | ||||
|  | ||||
| Paperless-ngx can use an AI LLM model to answer questions about a document or across multiple documents. Again, this feature works best when RAG is enabled. The chat feature is available in the upper app toolbar and will switch between chatting across multiple documents or a single document based on the current view. | ||||
|  | ||||
| ## Sharing documents from Paperless-ngx | ||||
|  | ||||
| Paperless-ngx supports sharing documents with other users by assigning them [permissions](#object-permissions) | ||||
| @@ -422,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 | ||||
| @@ -430,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 | ||||
| @@ -474,10 +466,11 @@ Workflows allow you to filter by: | ||||
| -   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. | ||||
| -   Tags (`Added`, `Updated` and `Scheduled` triggers only). Filter for documents with any of the specified tags | ||||
| -   Document type (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this doc type | ||||
| -   Correspondent (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this correspondent | ||||
| -   Storage path (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this storage path | ||||
|  | ||||
| ### Workflow Actions | ||||
|  | ||||
| @@ -521,37 +514,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 | ||||
|  | ||||
| @@ -598,12 +612,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 | ||||
|  | ||||
| @@ -621,7 +637,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} | ||||
|   | ||||
| @@ -47,6 +47,7 @@ markdown_extensions: | ||||
|   - pymdownx.superfences | ||||
|   - pymdownx.inlinehilite | ||||
|   - pymdownx.snippets | ||||
|   - pymdownx.tilde | ||||
|   - footnotes | ||||
|   - pymdownx.superfences: | ||||
|       custom_fences: | ||||
|   | ||||
| @@ -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.18.4" | ||||
| description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" | ||||
| readme = "README.md" | ||||
| requires-python = ">=3.10" | ||||
| classifiers = [ | ||||
| @@ -15,6 +15,7 @@ classifiers = [ | ||||
| # 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,54 +24,45 @@ 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~=5.2.5", | ||||
|   "django-allauth[socialaccount,mfa]~=65.4.0", | ||||
|   "django-auditlog~=3.1.2", | ||||
|   "django-auditlog~=3.2.1", | ||||
|   "django-cachalot~=2.8.0", | ||||
|   "django-celery-results~=2.6.0", | ||||
|   "django-compression-middleware~=0.5.0", | ||||
|   "django-cors-headers~=4.7.0", | ||||
|   "django-cors-headers~=4.9.0", | ||||
|   "django-extensions~=4.1", | ||||
|   "django-filter~=25.1", | ||||
|   "django-guardian~=2.4.0", | ||||
|   "django-multiselectfield~=0.1.13", | ||||
|   "django-guardian~=3.2.0", | ||||
|   "django-multiselectfield~=1.0.1", | ||||
|   "django-soft-delete~=1.0.18", | ||||
|   "djangorestframework~=3.15", | ||||
|   "djangorestframework-guardian~=0.3.0", | ||||
|   "django-treenode>=0.23.2", | ||||
|   "djangorestframework~=3.16", | ||||
|   "djangorestframework-guardian~=0.4.0", | ||||
|   "drf-spectacular~=0.28", | ||||
|   "drf-spectacular-sidecar~=2025.4.1", | ||||
|   "drf-spectacular-sidecar~=2025.9.1", | ||||
|   "drf-writable-nested~=0.7.1", | ||||
|   "faiss-cpu>=1.10", | ||||
|   "filelock~=3.18.0", | ||||
|   "filelock~=3.19.1", | ||||
|   "flower~=2.0.1", | ||||
|   "gotenberg-client~=0.10.0", | ||||
|   "gotenberg-client~=0.11.0", | ||||
|   "httpx-oauth~=0.16", | ||||
|   "imap-tools~=1.11.0", | ||||
|   "inotifyrecursive~=0.3", | ||||
|   "jinja2~=3.1.5", | ||||
|   "langdetect~=1.0.9", | ||||
|   "llama-index-core>=0.12.33.post1", | ||||
|   "llama-index-embeddings-huggingface>=0.5.3", | ||||
|   "llama-index-embeddings-openai>=0.3.1", | ||||
|   "llama-index-llms-ollama>=0.5.4", | ||||
|   "llama-index-llms-openai>=0.3.38", | ||||
|   "llama-index-vector-stores-faiss>=0.3", | ||||
|   "nltk~=3.9.1", | ||||
|   "ocrmypdf~=16.10.0", | ||||
|   "openai>=1.76", | ||||
|   "ocrmypdf~=16.11.0", | ||||
|   "pathvalidate~=3.3.1", | ||||
|   "pdf2image~=1.17.0", | ||||
|   "psycopg-pool", | ||||
|   "python-dateutil~=2.9.0", | ||||
|   "python-dotenv~=1.1.0", | ||||
|   "python-gnupg~=0.5.4", | ||||
|   "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", | ||||
|   "sentence-transformers>=4.1", | ||||
|   "setproctitle~=1.3.4", | ||||
|   "tika-client~=0.10.0", | ||||
|   "tqdm~=4.67.1", | ||||
| @@ -90,7 +82,7 @@ optional-dependencies.postgres = [ | ||||
|   "psycopg-pool==3.2.6", | ||||
| ] | ||||
| optional-dependencies.webserver = [ | ||||
|   "granian[uvloop]~=2.4.1", | ||||
|   "granian[uvloop]~=2.5.1", | ||||
| ] | ||||
|  | ||||
| [dependency-groups] | ||||
| @@ -102,7 +94,7 @@ dev = [ | ||||
| ] | ||||
|  | ||||
| docs = [ | ||||
|   "mkdocs-glightbox~=0.4.0", | ||||
|   "mkdocs-glightbox~=0.5.1", | ||||
|   "mkdocs-material~=9.6.4", | ||||
| ] | ||||
|  | ||||
| @@ -111,8 +103,8 @@ testing = [ | ||||
|   "factory-boy~=3.3.1", | ||||
|   "imagehash", | ||||
|   "pytest~=8.4.1", | ||||
|   "pytest-cov~=6.2.1", | ||||
|   "pytest-django~=4.10.0", | ||||
|   "pytest-cov~=7.0.0", | ||||
|   "pytest-django~=4.11.1", | ||||
|   "pytest-env", | ||||
|   "pytest-httpx", | ||||
|   "pytest-mock", | ||||
| @@ -122,9 +114,9 @@ testing = [ | ||||
| ] | ||||
|  | ||||
| lint = [ | ||||
|   "pre-commit~=4.2.0", | ||||
|   "pre-commit~=4.3.0", | ||||
|   "pre-commit-uv~=4.1.3", | ||||
|   "ruff~=0.12.2", | ||||
|   "ruff~=0.13.0", | ||||
| ] | ||||
|  | ||||
| typing = [ | ||||
| @@ -132,6 +124,7 @@ typing = [ | ||||
|   "django-filter-stubs", | ||||
|   "django-stubs[compatible-mypy]", | ||||
|   "djangorestframework-stubs[compatible-mypy]", | ||||
|   "lxml-stubs", | ||||
|   "mypy", | ||||
|   "types-bleach", | ||||
|   "types-colorama", | ||||
| @@ -139,6 +132,7 @@ typing = [ | ||||
|   "types-markdown", | ||||
|   "types-pygments", | ||||
|   "types-python-dateutil", | ||||
|   "types-pytz", | ||||
|   "types-redis", | ||||
|   "types-setuptools", | ||||
|   "types-tqdm", | ||||
| @@ -213,23 +207,19 @@ lint.per-file-ignores."docker/wait-for-redis.py" = [ | ||||
|   "INP001", | ||||
|   "T201", | ||||
| ] | ||||
| lint.per-file-ignores."src/documents/management/commands/document_consumer.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/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 = [ | ||||
| @@ -242,7 +232,6 @@ testpaths = [ | ||||
|   "src/paperless_tesseract/tests/", | ||||
|   "src/paperless_tika/tests", | ||||
|   "src/paperless_text/tests/", | ||||
|   "src/paperless_ai/tests", | ||||
| ] | ||||
| addopts = [ | ||||
|   "--pythonwarnings=all", | ||||
| @@ -283,10 +272,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 | ||||
|   | ||||
| @@ -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() | ||||
|   | ||||
							
								
								
									
										1490
									
								
								src-ui/messages.xlf
									
									
									
									
									
								
							
							
						
						
									
										1490
									
								
								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.18.4", | ||||
|   "scripts": { | ||||
|     "preinstall": "npx only-allow pnpm", | ||||
|     "ng": "ng", | ||||
| @@ -11,65 +11,66 @@ | ||||
|   }, | ||||
|   "private": true, | ||||
|   "dependencies": { | ||||
|     "@angular/cdk": "^20.1.4", | ||||
|     "@angular/common": "~20.1.4", | ||||
|     "@angular/compiler": "~20.1.4", | ||||
|     "@angular/core": "~20.1.4", | ||||
|     "@angular/forms": "~20.1.4", | ||||
|     "@angular/localize": "~20.1.4", | ||||
|     "@angular/platform-browser": "~20.1.4", | ||||
|     "@angular/platform-browser-dynamic": "~20.1.4", | ||||
|     "@angular/router": "~20.1.4", | ||||
|     "@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": "^20.0.1", | ||||
|     "@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-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.1.4", | ||||
|     "@angular-devkit/schematics": "^20.1.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.1.4", | ||||
|     "@angular/cli": "~20.1.4", | ||||
|     "@angular/compiler-cli": "~20.1.4", | ||||
|     "@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.54.2", | ||||
|     "@playwright/test": "^1.55.1", | ||||
|     "@types/jest": "^30.0.0", | ||||
|     "@types/node": "^24.1.0", | ||||
|     "@typescript-eslint/eslint-plugin": "^8.38.0", | ||||
|     "@typescript-eslint/parser": "^8.38.0", | ||||
|     "@typescript-eslint/utils": "^8.38.0", | ||||
|     "eslint": "^9.32.0", | ||||
|     "jest": "30.0.5", | ||||
|     "jest-environment-jsdom": "^30.0.5", | ||||
|     "@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": "^15.0.0", | ||||
|     "jest-preset-angular": "^15.0.2", | ||||
|     "jest-websocket-mock": "^2.5.0", | ||||
|     "prettier-plugin-organize-imports": "^4.2.0", | ||||
|     "prettier-plugin-organize-imports": "^4.3.0", | ||||
|     "ts-node": "~10.9.1", | ||||
|     "typescript": "^5.8.3", | ||||
|     "webpack": "^5.101.0" | ||||
|     "webpack": "^5.102.0" | ||||
|   }, | ||||
|   "packageManager": "pnpm@10.17.1", | ||||
|   "pnpm": { | ||||
|     "onlyBuiltDependencies": [ | ||||
|       "@parcel/watcher", | ||||
|   | ||||
							
								
								
									
										4583
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4583
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -121,8 +121,38 @@ if (!URL.revokeObjectURL) { | ||||
| } | ||||
| Object.defineProperty(window, 'ResizeObserver', { value: mock() }) | ||||
|  | ||||
| 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') | ||||
|   | ||||
| @@ -35,12 +35,8 @@ | ||||
|                                                     @case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> } | ||||
|                                                     @case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> } | ||||
|                                                     @case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> } | ||||
|                                                     @case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> } | ||||
|                                                 } | ||||
|                                             </div> | ||||
|                                             @if (option.note) { | ||||
|                                                 <div class="form-text fst-italic">{{option.note}}</div> | ||||
|                                             } | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|   | ||||
| @@ -29,7 +29,6 @@ import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { FileComponent } from '../../common/input/file/file.component' | ||||
| import { NumberComponent } from '../../common/input/number/number.component' | ||||
| import { PasswordComponent } from '../../common/input/password/password.component' | ||||
| import { SelectComponent } from '../../common/input/select/select.component' | ||||
| import { SwitchComponent } from '../../common/input/switch/switch.component' | ||||
| import { TextComponent } from '../../common/input/text/text.component' | ||||
| @@ -47,7 +46,6 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading | ||||
|     TextComponent, | ||||
|     NumberComponent, | ||||
|     FileComponent, | ||||
|     PasswordComponent, | ||||
|     AsyncPipe, | ||||
|     NgbNavModule, | ||||
|     FormsModule, | ||||
|   | ||||
| @@ -61,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> | ||||
| @@ -290,43 +324,6 @@ describe('SettingsComponent', () => { | ||||
|   }) | ||||
|  | ||||
|   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.', | ||||
|         llmindex_status: SystemStatusItemStatus.DISABLED, | ||||
|         llmindex_last_modified: new Date().toISOString(), | ||||
|         llmindex_error: null, | ||||
|       }, | ||||
|     } | ||||
|     jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status)) | ||||
|     jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true) | ||||
|     completeSetup() | ||||
| @@ -343,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, { | ||||
|   | ||||
| @@ -185,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 | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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() | ||||
|     } | ||||
|   } | ||||
|   | ||||
| @@ -30,9 +30,6 @@ | ||||
|     </div> | ||||
|   </div> | ||||
|   <ul ngbNav class="order-sm-3"> | ||||
|     @if (aiEnabled) { | ||||
|       <pngx-chat></pngx-chat> | ||||
|     } | ||||
|     <pngx-toasts-dropdown></pngx-toasts-dropdown> | ||||
|     <li ngbDropdown class="nav-item dropdown"> | ||||
|       <button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle> | ||||
| @@ -111,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> | ||||
|                     } | ||||
| @@ -149,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 { | ||||
|   | ||||
| @@ -44,7 +44,6 @@ import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { TasksService } from 'src/app/services/tasks.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { environment } from 'src/environments/environment' | ||||
| import { ChatComponent } from '../chat/chat/chat.component' | ||||
| import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' | ||||
| import { DocumentDetailComponent } from '../document-detail/document-detail.component' | ||||
| import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' | ||||
| @@ -60,7 +59,6 @@ import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.compo | ||||
|     DocumentTitlePipe, | ||||
|     IfPermissionsDirective, | ||||
|     ToastsDropdownComponent, | ||||
|     ChatComponent, | ||||
|     RouterModule, | ||||
|     NgClass, | ||||
|     NgbDropdownModule, | ||||
| @@ -147,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 { | ||||
| @@ -173,10 +171,6 @@ export class AppFrameComponent | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   get aiEnabled(): boolean { | ||||
|     return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) | ||||
|   } | ||||
|  | ||||
|   closeMenu() { | ||||
|     this.isMenuCollapsed = true | ||||
|   } | ||||
| @@ -293,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,5 +1,5 @@ | ||||
|  | ||||
| <li ngbDropdown class="nav-item mx-1" (openChange)="onOpenChange($event)"> | ||||
| <li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)"> | ||||
|   @if (toasts.length) { | ||||
|     <span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts.length }}</span> | ||||
|   } | ||||
|   | ||||
| @@ -1,35 +0,0 @@ | ||||
|  | ||||
| <li ngbDropdown class="nav-item me-n2" (openChange)="onOpenChange($event)"> | ||||
|   <button class="btn border-0" id="chatDropdown" ngbDropdownToggle> | ||||
|     <i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs> | ||||
|   </button> | ||||
|   <div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown"> | ||||
|     <div class="chat-container bg-light p-2"> | ||||
|       <div class="chat-messages font-monospace small"> | ||||
|         @for (message of messages; track message) { | ||||
|           <div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'"> | ||||
|             <span class="p-2 m-2" [class.bg-dark]="message.role === 'user'"> | ||||
|               {{ message.content }} | ||||
|               @if (message.isStreaming) { <span class="blinking-cursor">|</span> } | ||||
|             </span> | ||||
|           </div> | ||||
|         } | ||||
|         <div #scrollAnchor></div> | ||||
|       </div> | ||||
|  | ||||
|       <form class="chat-input"> | ||||
|         <div class="input-group"> | ||||
|           <input | ||||
|             #chatInput | ||||
|             class="form-control form-control-sm" name="chatInput" type="text" | ||||
|             [placeholder]="placeholder" | ||||
|             [disabled]="loading" | ||||
|             [(ngModel)]="input" | ||||
|             (keydown)="searchInputKeyDown($event)" | ||||
|             /> | ||||
|           <button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading">Send</button> | ||||
|         </div> | ||||
|       </form> | ||||
|     </div> | ||||
|   </div> | ||||
| </li> | ||||
| @@ -1,37 +0,0 @@ | ||||
| .dropdown-menu { | ||||
|   width: var(--pngx-toast-max-width); | ||||
| } | ||||
|  | ||||
| .chat-messages { | ||||
|   max-height: 350px; | ||||
|   overflow-y: auto; | ||||
| } | ||||
|  | ||||
| .dropdown-toggle::after { | ||||
|   display: none; | ||||
| } | ||||
|  | ||||
| .dropdown-item { | ||||
|   white-space: initial; | ||||
| } | ||||
|  | ||||
| @media screen and (max-width: 400px) { | ||||
|   :host ::ng-deep .dropdown-menu-end { | ||||
|     right: -3rem; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .blinking-cursor { | ||||
|   font-weight: bold; | ||||
|   font-size: 1.2em; | ||||
|   animation: blink 1s step-end infinite; | ||||
| } | ||||
|  | ||||
| @keyframes blink { | ||||
|   from, to { | ||||
|     opacity: 0; | ||||
|   } | ||||
|   50% { | ||||
|     opacity: 1; | ||||
|   } | ||||
| } | ||||
| @@ -1,132 +0,0 @@ | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { ElementRef } from '@angular/core' | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { NavigationEnd, Router } from '@angular/router' | ||||
| import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { Subject } from 'rxjs' | ||||
| import { ChatService } from 'src/app/services/chat.service' | ||||
| import { ChatComponent } from './chat.component' | ||||
|  | ||||
| describe('ChatComponent', () => { | ||||
|   let component: ChatComponent | ||||
|   let fixture: ComponentFixture<ChatComponent> | ||||
|   let chatService: ChatService | ||||
|   let router: Router | ||||
|   let routerEvents$: Subject<NavigationEnd> | ||||
|   let mockStream$: Subject<string> | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     TestBed.configureTestingModule({ | ||||
|       imports: [NgxBootstrapIconsModule.pick(allIcons), ChatComponent], | ||||
|       providers: [ | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|  | ||||
|     fixture = TestBed.createComponent(ChatComponent) | ||||
|     router = TestBed.inject(Router) | ||||
|     routerEvents$ = new Subject<any>() | ||||
|     jest | ||||
|       .spyOn(router, 'events', 'get') | ||||
|       .mockReturnValue(routerEvents$.asObservable()) | ||||
|     chatService = TestBed.inject(ChatService) | ||||
|     mockStream$ = new Subject<string>() | ||||
|     jest | ||||
|       .spyOn(chatService, 'streamChat') | ||||
|       .mockReturnValue(mockStream$.asObservable()) | ||||
|     component = fixture.componentInstance | ||||
|  | ||||
|     jest.useFakeTimers() | ||||
|  | ||||
|     fixture.detectChanges() | ||||
|  | ||||
|     component.scrollAnchor.nativeElement.scrollIntoView = jest.fn() | ||||
|   }) | ||||
|  | ||||
|   it('should update documentId on initialization', () => { | ||||
|     jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123') | ||||
|     component.ngOnInit() | ||||
|     expect(component.documentId).toBe(123) | ||||
|   }) | ||||
|  | ||||
|   it('should update documentId on navigation', () => { | ||||
|     component.ngOnInit() | ||||
|     routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456')) | ||||
|     expect(component.documentId).toBe(456) | ||||
|   }) | ||||
|  | ||||
|   it('should return correct placeholder based on documentId', () => { | ||||
|     component.documentId = 123 | ||||
|     expect(component.placeholder).toBe('Ask a question about this document...') | ||||
|     component.documentId = undefined | ||||
|     expect(component.placeholder).toBe('Ask a question about a document...') | ||||
|   }) | ||||
|  | ||||
|   it('should send a message and handle streaming response', () => { | ||||
|     component.input = 'Hello' | ||||
|     component.sendMessage() | ||||
|  | ||||
|     expect(component.messages.length).toBe(2) | ||||
|     expect(component.messages[0].content).toBe('Hello') | ||||
|     expect(component.loading).toBe(true) | ||||
|  | ||||
|     mockStream$.next('Hi') | ||||
|     expect(component.messages[1].content).toBe('H') | ||||
|     mockStream$.next('Hi there') | ||||
|     // advance time to process the typewriter effect | ||||
|     jest.advanceTimersByTime(1000) | ||||
|     expect(component.messages[1].content).toBe('Hi there') | ||||
|  | ||||
|     mockStream$.complete() | ||||
|     expect(component.loading).toBe(false) | ||||
|     expect(component.messages[1].isStreaming).toBe(false) | ||||
|   }) | ||||
|  | ||||
|   it('should handle errors during streaming', () => { | ||||
|     component.input = 'Hello' | ||||
|     component.sendMessage() | ||||
|  | ||||
|     mockStream$.error('Error') | ||||
|     expect(component.messages[1].content).toContain( | ||||
|       '⚠️ Error receiving response.' | ||||
|     ) | ||||
|     expect(component.loading).toBe(false) | ||||
|   }) | ||||
|  | ||||
|   it('should enqueue typewriter chunks correctly', () => { | ||||
|     const message = { content: '', role: 'assistant', isStreaming: true } | ||||
|     component.enqueueTypewriter(null, message as any) // coverage for null | ||||
|     component.enqueueTypewriter('Hello', message as any) | ||||
|     expect(component['typewriterBuffer'].length).toBe(4) | ||||
|   }) | ||||
|  | ||||
|   it('should scroll to bottom after sending a message', () => { | ||||
|     const scrollSpy = jest.spyOn( | ||||
|       ChatComponent.prototype as any, | ||||
|       'scrollToBottom' | ||||
|     ) | ||||
|     component.input = 'Test' | ||||
|     component.sendMessage() | ||||
|     expect(scrollSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should focus chat input when dropdown is opened', () => { | ||||
|     const focus = jest.fn() | ||||
|     component.chatInput = { | ||||
|       nativeElement: { focus: focus }, | ||||
|     } as unknown as ElementRef<HTMLInputElement> | ||||
|  | ||||
|     component.onOpenChange(true) | ||||
|     jest.advanceTimersByTime(15) | ||||
|     expect(focus).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should send message on Enter key press', () => { | ||||
|     jest.spyOn(component, 'sendMessage') | ||||
|     const event = new KeyboardEvent('keydown', { key: 'Enter' }) | ||||
|     component.searchInputKeyDown(event) | ||||
|     expect(component.sendMessage).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
| @@ -1,140 +0,0 @@ | ||||
| import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { NavigationEnd, Router } from '@angular/router' | ||||
| import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { filter, map } from 'rxjs' | ||||
| import { ChatMessage, ChatService } from 'src/app/services/chat.service' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-chat', | ||||
|   imports: [ | ||||
|     FormsModule, | ||||
|     ReactiveFormsModule, | ||||
|     NgxBootstrapIconsModule, | ||||
|     NgbDropdownModule, | ||||
|   ], | ||||
|   templateUrl: './chat.component.html', | ||||
|   styleUrl: './chat.component.scss', | ||||
| }) | ||||
| export class ChatComponent implements OnInit { | ||||
|   public messages: ChatMessage[] = [] | ||||
|   public loading = false | ||||
|   public input: string = '' | ||||
|   public documentId!: number | ||||
|  | ||||
|   private chatService: ChatService = inject(ChatService) | ||||
|   private router: Router = inject(Router) | ||||
|  | ||||
|   @ViewChild('scrollAnchor') scrollAnchor!: ElementRef<HTMLDivElement> | ||||
|   @ViewChild('chatInput') chatInput!: ElementRef<HTMLInputElement> | ||||
|  | ||||
|   private typewriterBuffer: string[] = [] | ||||
|   private typewriterActive = false | ||||
|  | ||||
|   public get placeholder(): string { | ||||
|     return this.documentId | ||||
|       ? $localize`Ask a question about this document...` | ||||
|       : $localize`Ask a question about a document...` | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.updateDocumentId(this.router.url) | ||||
|     this.router.events | ||||
|       .pipe( | ||||
|         filter((event) => event instanceof NavigationEnd), | ||||
|         map((event) => (event as NavigationEnd).url) | ||||
|       ) | ||||
|       .subscribe((url) => { | ||||
|         this.updateDocumentId(url) | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   private updateDocumentId(url: string): void { | ||||
|     const docIdRe = url.match(/^\/documents\/(\d+)/) | ||||
|     this.documentId = docIdRe ? +docIdRe[1] : undefined | ||||
|   } | ||||
|  | ||||
|   sendMessage(): void { | ||||
|     if (!this.input.trim()) return | ||||
|  | ||||
|     const userMessage: ChatMessage = { role: 'user', content: this.input } | ||||
|     this.messages.push(userMessage) | ||||
|     this.scrollToBottom() | ||||
|  | ||||
|     const assistantMessage: ChatMessage = { | ||||
|       role: 'assistant', | ||||
|       content: '', | ||||
|       isStreaming: true, | ||||
|     } | ||||
|     this.messages.push(assistantMessage) | ||||
|     this.loading = true | ||||
|  | ||||
|     let lastPartialLength = 0 | ||||
|  | ||||
|     this.chatService.streamChat(this.documentId, this.input).subscribe({ | ||||
|       next: (chunk) => { | ||||
|         const delta = chunk.substring(lastPartialLength) | ||||
|         lastPartialLength = chunk.length | ||||
|         this.enqueueTypewriter(delta, assistantMessage) | ||||
|       }, | ||||
|       error: () => { | ||||
|         assistantMessage.content += '\n\n⚠️ Error receiving response.' | ||||
|         assistantMessage.isStreaming = false | ||||
|         this.loading = false | ||||
|       }, | ||||
|       complete: () => { | ||||
|         assistantMessage.isStreaming = false | ||||
|         this.loading = false | ||||
|         this.scrollToBottom() | ||||
|       }, | ||||
|     }) | ||||
|  | ||||
|     this.input = '' | ||||
|   } | ||||
|  | ||||
|   enqueueTypewriter(chunk: string, message: ChatMessage): void { | ||||
|     if (!chunk) return | ||||
|  | ||||
|     this.typewriterBuffer.push(...chunk.split('')) | ||||
|  | ||||
|     if (!this.typewriterActive) { | ||||
|       this.typewriterActive = true | ||||
|       this.playTypewriter(message) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   playTypewriter(message: ChatMessage): void { | ||||
|     if (this.typewriterBuffer.length === 0) { | ||||
|       this.typewriterActive = false | ||||
|       return | ||||
|     } | ||||
|  | ||||
|     const nextChar = this.typewriterBuffer.shift()! | ||||
|     message.content += nextChar | ||||
|     this.scrollToBottom() | ||||
|  | ||||
|     setTimeout(() => this.playTypewriter(message), 10) // 10ms per character | ||||
|   } | ||||
|  | ||||
|   private scrollToBottom(): void { | ||||
|     setTimeout(() => { | ||||
|       this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' }) | ||||
|     }, 50) | ||||
|   } | ||||
|  | ||||
|   public onOpenChange(open: boolean): void { | ||||
|     if (open) { | ||||
|       setTimeout(() => { | ||||
|         this.chatInput.nativeElement.focus() | ||||
|       }, 10) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public searchInputKeyDown(event: KeyboardEvent) { | ||||
|     if (event.key === 'Enter') { | ||||
|       event.preventDefault() | ||||
|       this.sendMessage() | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -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,7 +1,7 @@ | ||||
| <div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions"> | ||||
|     <button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle> | ||||
| <div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions" placement="bottom-end"> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle> | ||||
|       <i-bs name="ui-radios"></i-bs> | ||||
|       <div class="d-none d-lg-inline"> <ng-container i18n>Custom Fields</ng-container></div> | ||||
|       <div class="d-none d-sm-inline"> <ng-container i18n>Custom Fields</ng-container></div> | ||||
|     </button> | ||||
|     <div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown"> | ||||
|         <div class="list-group list-group-flush" (keydown)="listKeyDown($event)"> | ||||
|   | ||||
| @@ -41,9 +41,3 @@ | ||||
|     min-width: 140px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .btn-group-xs { | ||||
|   > .btn { | ||||
|     border-radius: 0.15rem; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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,6 +28,16 @@ | ||||
|               </div> | ||||
|             } | ||||
|           </div> | ||||
|           @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> | ||||
|           } | ||||
|           @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> | ||||
|           } | ||||
|   | ||||
| @@ -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), | ||||
|   | ||||
| @@ -177,6 +177,7 @@ | ||||
|           <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> | ||||
|           <pngx-input-select i18n-title title="Has storage path" [items]="storagePaths" [allowNull]="true" formControlName="filter_has_storage_path"></pngx-input-select> | ||||
|         </div> | ||||
|       } | ||||
|     </div> | ||||
|   | ||||
| @@ -412,6 +412,9 @@ export class WorkflowEditDialogComponent | ||||
|         filter_has_document_type: new FormControl( | ||||
|           trigger.filter_has_document_type | ||||
|         ), | ||||
|         filter_has_storage_path: new FormControl( | ||||
|           trigger.filter_has_storage_path | ||||
|         ), | ||||
|         schedule_offset_days: new FormControl(trigger.schedule_offset_days), | ||||
|         schedule_is_recurring: new FormControl(trigger.schedule_is_recurring), | ||||
|         schedule_recurring_interval_days: new FormControl( | ||||
| @@ -536,6 +539,7 @@ export class WorkflowEditDialogComponent | ||||
|       filter_has_tags: [], | ||||
|       filter_has_correspondent: null, | ||||
|       filter_has_document_type: null, | ||||
|       filter_has_storage_path: null, | ||||
|       matching_algorithm: MATCH_NONE, | ||||
|       match: '', | ||||
|       is_insensitive: true, | ||||
|   | ||||
| @@ -114,6 +114,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 | ||||
|   | ||||
| @@ -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> { | ||||
|   | ||||
| @@ -1,24 +1,17 @@ | ||||
| <div class="mb-3" [class.pb-3]="error"> | ||||
|   <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> | ||||
|       } | ||||
|     </div> | ||||
|   <div class="position-relative" [class.col-md-9]="horizontal"> | ||||
|     <div class="input-group" [class.is-invalid]="error"> | ||||
|       <input #inputField [type]="showReveal && textVisible ? 'text' : 'password'" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (focus)="onFocus()" (focusout)="onFocusOut()" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete"> | ||||
|       @if (showReveal) { | ||||
|         <button type="button" class="btn btn-outline-secondary" (click)="toggleVisibility()" i18n-title title="Show password" [disabled]="disabled || disableRevealToggle"> | ||||
|           <i-bs name="eye"></i-bs> | ||||
|         </button> | ||||
|       } | ||||
|     </div> | ||||
|     <div class="invalid-feedback"> | ||||
|       {{error}} | ||||
|     </div> | ||||
|     @if (hint) { | ||||
|       <small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small> | ||||
| <div class="mb-3"> | ||||
|   <label class="form-label" [for]="inputId">{{title}}</label> | ||||
|   <div class="input-group" [class.is-invalid]="error"> | ||||
|     <input #inputField [type]="showReveal && textVisible ? 'text' : 'password'" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (focus)="onFocus()" (focusout)="onFocusOut()" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete"> | ||||
|     @if (showReveal) { | ||||
|       <button type="button" class="btn btn-outline-secondary" (click)="toggleVisibility()" i18n-title title="Show password" [disabled]="disabled || disableRevealToggle"> | ||||
|         <i-bs name="eye"></i-bs> | ||||
|       </button> | ||||
|     } | ||||
|   </div> | ||||
|   <div class="invalid-feedback"> | ||||
|     {{error}} | ||||
|   </div> | ||||
|   @if (hint) { | ||||
|     <small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small> | ||||
|   } | ||||
| </div> | ||||
|   | ||||
| @@ -7,13 +7,14 @@ | ||||
|       <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 +26,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 | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -15,12 +15,6 @@ | ||||
|         @if (hint) { | ||||
|           <small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small> | ||||
|         } | ||||
|         @if (getSuggestion()?.length > 0) { | ||||
|           <small> | ||||
|             <span i18n>Suggestion:</span>  | ||||
|             <a (click)="applySuggestion(s)" [routerLink]="[]">{{getSuggestion()}}</a>  | ||||
|           </small> | ||||
|         } | ||||
|         <div class="invalid-feedback position-absolute top-100"> | ||||
|           {{error}} | ||||
|         </div> | ||||
|   | ||||
| @@ -26,20 +26,10 @@ describe('TextComponent', () => { | ||||
|  | ||||
|   it('should support use of input field', () => { | ||||
|     expect(component.value).toBeUndefined() | ||||
|     input.value = 'foo' | ||||
|     input.dispatchEvent(new Event('input')) | ||||
|     fixture.detectChanges() | ||||
|     expect(component.value).toBe('foo') | ||||
|   }) | ||||
|  | ||||
|   it('should support suggestion', () => { | ||||
|     component.value = 'foo' | ||||
|     component.suggestion = 'foo' | ||||
|     expect(component.getSuggestion()).toBe('') | ||||
|     component.value = 'bar' | ||||
|     expect(component.getSuggestion()).toBe('foo') | ||||
|     component.applySuggestion() | ||||
|     fixture.detectChanges() | ||||
|     expect(component.value).toBe('foo') | ||||
|     // TODO: why doesn't this work? | ||||
|     // input.value = 'foo' | ||||
|     // input.dispatchEvent(new Event('change')) | ||||
|     // fixture.detectChanges() | ||||
|     // expect(component.value).toEqual('foo') | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -4,7 +4,6 @@ import { | ||||
|   NG_VALUE_ACCESSOR, | ||||
|   ReactiveFormsModule, | ||||
| } from '@angular/forms' | ||||
| import { RouterLink } from '@angular/router' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' | ||||
| import { AbstractInputComponent } from '../abstract-input' | ||||
| @@ -25,7 +24,6 @@ import { AbstractInputComponent } from '../abstract-input' | ||||
|     ReactiveFormsModule, | ||||
|     SafeHtmlPipe, | ||||
|     NgxBootstrapIconsModule, | ||||
|     RouterLink, | ||||
|   ], | ||||
| }) | ||||
| export class TextComponent extends AbstractInputComponent<string> { | ||||
| @@ -35,19 +33,7 @@ export class TextComponent extends AbstractInputComponent<string> { | ||||
|   @Input() | ||||
|   placeholder: string = '' | ||||
|  | ||||
|   @Input() | ||||
|   suggestion: string = '' | ||||
|  | ||||
|   constructor() { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
|   getSuggestion() { | ||||
|     return this.value !== this.suggestion ? this.suggestion : '' | ||||
|   } | ||||
|  | ||||
|   applySuggestion() { | ||||
|     this.value = this.suggestion | ||||
|     this.onChange(this.value) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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> | ||||
| @@ -0,0 +1,70 @@ | ||||
|  | ||||
|  | ||||
| .page-item { | ||||
|   position: relative; | ||||
|   cursor: pointer; | ||||
|   border: 1px solid transparent; | ||||
|   background-origin: border-box; | ||||
|  | ||||
|   &.selected { | ||||
|     background-color: var(--pngx-primary-darken-5); | ||||
|   } | ||||
| } | ||||
|  | ||||
| .pdf-viewer-container { | ||||
|   background-color: gray; | ||||
|   height: 240px; | ||||
|  | ||||
|   pdf-viewer { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|   } | ||||
| } | ||||
|  | ||||
| ::ng-deep .ng2-pdf-viewer-container { | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .hover-actions { | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   right: 0; | ||||
|   display: none; | ||||
| } | ||||
|  | ||||
| .page-item:hover .hover-actions { | ||||
|   display: block; | ||||
| } | ||||
|  | ||||
| .document-check { | ||||
|   display: none; | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   padding: 0.5rem; | ||||
|   border-top-left-radius: 0.25rem; | ||||
|   border-bottom-right-radius: 0.25rem; | ||||
|   pointer-events: none; | ||||
|  | ||||
|   .form-check { | ||||
|     padding: 0; | ||||
|     min-height: 0; | ||||
|     margin-bottom: 0; | ||||
|  | ||||
|     .form-check-input { | ||||
|       margin-left: 0; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .page-item:hover .document-check, .selected .document-check { | ||||
|   display: block; | ||||
| } | ||||
|  | ||||
| .z-10 { | ||||
|     z-index: 10; | ||||
| } | ||||
|  | ||||
| .split-after { | ||||
|   writing-mode: vertical-rl; | ||||
| } | ||||
| @@ -0,0 +1,142 @@ | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { provideHttpClientTesting } from '@angular/common/http/testing' | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { PDFEditorComponent } from './pdf-editor.component' | ||||
|  | ||||
| describe('PDFEditorComponent', () => { | ||||
|   let component: PDFEditorComponent | ||||
|   let fixture: ComponentFixture<PDFEditorComponent> | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       imports: [PDFEditorComponent, NgxBootstrapIconsModule.pick(allIcons)], | ||||
|       providers: [ | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|         { provide: NgbActiveModal, useValue: {} }, | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|     fixture = TestBed.createComponent(PDFEditorComponent) | ||||
|     component = fixture.componentInstance | ||||
|     fixture.detectChanges() | ||||
|   }) | ||||
|  | ||||
|   it('should return correct operations with no changes', () => { | ||||
|     component.pages = [ | ||||
|       { page: 1, rotate: 0, splitAfter: false }, | ||||
|       { page: 2, rotate: 0, splitAfter: false }, | ||||
|       { page: 3, rotate: 0, splitAfter: false }, | ||||
|     ] | ||||
|     const ops = component.getOperations() | ||||
|     expect(ops).toEqual([ | ||||
|       { page: 1, rotate: 0, doc: 0 }, | ||||
|       { page: 2, rotate: 0, doc: 0 }, | ||||
|       { page: 3, rotate: 0, doc: 0 }, | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('should rotate, delete and reorder pages', () => { | ||||
|     component.pages = [ | ||||
|       { page: 1, rotate: 0, splitAfter: false, selected: false }, | ||||
|       { page: 2, rotate: 0, splitAfter: false, selected: false }, | ||||
|     ] | ||||
|     component.toggleSelection(0) | ||||
|     component.rotateSelected(90) | ||||
|     expect(component.pages[0].rotate).toBe(90) | ||||
|     component.toggleSelection(0) // deselect | ||||
|     component.toggleSelection(1) | ||||
|     component.deleteSelected() | ||||
|     expect(component.pages.length).toBe(1) | ||||
|     component.pages.push({ page: 2, rotate: 0, splitAfter: false }) | ||||
|     component.drop({ previousIndex: 0, currentIndex: 1 } as any) | ||||
|     expect(component.pages[0].page).toBe(2) | ||||
|     component.rotate(0) | ||||
|     expect(component.pages[0].rotate).toBe(90) | ||||
|   }) | ||||
|  | ||||
|   it('should handle empty pages array', () => { | ||||
|     component.pages = [] | ||||
|     expect(component.getOperations()).toEqual([]) | ||||
|   }) | ||||
|  | ||||
|   it('should increment doc index after splitAfter', () => { | ||||
|     component.pages = [ | ||||
|       { page: 1, rotate: 0, splitAfter: true }, | ||||
|       { page: 2, rotate: 0, splitAfter: false }, | ||||
|       { page: 3, rotate: 0, splitAfter: true }, | ||||
|       { page: 4, rotate: 0, splitAfter: false }, | ||||
|     ] | ||||
|     const ops = component.getOperations() | ||||
|     expect(ops).toEqual([ | ||||
|       { page: 1, rotate: 0, doc: 0 }, | ||||
|       { page: 2, rotate: 0, doc: 1 }, | ||||
|       { page: 3, rotate: 0, doc: 1 }, | ||||
|       { page: 4, rotate: 0, doc: 2 }, | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('should include rotations in operations', () => { | ||||
|     component.pages = [ | ||||
|       { page: 1, rotate: 90, splitAfter: false }, | ||||
|       { page: 2, rotate: 180, splitAfter: true }, | ||||
|       { page: 3, rotate: 270, splitAfter: false }, | ||||
|     ] | ||||
|     const ops = component.getOperations() | ||||
|     expect(ops).toEqual([ | ||||
|       { page: 1, rotate: 90, doc: 0 }, | ||||
|       { page: 2, rotate: 180, doc: 0 }, | ||||
|       { page: 3, rotate: 270, doc: 1 }, | ||||
|     ]) | ||||
|   }) | ||||
|  | ||||
|   it('should handle remove operation', () => { | ||||
|     component.pages = [ | ||||
|       { page: 1, rotate: 0, splitAfter: false, selected: false }, | ||||
|       { page: 2, rotate: 0, splitAfter: false, selected: true }, | ||||
|       { page: 3, rotate: 0, splitAfter: false, selected: false }, | ||||
|     ] | ||||
|     component.remove(1) // remove page 2 | ||||
|     expect(component.pages.length).toBe(2) | ||||
|     expect(component.pages[0].page).toBe(1) | ||||
|     expect(component.pages[1].page).toBe(3) | ||||
|   }) | ||||
|  | ||||
|   it('should toggle splitAfter correctly', () => { | ||||
|     component.pages = [ | ||||
|       { page: 1, rotate: 0, splitAfter: false }, | ||||
|       { page: 2, rotate: 0, splitAfter: false }, | ||||
|     ] | ||||
|     component.toggleSplit(0) | ||||
|     expect(component.pages[0].splitAfter).toBeTruthy() | ||||
|     component.toggleSplit(1) | ||||
|     expect(component.pages[1].splitAfter).toBeTruthy() | ||||
|   }) | ||||
|  | ||||
|   it('should select and deselect all pages', () => { | ||||
|     component.pages = [ | ||||
|       { page: 1, rotate: 0, splitAfter: false, selected: false }, | ||||
|       { page: 2, rotate: 0, splitAfter: false, selected: false }, | ||||
|     ] | ||||
|     component.selectAll() | ||||
|     expect(component.pages.every((p) => p.selected)).toBeTruthy() | ||||
|     expect(component.hasSelection()).toBeTruthy() | ||||
|     component.deselectAll() | ||||
|     expect(component.pages.every((p) => !p.selected)).toBeTruthy() | ||||
|     expect(component.hasSelection()).toBeFalsy() | ||||
|   }) | ||||
|  | ||||
|   it('should handle pdf loading and page generation', () => { | ||||
|     const mockPdf = { | ||||
|       numPages: 3, | ||||
|       getPage: (pageNum: number) => Promise.resolve({ pageNumber: pageNum }), | ||||
|     } | ||||
|     component.pdfLoaded(mockPdf as any) | ||||
|     expect(component.totalPages).toBe(3) | ||||
|     expect(component.pages.length).toBe(3) | ||||
|     expect(component.pages[0].page).toBe(1) | ||||
|     expect(component.pages[1].page).toBe(2) | ||||
|     expect(component.pages[2].page).toBe(3) | ||||
|   }) | ||||
| }) | ||||
| @@ -0,0 +1,134 @@ | ||||
| import { | ||||
|   CdkDragDrop, | ||||
|   DragDropModule, | ||||
|   moveItemInArray, | ||||
| } from '@angular/cdk/drag-drop' | ||||
| import { Component, inject } from '@angular/core' | ||||
| import { FormsModule } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component' | ||||
|  | ||||
| interface PageOperation { | ||||
|   page: number | ||||
|   rotate: number | ||||
|   splitAfter: boolean | ||||
|   selected?: boolean | ||||
|   loaded?: boolean | ||||
| } | ||||
|  | ||||
| export enum PdfEditorEditMode { | ||||
|   Update = 'update', | ||||
|   Create = 'create', | ||||
| } | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-pdf-editor', | ||||
|   templateUrl: './pdf-editor.component.html', | ||||
|   styleUrl: './pdf-editor.component.scss', | ||||
|   imports: [ | ||||
|     DragDropModule, | ||||
|     FormsModule, | ||||
|     PdfViewerModule, | ||||
|     NgxBootstrapIconsModule, | ||||
|   ], | ||||
| }) | ||||
| export class PDFEditorComponent extends ConfirmDialogComponent { | ||||
|   public PdfEditorEditMode = PdfEditorEditMode | ||||
|  | ||||
|   private documentService = inject(DocumentService) | ||||
|   activeModal: NgbActiveModal = inject(NgbActiveModal) | ||||
|  | ||||
|   documentID: number | ||||
|   pages: PageOperation[] = [] | ||||
|   totalPages = 0 | ||||
|   editMode: PdfEditorEditMode = PdfEditorEditMode.Create | ||||
|   deleteOriginal: boolean = false | ||||
|   includeMetadata: boolean = true | ||||
|  | ||||
|   get pdfSrc(): string { | ||||
|     return this.documentService.getPreviewUrl(this.documentID) | ||||
|   } | ||||
|  | ||||
|   pdfLoaded(pdf: PDFDocumentProxy) { | ||||
|     this.totalPages = pdf.numPages | ||||
|     this.pages = Array.from({ length: this.totalPages }, (_, i) => ({ | ||||
|       page: i + 1, | ||||
|       rotate: 0, | ||||
|       splitAfter: false, | ||||
|       selected: false, | ||||
|       loaded: false, | ||||
|     })) | ||||
|   } | ||||
|  | ||||
|   toggleSelection(i: number) { | ||||
|     this.pages[i].selected = !this.pages[i].selected | ||||
|   } | ||||
|  | ||||
|   rotate(i: number, counterclockwise: boolean = false) { | ||||
|     this.pages[i].rotate = | ||||
|       (this.pages[i].rotate + (counterclockwise ? -90 : 90) + 360) % 360 | ||||
|   } | ||||
|  | ||||
|   rotateSelected(dir: number) { | ||||
|     for (let p of this.pages) { | ||||
|       if (p.selected) { | ||||
|         p.rotate = (p.rotate + dir + 360) % 360 | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   remove(i: number) { | ||||
|     this.pages.splice(i, 1) | ||||
|   } | ||||
|  | ||||
|   toggleSplit(i: number) { | ||||
|     this.pages[i].splitAfter = !this.pages[i].splitAfter | ||||
|     if (this.pages[i].splitAfter) { | ||||
|       // force create mode | ||||
|       this.editMode = PdfEditorEditMode.Create | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   selectAll() { | ||||
|     this.pages.forEach((p) => (p.selected = true)) | ||||
|   } | ||||
|  | ||||
|   deselectAll() { | ||||
|     this.pages.forEach((p) => (p.selected = false)) | ||||
|   } | ||||
|  | ||||
|   deleteSelected() { | ||||
|     this.pages = this.pages.filter((p) => !p.selected) | ||||
|   } | ||||
|  | ||||
|   hasSelection(): boolean { | ||||
|     return this.pages.some((p) => p.selected) | ||||
|   } | ||||
|  | ||||
|   hasSplit(): boolean { | ||||
|     return this.pages.some((p) => p.splitAfter) | ||||
|   } | ||||
|  | ||||
|   drop(event: CdkDragDrop<PageOperation[]>) { | ||||
|     moveItemInArray(this.pages, event.previousIndex, event.currentIndex) | ||||
|   } | ||||
|  | ||||
|   getOperations() { | ||||
|     return this.pages.map((p, idx) => ({ | ||||
|       page: p.page, | ||||
|       rotate: p.rotate, | ||||
|       doc: this.computeDocIndex(idx), | ||||
|     })) | ||||
|   } | ||||
|  | ||||
|   private computeDocIndex(index: number): number { | ||||
|     let docIndex = 0 | ||||
|     for (let i = 0; i <= index; i++) { | ||||
|       if (this.pages[i].splitAfter && i < index) docIndex++ | ||||
|     } | ||||
|     return docIndex | ||||
|   } | ||||
| } | ||||
| @@ -1,49 +0,0 @@ | ||||
| <div class="btn-group"> | ||||
|   <button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="loading || (suggestions && !aiEnabled)"> | ||||
|     @if (loading) { | ||||
|       <div class="spinner-border spinner-border-sm" role="status"></div> | ||||
|     } @else { | ||||
|       <i-bs width="1.2em" height="1.2em" name="stars"></i-bs> | ||||
|     } | ||||
|     <span class="d-none d-lg-inline ps-1" i18n>Suggest</span> | ||||
|     @if (totalSuggestions > 0) { | ||||
|       <span class="badge bg-primary ms-2">{{ totalSuggestions }}</span> | ||||
|     } | ||||
|   </button> | ||||
|  | ||||
|   @if (aiEnabled) { | ||||
|     <div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions"> | ||||
|       <button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="loading || !suggestions" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown"> | ||||
|         <span class="visually-hidden" i18n>Show suggestions</span> | ||||
|       </button> | ||||
|  | ||||
|       <div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown"> | ||||
|         <div class="list-group list-group-flush small pb-0"> | ||||
|           @if (!suggestions?.suggested_tags && !suggestions?.suggested_document_types && !suggestions?.suggested_correspondents) { | ||||
|             <div class="list-group-item text-muted fst-italic"> | ||||
|               <small class="text-muted small fst-italic" i18n>No novel suggestions</small> | ||||
|             </div> | ||||
|           } | ||||
|           @if (suggestions?.suggested_tags.length > 0) { | ||||
|             <small class="list-group-item text-uppercase text-muted small">Tags</small> | ||||
|             @for (tag of suggestions.suggested_tags; track tag) { | ||||
|               <button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)" i18n>{{ tag }}</button> | ||||
|             } | ||||
|           } | ||||
|           @if (suggestions?.suggested_document_types.length > 0) { | ||||
|             <div class="list-group-item text-uppercase text-muted small">Document Types</div> | ||||
|             @for (type of suggestions.suggested_document_types; track type) { | ||||
|               <button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)" i18n>{{ type }}</button> | ||||
|             } | ||||
|           } | ||||
|           @if (suggestions?.suggested_correspondents.length > 0) { | ||||
|             <div class="list-group-item text-uppercase text-muted small">Correspondents</div> | ||||
|             @for (correspondent of suggestions.suggested_correspondents; track correspondent) { | ||||
|               <button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)" i18n>{{ correspondent }}</button> | ||||
|             } | ||||
|           } | ||||
|         </div> | ||||
|       </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