mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-26 03:36:08 -05:00 
			
		
		
		
	Compare commits
	
		
			37 Commits
		
	
	
		
			feature-tr
			...
			e5cafff043
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | e5cafff043 | ||
|   | 8e0d574e99 | ||
|   | 8a5820328e | ||
|   | 809d62a2f4 | ||
|   | 0d87f94b9b | ||
|   | 315b90f8e5 | ||
|   | 47b2d2964b | ||
|   | e05639ae4e | ||
|   | f400a8cb2f | ||
|   | 26abcf5612 | ||
|   | afde52430d | ||
|   | 716f2da652 | ||
|   | c54073b7c2 | ||
|   | 247e6f39dc | ||
|   | 1e6dfc4481 | ||
|   | 7cc0750066 | ||
|   | bd6585d3b4 | ||
|   | 717e828a1d | ||
|   | 07381d48e6 | ||
|   | dd0ffaf312 | ||
|   | 264504affc | ||
|   | 4feedf2add | ||
|   | 2f76cf9831 | ||
|   | 1002d37f6b | ||
|   | d260a94740 | ||
|   | 88c69b83ea | ||
|   | 2557ee2014 | ||
|   | 3c75deed80 | ||
|   | d05343c927 | ||
|   | e7972b7eaf | ||
|   | 75a091cc0d | ||
|   | dca74803fd | ||
|   | 3cf3d868d0 | ||
|   | bf4fc6604a | ||
|   | e8c1eb86fa | ||
|   | c3dad3cf69 | ||
|   | 811bd66088 | 
							
								
								
									
										164
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										164
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -17,59 +17,18 @@ 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: | ||||
|     needs: | ||||
|       - detect-duplicate | ||||
|     if: needs.detect-duplicate.outputs.should_run == 'true' | ||||
|     # 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 | ||||
|     name: Linting Checks | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Install python | ||||
|         uses: actions/setup-python@v6 | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Check files | ||||
| @@ -84,7 +43,7 @@ jobs: | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v6 | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
| @@ -138,7 +97,7 @@ jobs: | ||||
|           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@v6 | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: "${{ matrix.python-version }}" | ||||
|       - name: Install uv | ||||
| @@ -183,13 +142,27 @@ 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: Upload coverage artifacts | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         if: always() | ||||
|         with: | ||||
|           name: backend-coverage-${{ matrix.python-version }} | ||||
|           path: | | ||||
|             .coverage | ||||
|             coverage.xml | ||||
|             junit.xml | ||||
|           retention-days: 1 | ||||
|           include-hidden-files: true | ||||
|           if-no-files-found: error | ||||
|       - name: Stop containers | ||||
|         if: always() | ||||
|         run: | | ||||
| @@ -207,7 +180,7 @@ jobs: | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v5 | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -240,7 +213,7 @@ jobs: | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v5 | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -263,13 +236,26 @@ 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/ | ||||
|       - name: Upload coverage artifacts | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         if: always() | ||||
|         with: | ||||
|           name: frontend-coverage-${{ matrix.shard-index }} | ||||
|           path: | | ||||
|             src-ui/coverage/lcov.info | ||||
|             src-ui/coverage/coverage-final.json | ||||
|             src-ui/junit.xml | ||||
|           retention-days: 1 | ||||
|           if-no-files-found: error | ||||
|   tests-frontend-e2e: | ||||
|     name: "Frontend E2E Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})" | ||||
|     runs-on: ubuntu-24.04 | ||||
| @@ -288,7 +274,7 @@ jobs: | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v5 | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -331,7 +317,7 @@ jobs: | ||||
|         with: | ||||
|           version: 10 | ||||
|       - name: Use Node.js 20 | ||||
|         uses: actions/setup-node@v5 | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
| @@ -350,6 +336,74 @@ jobs: | ||||
|         env: | ||||
|           CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} | ||||
|         run: cd src-ui && pnpm run build --configuration=production | ||||
|   sonarqube-analysis: | ||||
|     name: "SonarQube Analysis" | ||||
|     runs-on: ubuntu-24.04 | ||||
|     needs: | ||||
|       - tests-backend | ||||
|       - tests-frontend | ||||
|     if: github.repository_owner == 'paperless-ngx' | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v5 | ||||
|         with: | ||||
|           fetch-depth: 0 | ||||
|       - name: Download all backend coverage | ||||
|         uses: actions/download-artifact@v5.0.0 | ||||
|         with: | ||||
|           pattern: backend-coverage-* | ||||
|           path: ./coverage/ | ||||
|       - name: Download all frontend coverage | ||||
|         uses: actions/download-artifact@v5.0.0 | ||||
|         with: | ||||
|           pattern: frontend-coverage-* | ||||
|           path: ./coverage/ | ||||
|       - name: Set up Python | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install coverage tools | ||||
|         run: | | ||||
|           pip install coverage | ||||
|           npm install -g nyc | ||||
|       # Merge backend coverage from all Python versions | ||||
|       - name: Merge backend coverage | ||||
|         run: | | ||||
|           coverage combine coverage/backend-coverage-*/.coverage | ||||
|           coverage xml -o merged-backend-coverage.xml | ||||
|       # Merge frontend coverage from all shards | ||||
|       - name: Merge frontend coverage | ||||
|         run: | | ||||
|           # Find all coverage-final.json files from the shards, exit with error if none found | ||||
|           shopt -s nullglob | ||||
|           files=(coverage/frontend-coverage-*/coverage/coverage-final.json) | ||||
|           if [ ${#files[@]} -eq 0 ]; then | ||||
|             echo "No frontend coverage JSON found under coverage/" >&2 | ||||
|             exit 1 | ||||
|           fi | ||||
|           # Create .nyc_output directory and copy each shard's coverage JSON into it with a unique name | ||||
|           mkdir -p .nyc_output | ||||
|           for coverage_json in "${files[@]}"; do | ||||
|             shard=$(basename "$(dirname "$(dirname "$coverage_json")")") | ||||
|             cp "$coverage_json" ".nyc_output/${shard}.json" | ||||
|           done | ||||
|           npx nyc merge .nyc_output .nyc_output/out.json | ||||
|           npx nyc report --reporter=lcovonly --report-dir coverage | ||||
|       - name: Upload coverage artifacts | ||||
|         uses: actions/upload-artifact@v4.6.2 | ||||
|         with: | ||||
|           name: merged-coverage | ||||
|           path: | | ||||
|             merged-backend-coverage.xml | ||||
|             .nyc_output/* | ||||
|             coverage/lcov.info | ||||
|           retention-days: 7 | ||||
|           if-no-files-found: error | ||||
|           include-hidden-files: true | ||||
|       - name: SonarQube Analysis | ||||
|         uses: SonarSource/sonarqube-scan-action@v5 | ||||
|         env: | ||||
|           SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} | ||||
|   build-docker-image: | ||||
|     name: Build Docker image for ${{ github.ref_name }} | ||||
|     runs-on: ubuntu-24.04 | ||||
| @@ -473,7 +527,7 @@ jobs: | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v6 | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
| @@ -621,7 +675,7 @@ jobs: | ||||
|           ref: main | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v6 | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
| @@ -653,7 +707,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@v8 | ||||
|         uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|             const { repo, owner } = context.repo; | ||||
|   | ||||
							
								
								
									
										7
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
								
							| @@ -6,9 +6,10 @@ | ||||
| # This workflow will not trigger runs on forked repos. | ||||
| name: Cleanup Image Tags | ||||
| on: | ||||
|   workflow_dispatch: | ||||
|   schedule: | ||||
|     - cron: '0 0 * * 0' | ||||
|   delete: | ||||
|   push: | ||||
|     paths: | ||||
|       - ".github/workflows/cleanup-tags.yml" | ||||
| concurrency: | ||||
|   group: registry-tags-cleanup | ||||
|   cancel-in-progress: false | ||||
|   | ||||
							
								
								
									
										8
									
								
								.github/workflows/pr-bot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.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@v6 | ||||
|         uses: actions/labeler@v5 | ||||
|         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@v8 | ||||
|         uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|             const pr = context.payload.pull_request; | ||||
| @@ -52,7 +52,7 @@ jobs: | ||||
|             } | ||||
|       - name: Label bot-generated PRs | ||||
|         if: ${{ contains(github.actor, 'dependabot') || contains(github.actor, 'crowdin-bot') }} | ||||
|         uses: actions/github-script@v8 | ||||
|         uses: actions/github-script@v7 | ||||
|         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@v8 | ||||
|         uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|             const pr = context.payload.pull_request; | ||||
|   | ||||
							
								
								
									
										8
									
								
								.github/workflows/repo-maintenance.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.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@v10 | ||||
|       - uses: actions/stale@v9 | ||||
|         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@v8 | ||||
|       - uses: actions/github-script@v7 | ||||
|         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@v8 | ||||
|       - uses: actions/github-script@v7 | ||||
|         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@v8 | ||||
|       - uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|             function sleep(ms) { | ||||
|   | ||||
							
								
								
									
										4
									
								
								.github/workflows/translate-strings.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/translate-strings.yml
									
									
									
									
										vendored
									
									
								
							| @@ -17,7 +17,7 @@ jobs: | ||||
|           ref: ${{ github.head_ref }} | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v6 | ||||
|         uses: actions/setup-python@v5 | ||||
|       - 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@v5 | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: 20.x | ||||
|           cache: 'pnpm' | ||||
|   | ||||
| @@ -49,7 +49,7 @@ repos: | ||||
|           - 'prettier-plugin-organize-imports@4.1.0' | ||||
|   # Python hooks | ||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||
|     rev: v0.13.2 | ||||
|     rev: v0.13.0 | ||||
|     hooks: | ||||
|       - id: ruff-check | ||||
|       - id: ruff-format | ||||
| @@ -59,7 +59,7 @@ repos: | ||||
|       - id: pyproject-fmt | ||||
|   # Dockerfile hooks | ||||
|   - repo: https://github.com/AleksaC/hadolint-py | ||||
|     rev: v2.14.0 | ||||
|     rev: v2.12.1b3 | ||||
|     hooks: | ||||
|       - id: hadolint | ||||
|   # Shell script hooks | ||||
|   | ||||
							
								
								
									
										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-trixie-slim AS compile-frontend | ||||
| FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-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.22-python3.12-bookworm-slim AS s6-overlay-base | ||||
| FROM ghcr.io/astral-sh/uv:0.8.17-python3.12-bookworm-slim AS s6-overlay-base | ||||
|  | ||||
| WORKDIR /usr/src/s6 | ||||
|  | ||||
| @@ -170,8 +170,20 @@ 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 \ | ||||
|         https://github.com/paperless-ngx/builder/releases/download/jbig2enc-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ | ||||
|       && 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 \ | ||||
|       && echo "Installing jbig2enc" \ | ||||
|         && dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ | ||||
|       && echo "Configuring imagemagick" \ | ||||
|   | ||||
							
								
								
									
										319
									
								
								dev.txt
									
									
									
									
									
								
							
							
						
						
									
										319
									
								
								dev.txt
									
									
									
									
									
								
							| @@ -1,319 +0,0 @@ | ||||
| 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 | ||||
| @@ -32,7 +32,7 @@ services: | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|   db: | ||||
|     image: docker.io/library/postgres:18 | ||||
|     image: docker.io/library/postgres:17 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - pgdata:/var/lib/postgresql/data | ||||
|   | ||||
| @@ -35,7 +35,7 @@ services: | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|   db: | ||||
|     image: docker.io/library/postgres:18 | ||||
|     image: docker.io/library/postgres:17 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - pgdata:/var/lib/postgresql/data | ||||
|   | ||||
| @@ -31,7 +31,7 @@ services: | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|   db: | ||||
|     image: docker.io/library/postgres:18 | ||||
|     image: docker.io/library/postgres:17 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - pgdata:/var/lib/postgresql/data | ||||
|   | ||||
| @@ -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`. | ||||
| @@ -1805,3 +1805,23 @@ 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. | ||||
|  | ||||
| ## Remote OCR | ||||
|  | ||||
| #### [`PAPERLESS_REMOTE_OCR_ENGINE=<str>`](#PAPERLESS_REMOTE_OCR_ENGINE) {#PAPERLESS_REMOTE_OCR_ENGINE} | ||||
|  | ||||
| : The remote OCR engine to use. Currently only Azure AI is supported as "azureai". | ||||
|  | ||||
|     Defaults to None, which disables remote OCR. | ||||
|  | ||||
| #### [`PAPERLESS_REMOTE_OCR_API_KEY=<str>`](#PAPERLESS_REMOTE_OCR_API_KEY) {#PAPERLESS_REMOTE_OCR_API_KEY} | ||||
|  | ||||
| : The API key to use for the remote OCR engine. | ||||
|  | ||||
|     Defaults to None. | ||||
|  | ||||
| #### [`PAPERLESS_REMOTE_OCR_ENDPOINT=<str>`](#PAPERLESS_REMOTE_OCR_ENDPOINT) {#PAPERLESS_REMOTE_OCR_ENDPOINT} | ||||
|  | ||||
| : The endpoint to use for the remote OCR engine. This is required for Azure AI. | ||||
|  | ||||
|     Defaults to None. | ||||
|   | ||||
| @@ -25,9 +25,10 @@ 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. | ||||
| -   _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way, unless you explicitly choose to do so. | ||||
| -   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. | ||||
|     -   Utilizes the open-source Tesseract engine to recognize more than 100 languages. | ||||
|     -   _New!_ Supports remote OCR with Azure AI (opt-in). | ||||
| -   Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals. | ||||
| -   Uses machine-learning to automatically add tags, correspondents and document types to your documents. | ||||
| -   Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more. | ||||
|   | ||||
| @@ -414,7 +414,7 @@ fields and permissions, which will be merged. | ||||
|  | ||||
| #### Types {#workflow-trigger-types} | ||||
|  | ||||
| Currently, there are four events that correspond to workflow trigger 'types': | ||||
| Currently, there are three 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 | ||||
| @@ -427,7 +427,7 @@ Currently, there are four events that correspond to workflow trigger 'types': | ||||
|    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 four document trigger types: | ||||
| The following flow diagram illustrates the three document trigger types: | ||||
|  | ||||
| ```mermaid | ||||
| flowchart TD | ||||
| @@ -637,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 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 they 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} | ||||
| @@ -882,6 +882,21 @@ how regularly you intend to scan documents and use paperless. | ||||
|     performed the task associated with the document, move it to the | ||||
|     inbox. | ||||
|  | ||||
| ## Remote OCR | ||||
|  | ||||
| !!! important | ||||
|  | ||||
|     This feature is disabled by default and will always remain strictly "opt-in". | ||||
|  | ||||
| Paperless-ngx supports performing OCR on documents using remote services. At the moment, this is limited to | ||||
| [Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence). | ||||
| This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with | ||||
| Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing | ||||
| the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details. | ||||
|  | ||||
| Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size | ||||
| or page limitations (e.g. with a free tier). | ||||
|  | ||||
| ## Architecture | ||||
|  | ||||
| Paperless-ngx consists of the following components: | ||||
|   | ||||
| @@ -15,6 +15,7 @@ classifiers = [ | ||||
| # This will allow testing to not install a webserver, mysql, etc | ||||
|  | ||||
| dependencies = [ | ||||
|   "azure-ai-documentintelligence>=1.0.2", | ||||
|   "babel>=2.17", | ||||
|   "bleach~=6.2.0", | ||||
|   "celery[redis]~=5.5.1", | ||||
| @@ -30,10 +31,10 @@ dependencies = [ | ||||
|   "django-cachalot~=2.8.0", | ||||
|   "django-celery-results~=2.6.0", | ||||
|   "django-compression-middleware~=0.5.0", | ||||
|   "django-cors-headers~=4.9.0", | ||||
|   "django-cors-headers~=4.8.0", | ||||
|   "django-extensions~=4.1", | ||||
|   "django-filter~=25.1", | ||||
|   "django-guardian~=3.2.0", | ||||
|   "django-guardian~=3.1.2", | ||||
|   "django-multiselectfield~=1.0.1", | ||||
|   "django-soft-delete~=1.0.18", | ||||
|   "django-treenode>=0.23.2", | ||||
| @@ -54,6 +55,7 @@ dependencies = [ | ||||
|   "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", | ||||
| @@ -232,6 +234,7 @@ testpaths = [ | ||||
|   "src/paperless_tesseract/tests/", | ||||
|   "src/paperless_tika/tests", | ||||
|   "src/paperless_text/tests/", | ||||
|   "src/paperless_remote/tests/", | ||||
| ] | ||||
| addopts = [ | ||||
|   "--pythonwarnings=all", | ||||
| @@ -254,6 +257,7 @@ PAPERLESS_DISABLE_DBHANDLER = "true" | ||||
| PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache" | ||||
|  | ||||
| [tool.coverage.run] | ||||
| relative_files = true | ||||
| source = [ | ||||
|   "src/", | ||||
| ] | ||||
|   | ||||
							
								
								
									
										24
									
								
								sonar-project.properties
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								sonar-project.properties
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| sonar.projectKey=paperless-ngx_paperless-ngx | ||||
| sonar.organization=paperless-ngx | ||||
| sonar.projectName=Paperless-ngx | ||||
| sonar.projectVersion=1.0 | ||||
|  | ||||
| # Source and test directories | ||||
| sonar.sources=src/,src-ui/ | ||||
| sonar.test.inclusions=**/test_*.py,**/tests.py,**/*.spec.ts,**/*.test.ts | ||||
|  | ||||
| # Language specific settings | ||||
| sonar.python.version=3.10,3.11,3.12,3.13 | ||||
|  | ||||
| # Coverage reports | ||||
| sonar.python.coverage.reportPaths=merged-backend-coverage.xml | ||||
| sonar.javascript.lcov.reportPaths=coverage/lcov.info | ||||
|  | ||||
| # Test execution reports | ||||
| sonar.junit.reportPaths=**/junit.xml,**/test-results.xml | ||||
|  | ||||
| # Encoding | ||||
| sonar.sourceEncoding=UTF-8 | ||||
|  | ||||
| # Exclusions | ||||
| sonar.exclusions=**/migrations/**,**/node_modules/**,**/static/**,**/venv/**,**/.venv/**,**/dist/** | ||||
| @@ -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: 'None' }).click() | ||||
|   await page.getByRole('button', { name: 'Cancel' }).click() | ||||
|  | ||||
|   await page.locator('pngx-document-card-small').nth(1).click() | ||||
|   await page.locator('pngx-document-card-small').nth(2).click() | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -11,17 +11,17 @@ | ||||
|   }, | ||||
|   "private": true, | ||||
|   "dependencies": { | ||||
|     "@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", | ||||
|     "@angular/cdk": "^20.2.2", | ||||
|     "@angular/common": "~20.2.4", | ||||
|     "@angular/compiler": "~20.2.4", | ||||
|     "@angular/core": "~20.2.4", | ||||
|     "@angular/forms": "~20.2.4", | ||||
|     "@angular/localize": "~20.2.4", | ||||
|     "@angular/platform-browser": "~20.2.4", | ||||
|     "@angular/platform-browser-dynamic": "~20.2.4", | ||||
|     "@angular/router": "~20.2.4", | ||||
|     "@ng-bootstrap/ng-bootstrap": "^19.0.1", | ||||
|     "@ng-select/ng-select": "^20.2.2", | ||||
|     "@ng-select/ng-select": "^20.1.3", | ||||
|     "@ngneat/dirty-check-forms": "^3.0.3", | ||||
|     "@popperjs/core": "^2.11.8", | ||||
|     "bootstrap": "^5.3.8", | ||||
| @@ -29,48 +29,47 @@ | ||||
|     "mime-names": "^1.0.0", | ||||
|     "ng2-pdf-viewer": "^10.4.0", | ||||
|     "ngx-bootstrap-icons": "^1.9.3", | ||||
|     "ngx-color": "^10.1.0", | ||||
|     "ngx-color": "^10.0.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": "^13.0.0", | ||||
|     "uuid": "^11.1.0", | ||||
|     "zone.js": "^0.15.1" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@angular-builders/custom-webpack": "^20.0.0", | ||||
|     "@angular-builders/jest": "^20.0.0", | ||||
|     "@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", | ||||
|     "@angular-devkit/core": "^20.2.2", | ||||
|     "@angular-devkit/schematics": "^20.2.2", | ||||
|     "@angular-eslint/builder": "20.2.0", | ||||
|     "@angular-eslint/eslint-plugin": "20.2.0", | ||||
|     "@angular-eslint/eslint-plugin-template": "20.2.0", | ||||
|     "@angular-eslint/schematics": "20.2.0", | ||||
|     "@angular-eslint/template-parser": "20.2.0", | ||||
|     "@angular/build": "^20.2.2", | ||||
|     "@angular/cli": "~20.2.2", | ||||
|     "@angular/compiler-cli": "~20.2.4", | ||||
|     "@codecov/webpack-plugin": "^1.9.1", | ||||
|     "@playwright/test": "^1.55.1", | ||||
|     "@playwright/test": "^1.55.0", | ||||
|     "@types/jest": "^30.0.0", | ||||
|     "@types/node": "^24.6.1", | ||||
|     "@typescript-eslint/eslint-plugin": "^8.45.0", | ||||
|     "@typescript-eslint/parser": "^8.45.0", | ||||
|     "@typescript-eslint/utils": "^8.45.0", | ||||
|     "eslint": "^9.36.0", | ||||
|     "jest": "30.2.0", | ||||
|     "jest-environment-jsdom": "^30.2.0", | ||||
|     "@types/node": "^24.3.0", | ||||
|     "@typescript-eslint/eslint-plugin": "^8.41.0", | ||||
|     "@typescript-eslint/parser": "^8.41.0", | ||||
|     "@typescript-eslint/utils": "^8.41.0", | ||||
|     "eslint": "^9.34.0", | ||||
|     "jest": "30.1.3", | ||||
|     "jest-environment-jsdom": "^30.1.2", | ||||
|     "jest-junit": "^16.0.0", | ||||
|     "jest-preset-angular": "^15.0.2", | ||||
|     "jest-preset-angular": "^15.0.0", | ||||
|     "jest-websocket-mock": "^2.5.0", | ||||
|     "prettier-plugin-organize-imports": "^4.3.0", | ||||
|     "prettier-plugin-organize-imports": "^4.2.0", | ||||
|     "ts-node": "~10.9.1", | ||||
|     "typescript": "^5.8.3", | ||||
|     "webpack": "^5.102.0" | ||||
|     "webpack": "^5.101.3" | ||||
|   }, | ||||
|   "packageManager": "pnpm@10.17.1", | ||||
|   "pnpm": { | ||||
|     "onlyBuiltDependencies": [ | ||||
|       "@parcel/watcher", | ||||
|   | ||||
							
								
								
									
										3494
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3494
									
								
								src-ui/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -145,14 +145,4 @@ 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') | ||||
|   | ||||
| @@ -16,7 +16,6 @@ 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, | ||||
| @@ -29,7 +28,6 @@ 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' | ||||
| @@ -125,7 +123,6 @@ describe('TasksComponent', () => { | ||||
|   let router: Router | ||||
|   let httpTestingController: HttpTestingController | ||||
|   let reloadSpy | ||||
|   let toastService: ToastService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     TestBed.configureTestingModule({ | ||||
| @@ -160,7 +157,6 @@ 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() | ||||
| @@ -253,42 +249,6 @@ 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,7 +24,6 @@ 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' | ||||
| @@ -73,7 +72,6 @@ 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() | ||||
| @@ -156,19 +154,11 @@ export class TasksComponent | ||||
|       modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { | ||||
|         modal.componentInstance.buttonsEnabled = false | ||||
|         modal.close() | ||||
|         this.tasksService.dismissTasks(tasks).subscribe({ | ||||
|           error: (e) => { | ||||
|             this.toastService.showError($localize`Error dismissing tasks`, e) | ||||
|             modal.componentInstance.buttonsEnabled = true | ||||
|           }, | ||||
|         }) | ||||
|         this.tasksService.dismissTasks(tasks) | ||||
|         this.clearSelection() | ||||
|       }) | ||||
|     } else { | ||||
|       this.tasksService.dismissTasks(tasks).subscribe({ | ||||
|         error: (e) => | ||||
|           this.toastService.showError($localize`Error dismissing task`, e), | ||||
|       }) | ||||
|       this.tasksService.dismissTasks(tasks) | ||||
|       this.clearSelection() | ||||
|     } | ||||
|   } | ||||
|   | ||||
| @@ -41,3 +41,9 @@ | ||||
|     min-width: 140px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .btn-group-xs { | ||||
|   > .btn { | ||||
|     border-radius: 0.15rem; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -177,16 +177,10 @@ export class CustomFieldEditDialogComponent | ||||
|   } | ||||
|  | ||||
|   public removeSelectOption(index: number) { | ||||
|     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) | ||||
|     this.selectOptions.removeAt(index) | ||||
|     this._allSelectOptions.splice( | ||||
|       index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE, | ||||
|       1 | ||||
|     ) | ||||
|     const targetPage = Math.min(this.selectOptionsPage, totalPages) | ||||
|  | ||||
|     this.selectOptionsPage = targetPage | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,18 +1,19 @@ | ||||
| <div class="mb-3"> | ||||
|   @if (title) { | ||||
|     <label class="form-label" [for]="inputId">{{title}}</label> | ||||
|     <label [for]="inputId">{{title}}</label> | ||||
|   } | ||||
|  | ||||
|   <div class="input-group" [class.is-invalid]="error"> | ||||
|     <button type="button" class="input-group-text" [style.background-color]="value" (click)="colorPicker.toggle()">   </button> | ||||
|     <span class="input-group-text" [style.background-color]="value">   </span> | ||||
|  | ||||
|     <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" #colorPicker="ngbPopover" placement="bottom" popoverClass="shadow"> | ||||
|     <input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" 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: HTMLButtonElement = fixture.nativeElement.querySelector( | ||||
|       'button.input-group-text' | ||||
|     const swatch: HTMLSpanElement = fixture.nativeElement.querySelector( | ||||
|       'span.input-group-text' | ||||
|     ) | ||||
|     expect(swatch.style.backgroundColor).toEqual('') | ||||
|     component.value = '#ff0000' | ||||
|   | ||||
| @@ -1212,7 +1212,7 @@ describe('DocumentDetailComponent', () => { | ||||
|   it('should support keyboard shortcuts', () => { | ||||
|     initNormally() | ||||
|  | ||||
|     const hasNextSpy = jest.spyOn(component, 'hasNext').mockReturnValue(true) | ||||
|     jest.spyOn(component, 'hasNext').mockReturnValue(true) | ||||
|     const nextSpy = jest.spyOn(component, 'nextDoc') | ||||
|     document.dispatchEvent( | ||||
|       new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true }) | ||||
| @@ -1226,32 +1226,21 @@ describe('DocumentDetailComponent', () => { | ||||
|     ) | ||||
|     expect(prevSpy).toHaveBeenCalled() | ||||
|  | ||||
|     const isDirtySpy = jest | ||||
|       .spyOn(openDocumentsService, 'isDirty') | ||||
|       .mockReturnValue(true) | ||||
|     jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true) | ||||
|     const saveSpy = jest.spyOn(component, 'save') | ||||
|     document.dispatchEvent( | ||||
|       new KeyboardEvent('keydown', { key: 's', ctrlKey: true }) | ||||
|     ) | ||||
|     expect(saveSpy).toHaveBeenCalled() | ||||
|  | ||||
|     hasNextSpy.mockReturnValue(true) | ||||
|     jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true) | ||||
|     jest.spyOn(component, 'hasNext').mockReturnValue(true) | ||||
|     const saveNextSpy = jest.spyOn(component, 'saveEditNext') | ||||
|     document.dispatchEvent( | ||||
|       new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true }) | ||||
|     ) | ||||
|     expect(saveNextSpy).toHaveBeenCalled() | ||||
|  | ||||
|     saveSpy.mockClear() | ||||
|     saveNextSpy.mockClear() | ||||
|     isDirtySpy.mockReturnValue(true) | ||||
|     hasNextSpy.mockReturnValue(false) | ||||
|     document.dispatchEvent( | ||||
|       new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true }) | ||||
|     ) | ||||
|     expect(saveNextSpy).not.toHaveBeenCalled() | ||||
|     expect(saveSpy).toHaveBeenCalledWith(true) | ||||
|  | ||||
|     const closeSpy = jest.spyOn(component, 'close') | ||||
|     document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' })) | ||||
|     expect(closeSpy).toHaveBeenCalled() | ||||
|   | ||||
| @@ -615,10 +615,7 @@ export class DocumentDetailComponent | ||||
|       }) | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe(() => { | ||||
|         if (this.openDocumentService.isDirty(this.document)) { | ||||
|           if (this.hasNext()) this.saveEditNext() | ||||
|           else this.save(true) | ||||
|         } | ||||
|         if (this.openDocumentService.isDirty(this.document)) this.saveEditNext() | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,144 +1,161 @@ | ||||
| <div class="d-flex flex-wrap gap-4"> | ||||
|   <div class="d-flex flex-wrap align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> | ||||
|     <label class="me-2" i18n>Edit:</label> | ||||
|     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) { | ||||
|       <pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title | ||||
|         filterPlaceholder="Filter tags" i18n-filterPlaceholder | ||||
|         [disabled]="!userCanEditAll || disabled" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         [createRef]="createTag.bind(this)" | ||||
|         (opened)="openTagsDropdown()" | ||||
|         [(selectionModel)]="tagSelectionModel" | ||||
|         [documentCounts]="tagDocumentCounts" | ||||
|         (apply)="setTags($event)" | ||||
|         shortcutKey="t"> | ||||
|       </pngx-filterable-dropdown> | ||||
|     } | ||||
|     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { | ||||
|       <pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title | ||||
|         filterPlaceholder="Filter correspondents" i18n-filterPlaceholder | ||||
|         [disabled]="!userCanEditAll || disabled" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         [createRef]="createCorrespondent.bind(this)" | ||||
|         (opened)="openCorrespondentDropdown()" | ||||
|         [(selectionModel)]="correspondentSelectionModel" | ||||
|         [documentCounts]="correspondentDocumentCounts" | ||||
|         (apply)="setCorrespondents($event)" | ||||
|         shortcutKey="y"> | ||||
|       </pngx-filterable-dropdown> | ||||
|     } | ||||
|     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) { | ||||
|       <pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title | ||||
|         filterPlaceholder="Filter document types" i18n-filterPlaceholder | ||||
|         [disabled]="!userCanEditAll || disabled" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         [createRef]="createDocumentType.bind(this)" | ||||
|         (opened)="openDocumentTypeDropdown()" | ||||
|         [(selectionModel)]="documentTypeSelectionModel" | ||||
|         [documentCounts]="documentTypeDocumentCounts" | ||||
|         (apply)="setDocumentTypes($event)" | ||||
|         shortcutKey="u"> | ||||
|       </pngx-filterable-dropdown> | ||||
|     } | ||||
|     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) { | ||||
|       <pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title | ||||
|         filterPlaceholder="Filter storage paths" i18n-filterPlaceholder | ||||
|         [disabled]="!userCanEditAll || disabled" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         [createRef]="createStoragePath.bind(this)" | ||||
|         (opened)="openStoragePathDropdown()" | ||||
|         [(selectionModel)]="storagePathsSelectionModel" | ||||
|         [documentCounts]="storagePathDocumentCounts" | ||||
|         (apply)="setStoragePaths($event)" | ||||
|         shortcutKey="i"> | ||||
|       </pngx-filterable-dropdown> | ||||
|     } | ||||
|     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) { | ||||
|       <pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title | ||||
|         filterPlaceholder="Filter custom fields" i18n-filterPlaceholder | ||||
|         [disabled]="!userCanEditAll" | ||||
|         [editing]="true" | ||||
|         [applyOnClose]="applyOnClose" | ||||
|         [createRef]="createCustomField.bind(this)" | ||||
|         (opened)="openCustomFieldsDropdown()" | ||||
|         [(selectionModel)]="customFieldsSelectionModel" | ||||
|         [documentCounts]="customFieldDocumentCounts" | ||||
|         extraButtonTitle="Set values" | ||||
|         i18n-extraButtonTitle | ||||
|         (extraButton)="setCustomFieldValues($event)" | ||||
|         (apply)="setCustomFields($event)"> | ||||
|       </pngx-filterable-dropdown> | ||||
|     } | ||||
|     <div class="btn-group"> | ||||
|       <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll"> | ||||
|         <i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div> | ||||
|   <div class="d-flex align-items-center" role="group" aria-label="Select"> | ||||
|     <button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()"> | ||||
|       <i-bs name="slash-circle"></i-bs> <ng-container i18n>Cancel</ng-container> | ||||
|       </button> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="d-flex align-items-center gap-2 ms-auto"> | ||||
|     <div class="btn-toolbar"> | ||||
|       <div ngbDropdown> | ||||
|         <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle> | ||||
|           <i-bs name="three-dots"></i-bs> | ||||
|           <div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div> | ||||
|         </button> | ||||
|         <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|           <button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll"> | ||||
|             <i-bs name="body-text"></i-bs> <ng-container i18n>Reprocess</ng-container> | ||||
|     <div class="d-flex align-items-center gap-2" role="group" aria-label="Select"> | ||||
|       <label class="me-2" i18n>Select:</label> | ||||
|       <div class="btn-group"> | ||||
|         <button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()"> | ||||
|           <i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container> | ||||
|           </button> | ||||
|           <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll"> | ||||
|             <i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container> | ||||
|           </button> | ||||
|           <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2"> | ||||
|             <i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container> | ||||
|           </button> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class="btn-group btn-group-sm"> | ||||
|       <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()"> | ||||
|         @if (!awaitingDownload) { | ||||
|           <i-bs name="arrow-down"></i-bs> | ||||
|         } | ||||
|         @if (awaitingDownload) { | ||||
|           <div class="spinner-border spinner-border-sm" role="status"> | ||||
|             <span class="visually-hidden">Preparing download...</span> | ||||
|           <button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()"> | ||||
|             <i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container> | ||||
|             </button> | ||||
|           </div> | ||||
|         } | ||||
|         <div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div> | ||||
|       </button> | ||||
|       <div ngbDropdown class="me-2 d-flex btn-group" role="group"> | ||||
|         <button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button> | ||||
|         <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|           <form [formGroup]="downloadForm" class="px-3 py-1"> | ||||
|             <p class="mb-1" i18n>Include:</p> | ||||
|             <div class="form-group ps-3 mb-2"> | ||||
|               <div class="form-check"> | ||||
|                 <input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" /> | ||||
|                 <label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label> | ||||
|               </div> | ||||
|               <div class="form-check"> | ||||
|                 <input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" /> | ||||
|                 <label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div class="form-check"> | ||||
|               <input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" /> | ||||
|               <label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label> | ||||
|             </div> | ||||
|           </form> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|         <div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> | ||||
|           <label class="me-2" i18n>Edit:</label> | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) { | ||||
|             <pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title | ||||
|               filterPlaceholder="Filter tags" i18n-filterPlaceholder | ||||
|               [disabled]="!userCanEditAll || disabled" | ||||
|               [editing]="true" | ||||
|               [applyOnClose]="applyOnClose" | ||||
|               [createRef]="createTag.bind(this)" | ||||
|               (opened)="openTagsDropdown()" | ||||
|               [(selectionModel)]="tagSelectionModel" | ||||
|               [documentCounts]="tagDocumentCounts" | ||||
|               (apply)="setTags($event)" | ||||
|               shortcutKey="t"> | ||||
|             </pngx-filterable-dropdown> | ||||
|           } | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { | ||||
|             <pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title | ||||
|               filterPlaceholder="Filter correspondents" i18n-filterPlaceholder | ||||
|               [disabled]="!userCanEditAll || disabled" | ||||
|               [editing]="true" | ||||
|               [applyOnClose]="applyOnClose" | ||||
|               [createRef]="createCorrespondent.bind(this)" | ||||
|               (opened)="openCorrespondentDropdown()" | ||||
|               [(selectionModel)]="correspondentSelectionModel" | ||||
|               [documentCounts]="correspondentDocumentCounts" | ||||
|               (apply)="setCorrespondents($event)" | ||||
|               shortcutKey="y"> | ||||
|             </pngx-filterable-dropdown> | ||||
|           } | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) { | ||||
|             <pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title | ||||
|               filterPlaceholder="Filter document types" i18n-filterPlaceholder | ||||
|               [disabled]="!userCanEditAll || disabled" | ||||
|               [editing]="true" | ||||
|               [applyOnClose]="applyOnClose" | ||||
|               [createRef]="createDocumentType.bind(this)" | ||||
|               (opened)="openDocumentTypeDropdown()" | ||||
|               [(selectionModel)]="documentTypeSelectionModel" | ||||
|               [documentCounts]="documentTypeDocumentCounts" | ||||
|               (apply)="setDocumentTypes($event)" | ||||
|               shortcutKey="u"> | ||||
|             </pngx-filterable-dropdown> | ||||
|           } | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) { | ||||
|             <pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title | ||||
|               filterPlaceholder="Filter storage paths" i18n-filterPlaceholder | ||||
|               [disabled]="!userCanEditAll || disabled" | ||||
|               [editing]="true" | ||||
|               [applyOnClose]="applyOnClose" | ||||
|               [createRef]="createStoragePath.bind(this)" | ||||
|               (opened)="openStoragePathDropdown()" | ||||
|               [(selectionModel)]="storagePathsSelectionModel" | ||||
|               [documentCounts]="storagePathDocumentCounts" | ||||
|               (apply)="setStoragePaths($event)" | ||||
|               shortcutKey="i"> | ||||
|             </pngx-filterable-dropdown> | ||||
|           } | ||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) { | ||||
|             <pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title | ||||
|               filterPlaceholder="Filter custom fields" i18n-filterPlaceholder | ||||
|               [disabled]="!userCanEditAll" | ||||
|               [editing]="true" | ||||
|               [applyOnClose]="applyOnClose" | ||||
|               [createRef]="createCustomField.bind(this)" | ||||
|               (opened)="openCustomFieldsDropdown()" | ||||
|               [(selectionModel)]="customFieldsSelectionModel" | ||||
|               [documentCounts]="customFieldDocumentCounts" | ||||
|               extraButtonTitle="Set values" | ||||
|               i18n-extraButtonTitle | ||||
|               (extraButton)="setCustomFieldValues($event)" | ||||
|               (apply)="setCustomFields($event)"> | ||||
|             </pngx-filterable-dropdown> | ||||
|           } | ||||
|         </div> | ||||
|         <div class="d-flex align-items-center gap-2 ms-auto"> | ||||
|           <div class="btn-toolbar"> | ||||
|  | ||||
|     <div class="btn-group btn-group-sm"> | ||||
|       <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll"> | ||||
|         <i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container> | ||||
|       </button> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
|             <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll"> | ||||
|               <i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div> | ||||
|             </button> | ||||
|  | ||||
|             <div ngbDropdown> | ||||
|               <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle> | ||||
|                 <i-bs name="three-dots"></i-bs> | ||||
|                 <div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div> | ||||
|               </button> | ||||
|               <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|                 <button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll"> | ||||
|                   <i-bs name="body-text"></i-bs> <ng-container i18n>Reprocess</ng-container> | ||||
|                 </button> | ||||
|                 <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll"> | ||||
|                   <i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container> | ||||
|                 </button> | ||||
|                 <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2"> | ||||
|                   <i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container> | ||||
|                 </button> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|             <div class="btn-group btn-group-sm"> | ||||
|               <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()"> | ||||
|                 @if (!awaitingDownload) { | ||||
|                   <i-bs name="arrow-down"></i-bs> | ||||
|                 } | ||||
|                 @if (awaitingDownload) { | ||||
|                   <div class="spinner-border spinner-border-sm" role="status"> | ||||
|                     <span class="visually-hidden">Preparing download...</span> | ||||
|                   </div> | ||||
|                 } | ||||
|                 <div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div> | ||||
|               </button> | ||||
|               <div ngbDropdown class="me-2 d-flex btn-group" role="group"> | ||||
|                 <button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button> | ||||
|                 <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|                   <form [formGroup]="downloadForm" class="px-3 py-1"> | ||||
|                     <p class="mb-1" i18n>Include:</p> | ||||
|                     <div class="form-group ps-3 mb-2"> | ||||
|                       <div class="form-check"> | ||||
|                         <input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" /> | ||||
|                         <label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label> | ||||
|                       </div> | ||||
|                       <div class="form-check"> | ||||
|                         <input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" /> | ||||
|                         <label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label> | ||||
|                       </div> | ||||
|                     </div> | ||||
|                     <div class="form-check"> | ||||
|                       <input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" /> | ||||
|                       <label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label> | ||||
|                     </div> | ||||
|                   </form> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="btn-group btn-group-sm"> | ||||
|               <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll"> | ||||
|                 <i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container> | ||||
|                 </button> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|   | ||||
| @@ -5,7 +5,3 @@ | ||||
| .dropdown-menu{ | ||||
|     --bs-dropdown-min-width: 12rem; | ||||
| } | ||||
|  | ||||
| .btn-group .btn { | ||||
|   white-space: nowrap; | ||||
| } | ||||
|   | ||||
| @@ -1,36 +1,16 @@ | ||||
| <pngx-page-header [title]="getTitle()"> | ||||
|   <div ngbDropdown class="btn-group flex-fill d-sm-none"> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle> | ||||
|  | ||||
|   <div ngbDropdown class="btn-group flex-fill"> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle> | ||||
|       <i-bs name="text-indent-left"></i-bs> | ||||
|       <div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div> | ||||
|       @if (list.selected.size > 0) { | ||||
|         <pngx-clearable-badge [selected]="list.selected.size > 0" [number]="list.selected.size" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span> | ||||
|       } | ||||
|     </button> | ||||
|     <div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow"> | ||||
|     <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|       <button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button> | ||||
|       <button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button> | ||||
|       <button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="d-none d-sm-flex flex-fill me-3"> | ||||
|     <div class="input-group input-group-sm"> | ||||
|       <span class="input-group-text border-0">Select:</span> | ||||
|     </div> | ||||
|     <div class="btn-group btn-group-sm flex-nowrap"> | ||||
|       @if (list.selected.size > 0) { | ||||
|         <button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()"> | ||||
|           <i-bs name="slash-circle"></i-bs> <ng-container i18n>None</ng-container> | ||||
|         </button> | ||||
|       } | ||||
|       <button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()"> | ||||
|         <i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container> | ||||
|       </button> | ||||
|       <button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()"> | ||||
|         <i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container> | ||||
|       </button> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div ngbDropdown class="btn-group flex-fill"> | ||||
|     <button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle> | ||||
|       <i-bs name="card-heading"></i-bs> | ||||
| @@ -146,13 +126,8 @@ | ||||
|       @if (!list.isReloading && isFiltered) { | ||||
|         <button class="btn btn-link py-0" (click)="resetFilters()"> | ||||
|           <i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small> | ||||
|         </button> | ||||
|       } | ||||
|       @if (!list.isReloading && list.selected.size > 0) { | ||||
|         <button class="btn btn-link py-0" (click)="list.selectNone()"> | ||||
|           <i-bs width="1em" height="1em" name="slash-circle" class="me-1"></i-bs><small i18n>Clear selection</small> | ||||
|         </button> | ||||
|       } | ||||
|           </button> | ||||
|         } | ||||
|       </div> | ||||
|       @if (list.collectionSize) { | ||||
|         <ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5" | ||||
|   | ||||
| @@ -56,7 +56,6 @@ import { | ||||
|   filterRulesDiffer, | ||||
|   isFullTextFilterRule, | ||||
| } from 'src/app/utils/filter-rules' | ||||
| import { ClearableBadgeComponent } from '../common/clearable-badge/clearable-badge.component' | ||||
| import { CustomFieldDisplayComponent } from '../common/custom-field-display/custom-field-display.component' | ||||
| import { PageHeaderComponent } from '../common/page-header/page-header.component' | ||||
| import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component' | ||||
| @@ -73,7 +72,6 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi | ||||
|   templateUrl: './document-list.component.html', | ||||
|   styleUrls: ['./document-list.component.scss'], | ||||
|   imports: [ | ||||
|     ClearableBadgeComponent, | ||||
|     CustomFieldDisplayComponent, | ||||
|     PageHeaderComponent, | ||||
|     BulkEditorComponent, | ||||
|   | ||||
| @@ -51,7 +51,7 @@ describe('TasksService', () => { | ||||
|   }) | ||||
|  | ||||
|   it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => { | ||||
|     tasksService.dismissTasks(new Set([1, 2, 3])).subscribe() | ||||
|     tasksService.dismissTasks(new Set([1, 2, 3])) | ||||
|     const req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}tasks/acknowledge/` | ||||
|     ) | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { HttpClient } from '@angular/common/http' | ||||
| import { Injectable, inject } from '@angular/core' | ||||
| import { Observable, Subject } from 'rxjs' | ||||
| import { first, takeUntil, tap } from 'rxjs/operators' | ||||
| import { first, takeUntil } from 'rxjs/operators' | ||||
| import { | ||||
|   PaperlessTask, | ||||
|   PaperlessTaskName, | ||||
| @@ -68,17 +68,14 @@ export class TasksService { | ||||
|   } | ||||
|  | ||||
|   public dismissTasks(task_ids: Set<number>) { | ||||
|     return this.http | ||||
|     this.http | ||||
|       .post(`${this.baseUrl}tasks/acknowledge/`, { | ||||
|         tasks: [...task_ids], | ||||
|       }) | ||||
|       .pipe( | ||||
|         first(), | ||||
|         takeUntil(this.unsubscribeNotifer), | ||||
|         tap(() => { | ||||
|           this.reload() | ||||
|         }) | ||||
|       ) | ||||
|       .pipe(first()) | ||||
|       .subscribe((r) => { | ||||
|         this.reload() | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   public cancelPending(): void { | ||||
|   | ||||
| @@ -164,9 +164,6 @@ class BarcodePlugin(ConsumeTaskPlugin): | ||||
|                         mailrule_id=self.input_doc.mailrule_id, | ||||
|                         # Can't use same folder or the consume might grab it again | ||||
|                         original_file=(tmp_dir / new_document.name).resolve(), | ||||
|                         # Adding optional original_path for later uses in | ||||
|                         # workflow matching | ||||
|                         original_path=self.input_doc.original_file, | ||||
|                     ), | ||||
|                     # All the same metadata | ||||
|                     self.metadata, | ||||
|   | ||||
| @@ -156,7 +156,6 @@ class ConsumableDocument: | ||||
|  | ||||
|     source: DocumentSource | ||||
|     original_file: Path | ||||
|     original_path: Path | None = None | ||||
|     mailrule_id: int | None = None | ||||
|     mime_type: str = dataclasses.field(init=False, default=None) | ||||
|  | ||||
|   | ||||
| @@ -92,9 +92,6 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand): | ||||
|                 # doc to doc is obviously not useful | ||||
|                 if first_doc.pk == second_doc.pk: | ||||
|                     continue | ||||
|                 # Skip empty documents (e.g. password-protected) | ||||
|                 if first_doc.content.strip() == "" or second_doc.content.strip() == "": | ||||
|                     continue | ||||
|                 # Skip matching which have already been matched together | ||||
|                 # doc 1 to doc 2 is the same as doc 2 to doc 1 | ||||
|                 doc_1_to_doc_2 = (first_doc.pk, second_doc.pk) | ||||
|   | ||||
| @@ -314,19 +314,11 @@ def consumable_document_matches_workflow( | ||||
|         trigger_matched = False | ||||
|  | ||||
|     # Document path vs trigger path | ||||
|  | ||||
|     # Use the original_path if set, else us the original_file | ||||
|     match_against = ( | ||||
|         document.original_path | ||||
|         if document.original_path is not None | ||||
|         else document.original_file | ||||
|     ) | ||||
|  | ||||
|     if ( | ||||
|         trigger.filter_path is not None | ||||
|         and len(trigger.filter_path) > 0 | ||||
|         and not fnmatch( | ||||
|             match_against, | ||||
|             document.original_file, | ||||
|             trigger.filter_path, | ||||
|         ) | ||||
|     ): | ||||
|   | ||||
| @@ -161,21 +161,3 @@ class PaperlessNotePermissions(BasePermission): | ||||
|         perms = self.perms_map[request.method] | ||||
|  | ||||
|         return request.user.has_perms(perms) | ||||
|  | ||||
|  | ||||
| class AcknowledgeTasksPermissions(BasePermission): | ||||
|     """ | ||||
|     Permissions class that checks for model permissions for acknowledging tasks. | ||||
|     """ | ||||
|  | ||||
|     perms_map = { | ||||
|         "POST": ["documents.change_paperlesstask"], | ||||
|     } | ||||
|  | ||||
|     def has_permission(self, request, view): | ||||
|         if not request.user or not request.user.is_authenticated:  # pragma: no cover | ||||
|             return False | ||||
|  | ||||
|         perms = self.perms_map.get(request.method, []) | ||||
|  | ||||
|         return request.user.has_perms(perms) | ||||
|   | ||||
| @@ -76,9 +76,7 @@ def check_sanity(*, progress=False, scheduled=True) -> SanityCheckMessages: | ||||
|     messages = SanityCheckMessages() | ||||
|  | ||||
|     present_files = { | ||||
|         x.resolve() | ||||
|         for x in Path(settings.MEDIA_ROOT).glob("**/*") | ||||
|         if not x.is_dir() and x.name not in settings.IGNORABLE_FILES | ||||
|         x.resolve() for x in Path(settings.MEDIA_ROOT).glob("**/*") if not x.is_dir() | ||||
|     } | ||||
|  | ||||
|     lockfile = Path(settings.MEDIA_LOCK).resolve() | ||||
|   | ||||
| @@ -6,7 +6,6 @@ import re | ||||
| from datetime import datetime | ||||
| from decimal import Decimal | ||||
| from typing import TYPE_CHECKING | ||||
| from typing import Literal | ||||
|  | ||||
| import magic | ||||
| from celery import states | ||||
| @@ -253,35 +252,6 @@ class OwnedObjectSerializer( | ||||
|             except KeyError: | ||||
|                 pass | ||||
|  | ||||
|     def _get_perms(self, obj, codename: str, target: Literal["users", "groups"]): | ||||
|         """ | ||||
|         Get the given permissions from context or from django-guardian. | ||||
|  | ||||
|         :param codename: The permission codename, e.g. 'view' or 'change' | ||||
|         :param target: 'users' or 'groups' | ||||
|         """ | ||||
|         key = f"{target}_{codename}_perms" | ||||
|         cached = self.context.get(key, {}).get(obj.pk) | ||||
|         if cached is not None: | ||||
|             return list(cached) | ||||
|  | ||||
|         # Permission not found in the context, get it from guardian | ||||
|         if target == "users": | ||||
|             return list( | ||||
|                 get_users_with_perms( | ||||
|                     obj, | ||||
|                     only_with_perms_in=[f"{codename}_{obj.__class__.__name__.lower()}"], | ||||
|                     with_group_users=False, | ||||
|                 ).values_list("id", flat=True), | ||||
|             ) | ||||
|         else:  # groups | ||||
|             return list( | ||||
|                 get_groups_with_only_permission( | ||||
|                     obj, | ||||
|                     codename=f"{codename}_{obj.__class__.__name__.lower()}", | ||||
|                 ).values_list("id", flat=True), | ||||
|             ) | ||||
|  | ||||
|     @extend_schema_field( | ||||
|         field={ | ||||
|             "type": "object", | ||||
| @@ -316,14 +286,31 @@ class OwnedObjectSerializer( | ||||
|         }, | ||||
|     ) | ||||
|     def get_permissions(self, obj) -> dict: | ||||
|         view_codename = f"view_{obj.__class__.__name__.lower()}" | ||||
|         change_codename = f"change_{obj.__class__.__name__.lower()}" | ||||
|  | ||||
|         return { | ||||
|             "view": { | ||||
|                 "users": self._get_perms(obj, "view", "users"), | ||||
|                 "groups": self._get_perms(obj, "view", "groups"), | ||||
|                 "users": get_users_with_perms( | ||||
|                     obj, | ||||
|                     only_with_perms_in=[view_codename], | ||||
|                     with_group_users=False, | ||||
|                 ).values_list("id", flat=True), | ||||
|                 "groups": get_groups_with_only_permission( | ||||
|                     obj, | ||||
|                     codename=view_codename, | ||||
|                 ).values_list("id", flat=True), | ||||
|             }, | ||||
|             "change": { | ||||
|                 "users": self._get_perms(obj, "change", "users"), | ||||
|                 "groups": self._get_perms(obj, "change", "groups"), | ||||
|                 "users": get_users_with_perms( | ||||
|                     obj, | ||||
|                     only_with_perms_in=[change_codename], | ||||
|                     with_group_users=False, | ||||
|                 ).values_list("id", flat=True), | ||||
|                 "groups": get_groups_with_only_permission( | ||||
|                     obj, | ||||
|                     codename=change_codename, | ||||
|                 ).values_list("id", flat=True), | ||||
|             }, | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -135,44 +135,6 @@ class TestTasks(DirectoriesMixin, APITestCase): | ||||
|         response = self.client.get(self.ENDPOINT + "?acknowledged=false") | ||||
|         self.assertEqual(len(response.data), 0) | ||||
|  | ||||
|     def test_acknowledge_tasks_requires_change_permission(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - A regular user initially without change permissions | ||||
|             - A regular user with change permissions | ||||
|         WHEN: | ||||
|             - API call is made to acknowledge tasks | ||||
|         THEN: | ||||
|             - The first user is forbidden from acknowledging tasks | ||||
|             - The second user is allowed to acknowledge tasks | ||||
|         """ | ||||
|         regular_user = User.objects.create_user(username="test") | ||||
|         self.client.force_authenticate(user=regular_user) | ||||
|  | ||||
|         task = PaperlessTask.objects.create( | ||||
|             task_id=str(uuid.uuid4()), | ||||
|             task_file_name="task_one.pdf", | ||||
|         ) | ||||
|  | ||||
|         response = self.client.post( | ||||
|             self.ENDPOINT + "acknowledge/", | ||||
|             {"tasks": [task.id]}, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) | ||||
|  | ||||
|         regular_user2 = User.objects.create_user(username="test2") | ||||
|         regular_user2.user_permissions.add( | ||||
|             Permission.objects.get(codename="change_paperlesstask"), | ||||
|         ) | ||||
|         regular_user2.save() | ||||
|         self.client.force_authenticate(user=regular_user2) | ||||
|  | ||||
|         response = self.client.post( | ||||
|             self.ENDPOINT + "acknowledge/", | ||||
|             {"tasks": [task.id]}, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|  | ||||
|     def test_tasks_owner_aware(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|   | ||||
| @@ -614,16 +614,14 @@ class TestBarcodeNewConsume( | ||||
|             self.assertIsNotFile(temp_copy) | ||||
|  | ||||
|             # Check the split files exist | ||||
|             # Check the original_path is set | ||||
|             # Check the source is unchanged | ||||
|             # Check the overrides are unchanged | ||||
|             for ( | ||||
|                 new_input_doc, | ||||
|                 new_doc_overrides, | ||||
|             ) in self.get_all_consume_delay_call_args(): | ||||
|                 self.assertIsFile(new_input_doc.original_file) | ||||
|                 self.assertEqual(new_input_doc.original_path, temp_copy) | ||||
|                 self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder) | ||||
|                 self.assertIsFile(new_input_doc.original_file) | ||||
|                 self.assertEqual(overrides, new_doc_overrides) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -206,29 +206,3 @@ class TestFuzzyMatchCommand(TestCase): | ||||
|         self.assertEqual(Document.objects.count(), 2) | ||||
|         self.assertIsNotNone(Document.objects.get(pk=1)) | ||||
|         self.assertIsNotNone(Document.objects.get(pk=2)) | ||||
|  | ||||
|     def test_empty_content(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - 2 documents exist, content is empty (pw-protected) | ||||
|         WHEN: | ||||
|             - Command is called | ||||
|         THEN: | ||||
|             - No matches are found | ||||
|         """ | ||||
|         Document.objects.create( | ||||
|             checksum="BEEFCAFE", | ||||
|             title="A", | ||||
|             content="", | ||||
|             mime_type="application/pdf", | ||||
|             filename="test.pdf", | ||||
|         ) | ||||
|         Document.objects.create( | ||||
|             checksum="DEADBEAF", | ||||
|             title="A", | ||||
|             content="", | ||||
|             mime_type="application/pdf", | ||||
|             filename="other_test.pdf", | ||||
|         ) | ||||
|         stdout, _ = self.call_command() | ||||
|         self.assertIn("No matches found", stdout) | ||||
|   | ||||
| @@ -169,13 +169,6 @@ class TestSanityCheck(DirectoriesMixin, TestCase): | ||||
|         messages = check_sanity() | ||||
|         self.assertFalse(messages.has_warning) | ||||
|  | ||||
|     def test_ignore_ignorable_files(self): | ||||
|         self.make_test_data() | ||||
|         Path(self.dirs.media_dir, ".DS_Store").touch() | ||||
|         Path(self.dirs.media_dir, "desktop.ini").touch() | ||||
|         messages = check_sanity() | ||||
|         self.assertFalse(messages.has_warning) | ||||
|  | ||||
|     def test_archive_filename_no_checksum(self): | ||||
|         doc = self.make_test_data() | ||||
|         doc.archive_checksum = None | ||||
|   | ||||
| @@ -1,23 +1,17 @@ | ||||
| import json | ||||
| import tempfile | ||||
| from datetime import timedelta | ||||
| from pathlib import Path | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.contrib.auth.models import Group | ||||
| from django.contrib.auth.models import Permission | ||||
| from django.contrib.auth.models import User | ||||
| from django.db import connection | ||||
| from django.test import TestCase | ||||
| from django.test import override_settings | ||||
| from django.test.utils import CaptureQueriesContext | ||||
| from django.utils import timezone | ||||
| from guardian.shortcuts import assign_perm | ||||
| from rest_framework import status | ||||
|  | ||||
| from documents.models import Document | ||||
| from documents.models import ShareLink | ||||
| from documents.models import Tag | ||||
| from documents.tests.utils import DirectoriesMixin | ||||
| from paperless.models import ApplicationConfiguration | ||||
|  | ||||
| @@ -160,113 +154,3 @@ class TestViews(DirectoriesMixin, TestCase): | ||||
|         response.render() | ||||
|         self.assertEqual(response.request["PATH_INFO"], "/accounts/login/") | ||||
|         self.assertContains(response, b"Share link has expired") | ||||
|  | ||||
|     def test_list_with_full_permissions(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Tags with different permissions | ||||
|         WHEN: | ||||
|             - Request to get tag list with full permissions is made | ||||
|         THEN: | ||||
|             - Tag list is returned with the right permission information | ||||
|         """ | ||||
|         user2 = User.objects.create(username="user2") | ||||
|         user3 = User.objects.create(username="user3") | ||||
|         group1 = Group.objects.create(name="group1") | ||||
|         group2 = Group.objects.create(name="group2") | ||||
|         group3 = Group.objects.create(name="group3") | ||||
|         t1 = Tag.objects.create(name="invoice", pk=1) | ||||
|         assign_perm("view_tag", self.user, t1) | ||||
|         assign_perm("view_tag", user2, t1) | ||||
|         assign_perm("view_tag", user3, t1) | ||||
|         assign_perm("view_tag", group1, t1) | ||||
|         assign_perm("view_tag", group2, t1) | ||||
|         assign_perm("view_tag", group3, t1) | ||||
|         assign_perm("change_tag", self.user, t1) | ||||
|         assign_perm("change_tag", user2, t1) | ||||
|         assign_perm("change_tag", group1, t1) | ||||
|         assign_perm("change_tag", group2, t1) | ||||
|  | ||||
|         Tag.objects.create(name="bank statement", pk=2) | ||||
|         d1 = Document.objects.create( | ||||
|             title="Invoice 1", | ||||
|             content="This is the invoice of a very expensive item", | ||||
|             checksum="A", | ||||
|         ) | ||||
|         d1.tags.add(t1) | ||||
|         d2 = Document.objects.create( | ||||
|             title="Invoice 2", | ||||
|             content="Internet invoice, I should pay it to continue contributing", | ||||
|             checksum="B", | ||||
|         ) | ||||
|         d2.tags.add(t1) | ||||
|  | ||||
|         view_permissions = Permission.objects.filter( | ||||
|             codename__contains="view_tag", | ||||
|         ) | ||||
|         self.user.user_permissions.add(*view_permissions) | ||||
|         self.user.save() | ||||
|  | ||||
|         self.client.force_login(self.user) | ||||
|         response = self.client.get("/api/tags/?page=1&full_perms=true") | ||||
|         results = json.loads(response.content)["results"] | ||||
|         for tag in results: | ||||
|             if tag["name"] == "invoice": | ||||
|                 assert tag["permissions"] == { | ||||
|                     "view": { | ||||
|                         "users": [self.user.pk, user2.pk, user3.pk], | ||||
|                         "groups": [group1.pk, group2.pk, group3.pk], | ||||
|                     }, | ||||
|                     "change": { | ||||
|                         "users": [self.user.pk, user2.pk], | ||||
|                         "groups": [group1.pk, group2.pk], | ||||
|                     }, | ||||
|                 } | ||||
|             elif tag["name"] == "bank statement": | ||||
|                 assert tag["permissions"] == { | ||||
|                     "view": {"users": [], "groups": []}, | ||||
|                     "change": {"users": [], "groups": []}, | ||||
|                 } | ||||
|             else: | ||||
|                 assert False, f"Unexpected tag found: {tag['name']}" | ||||
|  | ||||
|     def test_list_no_n_plus_1_queries(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Tags with different permissions | ||||
|         WHEN: | ||||
|             - Request to get tag list with full permissions is made | ||||
|         THEN: | ||||
|             - Permissions are not queried in database tag by tag, | ||||
|              i.e. there are no N+1 queries | ||||
|         """ | ||||
|         view_permissions = Permission.objects.filter( | ||||
|             codename__contains="view_tag", | ||||
|         ) | ||||
|         self.user.user_permissions.add(*view_permissions) | ||||
|         self.user.save() | ||||
|         self.client.force_login(self.user) | ||||
|  | ||||
|         # Start by a small list, and count the number of SQL queries | ||||
|         for i in range(2): | ||||
|             Tag.objects.create(name=f"tag_{i}") | ||||
|  | ||||
|         with CaptureQueriesContext(connection) as ctx_small: | ||||
|             response_small = self.client.get("/api/tags/?full_perms=true") | ||||
|             assert response_small.status_code == 200 | ||||
|         num_queries_small = len(ctx_small.captured_queries) | ||||
|  | ||||
|         # Complete the list, and count the number of SQL queries again | ||||
|         for i in range(2, 50): | ||||
|             Tag.objects.create(name=f"tag_{i}") | ||||
|  | ||||
|         with CaptureQueriesContext(connection) as ctx_large: | ||||
|             response_large = self.client.get("/api/tags/?full_perms=true") | ||||
|             assert response_large.status_code == 200 | ||||
|         num_queries_large = len(ctx_large.captured_queries) | ||||
|  | ||||
|         # A few additional queries are allowed, but not a linear explosion | ||||
|         assert num_queries_large <= num_queries_small + 5, ( | ||||
|             f"Possible N+1 queries detected: {num_queries_small} queries for 2 tags, " | ||||
|             f"but {num_queries_large} queries for 50 tags" | ||||
|         ) | ||||
|   | ||||
| @@ -5,11 +5,9 @@ import platform | ||||
| import re | ||||
| import tempfile | ||||
| import zipfile | ||||
| from collections import defaultdict | ||||
| from datetime import datetime | ||||
| from pathlib import Path | ||||
| from time import mktime | ||||
| from typing import Literal | ||||
| from unicodedata import normalize | ||||
| from urllib.parse import quote | ||||
| from urllib.parse import urlparse | ||||
| @@ -21,7 +19,6 @@ from celery import states | ||||
| from django.conf import settings | ||||
| from django.contrib.auth.models import Group | ||||
| from django.contrib.auth.models import User | ||||
| from django.contrib.contenttypes.models import ContentType | ||||
| from django.db import connections | ||||
| from django.db.migrations.loader import MigrationLoader | ||||
| from django.db.migrations.recorder import MigrationRecorder | ||||
| @@ -59,8 +56,6 @@ from drf_spectacular.utils import OpenApiParameter | ||||
| from drf_spectacular.utils import extend_schema | ||||
| from drf_spectacular.utils import extend_schema_view | ||||
| from drf_spectacular.utils import inline_serializer | ||||
| from guardian.utils import get_group_obj_perms_model | ||||
| from guardian.utils import get_user_obj_perms_model | ||||
| from langdetect import detect | ||||
| from packaging import version as packaging_version | ||||
| from redis import Redis | ||||
| @@ -136,7 +131,6 @@ from documents.models import WorkflowAction | ||||
| from documents.models import WorkflowTrigger | ||||
| from documents.parsers import get_parser_class_for_mime_type | ||||
| from documents.parsers import parse_date_generator | ||||
| from documents.permissions import AcknowledgeTasksPermissions | ||||
| from documents.permissions import PaperlessAdminPermissions | ||||
| from documents.permissions import PaperlessNotePermissions | ||||
| from documents.permissions import PaperlessObjectPermissions | ||||
| @@ -260,104 +254,7 @@ class PassUserMixin(GenericAPIView): | ||||
|         return super().get_serializer(*args, **kwargs) | ||||
|  | ||||
|  | ||||
| class BulkPermissionMixin: | ||||
|     """ | ||||
|     Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries. | ||||
|     """ | ||||
|  | ||||
|     def _get_object_perms( | ||||
|         self, | ||||
|         objects: list, | ||||
|         perm_codenames: list[str], | ||||
|         actor: Literal["users", "groups"], | ||||
|     ) -> dict[int, dict[str, list[int]]]: | ||||
|         """ | ||||
|         Collect object-level permissions for either users or groups. | ||||
|         """ | ||||
|         model = self.queryset.model | ||||
|         obj_perm_model = ( | ||||
|             get_user_obj_perms_model(model) | ||||
|             if actor == "users" | ||||
|             else get_group_obj_perms_model(model) | ||||
|         ) | ||||
|         id_field = "user_id" if actor == "users" else "group_id" | ||||
|         ctype = ContentType.objects.get_for_model(model) | ||||
|         object_pks = [obj.pk for obj in objects] | ||||
|  | ||||
|         perms_qs = obj_perm_model.objects.filter( | ||||
|             content_type=ctype, | ||||
|             object_pk__in=object_pks, | ||||
|             permission__codename__in=perm_codenames, | ||||
|         ).values_list("object_pk", id_field, "permission__codename") | ||||
|  | ||||
|         perms: dict[int, dict[str, list[int]]] = defaultdict(lambda: defaultdict(list)) | ||||
|         for object_pk, actor_id, codename in perms_qs: | ||||
|             perms[int(object_pk)][codename].append(actor_id) | ||||
|  | ||||
|         # Ensure that all objects have all codenames, even if empty | ||||
|         for pk in object_pks: | ||||
|             for codename in perm_codenames: | ||||
|                 perms[pk][codename] | ||||
|  | ||||
|         return perms | ||||
|  | ||||
|     def get_serializer_context(self): | ||||
|         """ | ||||
|         Get all permissions of the current list of objects at once and pass them to the serializer. | ||||
|         This avoid fetching permissions object by object in database. | ||||
|         """ | ||||
|         context = super().get_serializer_context() | ||||
|         try: | ||||
|             full_perms = get_boolean( | ||||
|                 str(self.request.query_params.get("full_perms", "false")), | ||||
|             ) | ||||
|         except ValueError: | ||||
|             full_perms = False | ||||
|  | ||||
|         if not full_perms: | ||||
|             return context | ||||
|  | ||||
|         # Check which objects are being paginated | ||||
|         page = getattr(self, "paginator", None) | ||||
|         if page and hasattr(page, "page"): | ||||
|             queryset = page.page.object_list | ||||
|         elif hasattr(self, "page"): | ||||
|             queryset = self.page | ||||
|         else: | ||||
|             queryset = self.filter_queryset(self.get_queryset()) | ||||
|  | ||||
|         model_name = self.queryset.model.__name__.lower() | ||||
|         permission_name_view = f"view_{model_name}" | ||||
|         permission_name_change = f"change_{model_name}" | ||||
|  | ||||
|         user_perms = self._get_object_perms( | ||||
|             objects=queryset, | ||||
|             perm_codenames=[permission_name_view, permission_name_change], | ||||
|             actor="users", | ||||
|         ) | ||||
|         group_perms = self._get_object_perms( | ||||
|             objects=queryset, | ||||
|             perm_codenames=[permission_name_view, permission_name_change], | ||||
|             actor="groups", | ||||
|         ) | ||||
|  | ||||
|         context["users_view_perms"] = { | ||||
|             pk: user_perms[pk][permission_name_view] for pk in user_perms | ||||
|         } | ||||
|         context["users_change_perms"] = { | ||||
|             pk: user_perms[pk][permission_name_change] for pk in user_perms | ||||
|         } | ||||
|         context["groups_view_perms"] = { | ||||
|             pk: group_perms[pk][permission_name_view] for pk in group_perms | ||||
|         } | ||||
|         context["groups_change_perms"] = { | ||||
|             pk: group_perms[pk][permission_name_change] for pk in group_perms | ||||
|         } | ||||
|  | ||||
|         return context | ||||
|  | ||||
|  | ||||
| class PermissionsAwareDocumentCountMixin(BulkPermissionMixin, PassUserMixin): | ||||
| class PermissionsAwareDocumentCountMixin(PassUserMixin): | ||||
|     """ | ||||
|     Mixin to add document count to queryset, permissions-aware if needed | ||||
|     """ | ||||
| @@ -2488,11 +2385,7 @@ class TasksViewSet(ReadOnlyModelViewSet): | ||||
|             queryset = PaperlessTask.objects.filter(task_id=task_id) | ||||
|         return queryset | ||||
|  | ||||
|     @action( | ||||
|         methods=["post"], | ||||
|         detail=False, | ||||
|         permission_classes=[IsAuthenticated, AcknowledgeTasksPermissions], | ||||
|     ) | ||||
|     @action(methods=["post"], detail=False) | ||||
|     def acknowledge(self, request): | ||||
|         serializer = AcknowledgeTasksViewSerializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|   | ||||
| @@ -2,7 +2,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: paperless-ngx\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2025-09-30 16:50+0000\n" | ||||
| "POT-Creation-Date: 2025-09-22 18:20+0000\n" | ||||
| "PO-Revision-Date: 2022-02-17 04:17\n" | ||||
| "Last-Translator: \n" | ||||
| "Language-Team: English\n" | ||||
| @@ -1191,44 +1191,44 @@ msgstr "" | ||||
| msgid "workflow runs" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:141 | ||||
| #: documents/serialisers.py:140 | ||||
| #, python-format | ||||
| msgid "Invalid regular expression: %(error)s" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:607 | ||||
| #: documents/serialisers.py:594 | ||||
| msgid "Invalid color." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:636 | ||||
| #: documents/serialisers.py:623 | ||||
| msgid "Invalid parent tag." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1793 | ||||
| #: documents/serialisers.py:1780 | ||||
| #, python-format | ||||
| msgid "File type %(type)s not supported" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1837 | ||||
| #: documents/serialisers.py:1824 | ||||
| #, python-format | ||||
| msgid "Custom field id must be an integer: %(id)s" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1844 | ||||
| #: documents/serialisers.py:1831 | ||||
| #, python-format | ||||
| msgid "Custom field with id %(id)s does not exist" | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1861 documents/serialisers.py:1871 | ||||
| #: documents/serialisers.py:1848 documents/serialisers.py:1858 | ||||
| msgid "" | ||||
| "Custom fields must be a list of integers or an object mapping ids to values." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1866 | ||||
| #: documents/serialisers.py:1853 | ||||
| msgid "Some custom fields don't exist or were specified twice." | ||||
| msgstr "" | ||||
|  | ||||
| #: documents/serialisers.py:1936 | ||||
| #: documents/serialisers.py:1923 | ||||
| msgid "Invalid variable detected." | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -322,6 +322,7 @@ INSTALLED_APPS = [ | ||||
|     "paperless_tesseract.apps.PaperlessTesseractConfig", | ||||
|     "paperless_text.apps.PaperlessTextConfig", | ||||
|     "paperless_mail.apps.PaperlessMailConfig", | ||||
|     "paperless_remote.apps.PaperlessRemoteParserConfig", | ||||
|     "django.contrib.admin", | ||||
|     "rest_framework", | ||||
|     "rest_framework.authtoken", | ||||
| @@ -1003,18 +1004,6 @@ THREADS_PER_WORKER = os.getenv( | ||||
| # Paperless Specific Settings                                                 # | ||||
| ############################################################################### | ||||
|  | ||||
| IGNORABLE_FILES: Final[list[str]] = [ | ||||
|     ".DS_Store", | ||||
|     ".DS_STORE", | ||||
|     "._*", | ||||
|     ".stfolder/*", | ||||
|     ".stversions/*", | ||||
|     ".localized/*", | ||||
|     "desktop.ini", | ||||
|     "@eaDir/*", | ||||
|     "Thumbs.db", | ||||
| ] | ||||
|  | ||||
| CONSUMER_POLLING = int(os.getenv("PAPERLESS_CONSUMER_POLLING", 0)) | ||||
|  | ||||
| CONSUMER_POLLING_DELAY = int(os.getenv("PAPERLESS_CONSUMER_POLLING_DELAY", 5)) | ||||
| @@ -1037,7 +1026,7 @@ CONSUMER_IGNORE_PATTERNS = list( | ||||
|     json.loads( | ||||
|         os.getenv( | ||||
|             "PAPERLESS_CONSUMER_IGNORE_PATTERNS", | ||||
|             json.dumps(IGNORABLE_FILES), | ||||
|             '[".DS_Store", ".DS_STORE", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini", "@eaDir/*", "Thumbs.db"]', | ||||
|         ), | ||||
|     ), | ||||
| ) | ||||
| @@ -1401,3 +1390,10 @@ WEBHOOKS_ALLOW_INTERNAL_REQUESTS = __get_boolean( | ||||
|     "PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS", | ||||
|     "true", | ||||
| ) | ||||
|  | ||||
| ############################################################################### | ||||
| # Remote Parser                                                               # | ||||
| ############################################################################### | ||||
| REMOTE_OCR_ENGINE = os.getenv("PAPERLESS_REMOTE_OCR_ENGINE") | ||||
| REMOTE_OCR_API_KEY = os.getenv("PAPERLESS_REMOTE_OCR_API_KEY") | ||||
| REMOTE_OCR_ENDPOINT = os.getenv("PAPERLESS_REMOTE_OCR_ENDPOINT") | ||||
|   | ||||
							
								
								
									
										4
									
								
								src/paperless_remote/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/paperless_remote/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| # this is here so that django finds the checks. | ||||
| from paperless_remote.checks import check_remote_parser_configured | ||||
|  | ||||
| __all__ = ["check_remote_parser_configured"] | ||||
							
								
								
									
										14
									
								
								src/paperless_remote/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/paperless_remote/apps.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| from django.apps import AppConfig | ||||
|  | ||||
| from paperless_remote.signals import remote_consumer_declaration | ||||
|  | ||||
|  | ||||
| class PaperlessRemoteParserConfig(AppConfig): | ||||
|     name = "paperless_remote" | ||||
|  | ||||
|     def ready(self): | ||||
|         from documents.signals import document_consumer_declaration | ||||
|  | ||||
|         document_consumer_declaration.connect(remote_consumer_declaration) | ||||
|  | ||||
|         AppConfig.ready(self) | ||||
							
								
								
									
										17
									
								
								src/paperless_remote/checks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/paperless_remote/checks.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| from django.conf import settings | ||||
| from django.core.checks import Error | ||||
| from django.core.checks import register | ||||
|  | ||||
|  | ||||
| @register() | ||||
| def check_remote_parser_configured(app_configs, **kwargs): | ||||
|     if settings.REMOTE_OCR_ENGINE == "azureai" and not ( | ||||
|         settings.REMOTE_OCR_ENDPOINT and settings.REMOTE_OCR_API_KEY | ||||
|     ): | ||||
|         return [ | ||||
|             Error( | ||||
|                 "Azure AI remote parser requires endpoint and API key to be configured.", | ||||
|             ), | ||||
|         ] | ||||
|  | ||||
|     return [] | ||||
							
								
								
									
										113
									
								
								src/paperless_remote/parsers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								src/paperless_remote/parsers.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,113 @@ | ||||
| from pathlib import Path | ||||
|  | ||||
| from django.conf import settings | ||||
|  | ||||
| from paperless_tesseract.parsers import RasterisedDocumentParser | ||||
|  | ||||
|  | ||||
| class RemoteEngineConfig: | ||||
|     def __init__( | ||||
|         self, | ||||
|         engine: str, | ||||
|         api_key: str | None = None, | ||||
|         endpoint: str | None = None, | ||||
|     ): | ||||
|         self.engine = engine | ||||
|         self.api_key = api_key | ||||
|         self.endpoint = endpoint | ||||
|  | ||||
|     def engine_is_valid(self): | ||||
|         valid = self.engine in ["azureai"] and self.api_key is not None | ||||
|         if self.engine == "azureai": | ||||
|             valid = valid and self.endpoint is not None | ||||
|         return valid | ||||
|  | ||||
|  | ||||
| class RemoteDocumentParser(RasterisedDocumentParser): | ||||
|     """ | ||||
|     This parser uses a remote OCR engine to parse documents. Currently, it supports Azure AI Vision | ||||
|     as this is the only service that provides a remote OCR API with text-embedded PDF output. | ||||
|     """ | ||||
|  | ||||
|     logging_name = "paperless.parsing.remote" | ||||
|  | ||||
|     def get_settings(self) -> RemoteEngineConfig: | ||||
|         """ | ||||
|         Returns the configuration for the remote OCR engine, loaded from Django settings. | ||||
|         """ | ||||
|         return RemoteEngineConfig( | ||||
|             engine=settings.REMOTE_OCR_ENGINE, | ||||
|             api_key=settings.REMOTE_OCR_API_KEY, | ||||
|             endpoint=settings.REMOTE_OCR_ENDPOINT, | ||||
|         ) | ||||
|  | ||||
|     def supported_mime_types(self): | ||||
|         if self.settings.engine_is_valid(): | ||||
|             return { | ||||
|                 "application/pdf": ".pdf", | ||||
|                 "image/png": ".png", | ||||
|                 "image/jpeg": ".jpg", | ||||
|                 "image/tiff": ".tiff", | ||||
|                 "image/bmp": ".bmp", | ||||
|                 "image/gif": ".gif", | ||||
|                 "image/webp": ".webp", | ||||
|             } | ||||
|         else: | ||||
|             return {} | ||||
|  | ||||
|     def azure_ai_vision_parse( | ||||
|         self, | ||||
|         file: Path, | ||||
|     ) -> str | None: | ||||
|         """ | ||||
|         Uses Azure AI Vision to parse the document and return the text content. | ||||
|         It requests a searchable PDF output with embedded text. | ||||
|         The PDF is saved to the archive_path attribute. | ||||
|         Returns the text content extracted from the document. | ||||
|         If the parsing fails, it returns None. | ||||
|         """ | ||||
|         from azure.ai.documentintelligence import DocumentIntelligenceClient | ||||
|         from azure.ai.documentintelligence.models import AnalyzeDocumentRequest | ||||
|         from azure.ai.documentintelligence.models import AnalyzeOutputOption | ||||
|         from azure.ai.documentintelligence.models import DocumentContentFormat | ||||
|         from azure.core.credentials import AzureKeyCredential | ||||
|  | ||||
|         client = DocumentIntelligenceClient( | ||||
|             endpoint=self.settings.endpoint, | ||||
|             credential=AzureKeyCredential(self.settings.api_key), | ||||
|         ) | ||||
|  | ||||
|         with file.open("rb") as f: | ||||
|             analyze_request = AnalyzeDocumentRequest(bytes_source=f.read()) | ||||
|             poller = client.begin_analyze_document( | ||||
|                 model_id="prebuilt-read", | ||||
|                 body=analyze_request, | ||||
|                 output_content_format=DocumentContentFormat.TEXT, | ||||
|                 output=[AnalyzeOutputOption.PDF],  # request searchable PDF output | ||||
|                 content_type="application/json", | ||||
|             ) | ||||
|  | ||||
|         poller.wait() | ||||
|         result_id = poller.details["operation_id"] | ||||
|         result = poller.result() | ||||
|  | ||||
|         # Download the PDF with embedded text | ||||
|         self.archive_path = self.tempdir / "archive.pdf" | ||||
|         with self.archive_path.open("wb") as f: | ||||
|             for chunk in client.get_analyze_result_pdf( | ||||
|                 model_id="prebuilt-read", | ||||
|                 result_id=result_id, | ||||
|             ): | ||||
|                 f.write(chunk) | ||||
|  | ||||
|         client.close() | ||||
|         return result.content | ||||
|  | ||||
|     def parse(self, document_path: Path, mime_type, file_name=None): | ||||
|         if not self.settings.engine_is_valid(): | ||||
|             self.log.warning( | ||||
|                 "No valid remote parser engine is configured, content will be empty.", | ||||
|             ) | ||||
|             self.text = "" | ||||
|         elif self.settings.engine == "azureai": | ||||
|             self.text = self.azure_ai_vision_parse(document_path) | ||||
							
								
								
									
										18
									
								
								src/paperless_remote/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/paperless_remote/signals.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| def get_parser(*args, **kwargs): | ||||
|     from paperless_remote.parsers import RemoteDocumentParser | ||||
|  | ||||
|     return RemoteDocumentParser(*args, **kwargs) | ||||
|  | ||||
|  | ||||
| def get_supported_mime_types(): | ||||
|     from paperless_remote.parsers import RemoteDocumentParser | ||||
|  | ||||
|     return RemoteDocumentParser(None).supported_mime_types() | ||||
|  | ||||
|  | ||||
| def remote_consumer_declaration(sender, **kwargs): | ||||
|     return { | ||||
|         "parser": get_parser, | ||||
|         "weight": 5, | ||||
|         "mime_types": get_supported_mime_types(), | ||||
|     } | ||||
							
								
								
									
										0
									
								
								src/paperless_remote/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/paperless_remote/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										
											BIN
										
									
								
								src/paperless_remote/tests/samples/simple-digital.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/paperless_remote/tests/samples/simple-digital.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										24
									
								
								src/paperless_remote/tests/test_checks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/paperless_remote/tests/test_checks.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| from unittest import TestCase | ||||
|  | ||||
| from django.test import override_settings | ||||
|  | ||||
| from paperless_remote import check_remote_parser_configured | ||||
|  | ||||
|  | ||||
| class TestChecks(TestCase): | ||||
|     @override_settings(REMOTE_OCR_ENGINE=None) | ||||
|     def test_no_engine(self): | ||||
|         msgs = check_remote_parser_configured(None) | ||||
|         self.assertEqual(len(msgs), 0) | ||||
|  | ||||
|     @override_settings(REMOTE_OCR_ENGINE="azureai") | ||||
|     @override_settings(REMOTE_OCR_API_KEY="somekey") | ||||
|     @override_settings(REMOTE_OCR_ENDPOINT=None) | ||||
|     def test_azure_no_endpoint(self): | ||||
|         msgs = check_remote_parser_configured(None) | ||||
|         self.assertEqual(len(msgs), 1) | ||||
|         self.assertTrue( | ||||
|             msgs[0].msg.startswith( | ||||
|                 "Azure AI remote parser requires endpoint and API key to be configured.", | ||||
|             ), | ||||
|         ) | ||||
							
								
								
									
										101
									
								
								src/paperless_remote/tests/test_parser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								src/paperless_remote/tests/test_parser.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,101 @@ | ||||
| import uuid | ||||
| from pathlib import Path | ||||
| from unittest import mock | ||||
|  | ||||
| from django.test import TestCase | ||||
| from django.test import override_settings | ||||
|  | ||||
| from documents.tests.utils import DirectoriesMixin | ||||
| from documents.tests.utils import FileSystemAssertsMixin | ||||
| from paperless_remote.parsers import RemoteDocumentParser | ||||
| from paperless_remote.signals import get_parser | ||||
|  | ||||
|  | ||||
| class TestParser(DirectoriesMixin, FileSystemAssertsMixin, TestCase): | ||||
|     SAMPLE_FILES = Path(__file__).resolve().parent / "samples" | ||||
|  | ||||
|     def assertContainsStrings(self, content: str, strings: list[str]): | ||||
|         # Asserts that all strings appear in content, in the given order. | ||||
|         indices = [] | ||||
|         for s in strings: | ||||
|             if s in content: | ||||
|                 indices.append(content.index(s)) | ||||
|             else: | ||||
|                 self.fail(f"'{s}' is not in '{content}'") | ||||
|         self.assertListEqual(indices, sorted(indices)) | ||||
|  | ||||
|     @mock.patch("paperless_tesseract.parsers.run_subprocess") | ||||
|     @mock.patch("azure.ai.documentintelligence.DocumentIntelligenceClient") | ||||
|     def test_get_text_with_azure(self, mock_client_cls, mock_subprocess): | ||||
|         # Arrange mock Azure client | ||||
|         mock_client = mock.Mock() | ||||
|         mock_client_cls.return_value = mock_client | ||||
|  | ||||
|         # Simulate poller result and its `.details` | ||||
|         mock_poller = mock.Mock() | ||||
|         mock_poller.wait.return_value = None | ||||
|         mock_poller.details = {"operation_id": "fake-op-id"} | ||||
|         mock_client.begin_analyze_document.return_value = mock_poller | ||||
|         mock_poller.result.return_value.content = "This is a test document." | ||||
|  | ||||
|         # Return dummy PDF bytes | ||||
|         mock_client.get_analyze_result_pdf.return_value = [ | ||||
|             b"%PDF-", | ||||
|             b"1.7 ", | ||||
|             b"FAKEPDF", | ||||
|         ] | ||||
|  | ||||
|         # Simulate pdftotext by writing dummy text to sidecar file | ||||
|         def fake_run(cmd, *args, **kwargs): | ||||
|             with Path(cmd[-1]).open("w", encoding="utf-8") as f: | ||||
|                 f.write("This is a test document.") | ||||
|  | ||||
|         mock_subprocess.side_effect = fake_run | ||||
|  | ||||
|         with override_settings( | ||||
|             REMOTE_OCR_ENGINE="azureai", | ||||
|             REMOTE_OCR_API_KEY="somekey", | ||||
|             REMOTE_OCR_ENDPOINT="https://endpoint.cognitiveservices.azure.com", | ||||
|         ): | ||||
|             parser = get_parser(uuid.uuid4()) | ||||
|             parser.parse( | ||||
|                 self.SAMPLE_FILES / "simple-digital.pdf", | ||||
|                 "application/pdf", | ||||
|             ) | ||||
|  | ||||
|             self.assertContainsStrings( | ||||
|                 parser.text.strip(), | ||||
|                 ["This is a test document."], | ||||
|             ) | ||||
|  | ||||
|     @override_settings( | ||||
|         REMOTE_OCR_ENGINE="azureai", | ||||
|         REMOTE_OCR_API_KEY="key", | ||||
|         REMOTE_OCR_ENDPOINT="https://endpoint.cognitiveservices.azure.com", | ||||
|     ) | ||||
|     def test_supported_mime_types_valid_config(self): | ||||
|         parser = RemoteDocumentParser(uuid.uuid4()) | ||||
|         expected_types = { | ||||
|             "application/pdf": ".pdf", | ||||
|             "image/png": ".png", | ||||
|             "image/jpeg": ".jpg", | ||||
|             "image/tiff": ".tiff", | ||||
|             "image/bmp": ".bmp", | ||||
|             "image/gif": ".gif", | ||||
|             "image/webp": ".webp", | ||||
|         } | ||||
|         self.assertEqual(parser.supported_mime_types(), expected_types) | ||||
|  | ||||
|     def test_supported_mime_types_invalid_config(self): | ||||
|         parser = get_parser(uuid.uuid4()) | ||||
|         self.assertEqual(parser.supported_mime_types(), {}) | ||||
|  | ||||
|     @override_settings( | ||||
|         REMOTE_OCR_ENGINE=None, | ||||
|         REMOTE_OCR_API_KEY=None, | ||||
|         REMOTE_OCR_ENDPOINT=None, | ||||
|     ) | ||||
|     def test_parse_with_invalid_config(self): | ||||
|         parser = get_parser(uuid.uuid4()) | ||||
|         parser.parse(self.SAMPLE_FILES / "simple-digital.pdf", "application/pdf") | ||||
|         self.assertEqual(parser.text, "") | ||||
		Reference in New Issue
	
	Block a user