mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Merge branch 'dev'
This commit is contained in:
		| @@ -1,6 +1,6 @@ | |||||||
| { | { | ||||||
|   "qpdf": { |   "qpdf": { | ||||||
|       "version": "11.1.1" |       "version": "11.2.0" | ||||||
|     }, |     }, | ||||||
|   "jbig2enc": { |   "jbig2enc": { | ||||||
|       "version": "0.29", |       "version": "0.29", | ||||||
|   | |||||||
							
								
								
									
										12
									
								
								.github/release-drafter.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.github/release-drafter.yml
									
									
									
									
										vendored
									
									
								
							| @@ -4,6 +4,7 @@ autolabeler: | |||||||
|       - '/^fix/' |       - '/^fix/' | ||||||
|     title: |     title: | ||||||
|       - "/^fix/i" |       - "/^fix/i" | ||||||
|  |       - "/^Bugfix/i" | ||||||
|   - label: "enhancement" |   - label: "enhancement" | ||||||
|     branch: |     branch: | ||||||
|       - '/^feature/' |       - '/^feature/' | ||||||
| @@ -13,6 +14,9 @@ categories: | |||||||
|   - title: 'Breaking Changes' |   - title: 'Breaking Changes' | ||||||
|     labels: |     labels: | ||||||
|       - 'breaking-change' |       - 'breaking-change' | ||||||
|  |   - title: 'Notable Changes' | ||||||
|  |     labels: | ||||||
|  |       - 'notable' | ||||||
|   - title: 'Features' |   - title: 'Features' | ||||||
|     labels: |     labels: | ||||||
|       - 'enhancement' |       - 'enhancement' | ||||||
| @@ -20,7 +24,8 @@ categories: | |||||||
|     labels: |     labels: | ||||||
|       - 'bug' |       - 'bug' | ||||||
|   - title: 'Documentation' |   - title: 'Documentation' | ||||||
|     label: 'documentation' |     labels: | ||||||
|  |       - 'documentation' | ||||||
|   - title: 'Maintenance' |   - title: 'Maintenance' | ||||||
|     labels: |     labels: | ||||||
|       - 'chore' |       - 'chore' | ||||||
| @@ -29,7 +34,8 @@ categories: | |||||||
|       - 'ci-cd' |       - 'ci-cd' | ||||||
|   - title: 'Dependencies' |   - title: 'Dependencies' | ||||||
|     collapse-after: 3 |     collapse-after: 3 | ||||||
|     label: 'dependencies' |     labels: | ||||||
|  |       - 'dependencies' | ||||||
|   - title: 'All App Changes' |   - title: 'All App Changes' | ||||||
|     labels: |     labels: | ||||||
|       - 'frontend' |       - 'frontend' | ||||||
| @@ -46,6 +52,8 @@ include-labels: | |||||||
|   - 'frontend' |   - 'frontend' | ||||||
|   - 'backend' |   - 'backend' | ||||||
|   - 'ci-cd' |   - 'ci-cd' | ||||||
|  |   - 'breaking-change' | ||||||
|  |   - 'notable' | ||||||
| category-template: '### $TITLE' | category-template: '### $TITLE' | ||||||
| change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))' | change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))' | ||||||
| change-title-escapes: '\<*_&#@' | change-title-escapes: '\<*_&#@' | ||||||
|   | |||||||
							
								
								
									
										29
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										29
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -16,7 +16,7 @@ on: | |||||||
| jobs: | jobs: | ||||||
|   pre-commit: |   pre-commit: | ||||||
|     name: Linting Checks |     name: Linting Checks | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-22.04 | ||||||
|     steps: |     steps: | ||||||
|       - |       - | ||||||
|         name: Checkout repository |         name: Checkout repository | ||||||
| @@ -34,7 +34,7 @@ jobs: | |||||||
|  |  | ||||||
|   documentation: |   documentation: | ||||||
|     name: "Build Documentation" |     name: "Build Documentation" | ||||||
|     runs-on: ubuntu-20.04 |     runs-on: ubuntu-22.04 | ||||||
|     needs: |     needs: | ||||||
|       - pre-commit |       - pre-commit | ||||||
|     steps: |     steps: | ||||||
| @@ -44,7 +44,7 @@ jobs: | |||||||
|       - |       - | ||||||
|         name: Install pipenv |         name: Install pipenv | ||||||
|         run: | |         run: | | ||||||
|           pipx install pipenv==2022.10.12 |           pipx install pipenv==2022.11.30 | ||||||
|       - |       - | ||||||
|         name: Set up Python |         name: Set up Python | ||||||
|         uses: actions/setup-python@v4 |         uses: actions/setup-python@v4 | ||||||
| @@ -73,7 +73,7 @@ jobs: | |||||||
|  |  | ||||||
|   documentation-deploy: |   documentation-deploy: | ||||||
|     name: "Deploy Documentation" |     name: "Deploy Documentation" | ||||||
|     runs-on: ubuntu-20.04 |     runs-on: ubuntu-22.04 | ||||||
|     if: github.event_name == 'push' && github.ref == 'refs/heads/main' |     if: github.event_name == 'push' && github.ref == 'refs/heads/main' | ||||||
|     needs: |     needs: | ||||||
|       - documentation |       - documentation | ||||||
| @@ -92,7 +92,7 @@ jobs: | |||||||
|  |  | ||||||
|   tests-backend: |   tests-backend: | ||||||
|     name: "Tests (${{ matrix.python-version }})" |     name: "Tests (${{ matrix.python-version }})" | ||||||
|     runs-on: ubuntu-20.04 |     runs-on: ubuntu-22.04 | ||||||
|     needs: |     needs: | ||||||
|       - pre-commit |       - pre-commit | ||||||
|     strategy: |     strategy: | ||||||
| @@ -106,6 +106,10 @@ jobs: | |||||||
|       PAPERLESS_MAIL_TEST_HOST: ${{ secrets.TEST_MAIL_HOST }} |       PAPERLESS_MAIL_TEST_HOST: ${{ secrets.TEST_MAIL_HOST }} | ||||||
|       PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }} |       PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }} | ||||||
|       PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }} |       PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }} | ||||||
|  |       # Skip Tests which require convert | ||||||
|  |       PAPERLESS_TEST_SKIP_CONVERT: 1 | ||||||
|  |       # Enable Gotenberg end to end testing | ||||||
|  |       GOTENBERG_LIVE: 1 | ||||||
|     steps: |     steps: | ||||||
|       - |       - | ||||||
|         name: Checkout |         name: Checkout | ||||||
| @@ -120,7 +124,7 @@ jobs: | |||||||
|       - |       - | ||||||
|         name: Install pipenv |         name: Install pipenv | ||||||
|         run: | |         run: | | ||||||
|           pipx install pipenv==2022.10.12 |           pipx install pipenv==2022.11.30 | ||||||
|       - |       - | ||||||
|         name: Set up Python |         name: Set up Python | ||||||
|         uses: actions/setup-python@v4 |         uses: actions/setup-python@v4 | ||||||
| @@ -177,7 +181,7 @@ jobs: | |||||||
|  |  | ||||||
|   tests-frontend: |   tests-frontend: | ||||||
|     name: "Tests Frontend" |     name: "Tests Frontend" | ||||||
|     runs-on: ubuntu-20.04 |     runs-on: ubuntu-22.04 | ||||||
|     needs: |     needs: | ||||||
|       - pre-commit |       - pre-commit | ||||||
|     strategy: |     strategy: | ||||||
| @@ -191,13 +195,14 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           node-version: ${{ matrix.node-version }} |           node-version: ${{ matrix.node-version }} | ||||||
|       - run: cd src-ui && npm ci |       - run: cd src-ui && npm ci | ||||||
|  |       - run: cd src-ui && npm run lint | ||||||
|       - run: cd src-ui && npm run test |       - run: cd src-ui && npm run test | ||||||
|       - run: cd src-ui && npm run e2e:ci |       - run: cd src-ui && npm run e2e:ci | ||||||
|  |  | ||||||
|   prepare-docker-build: |   prepare-docker-build: | ||||||
|     name: Prepare Docker Pipeline Data |     name: Prepare Docker Pipeline Data | ||||||
|     if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v')) |     if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v')) | ||||||
|     runs-on: ubuntu-20.04 |     runs-on: ubuntu-22.04 | ||||||
|     # If the push triggered the installer library workflow, wait for it to |     # If the push triggered the installer library workflow, wait for it to | ||||||
|     # complete here.  This ensures the required versions for the final |     # complete here.  This ensures the required versions for the final | ||||||
|     # image have been built, while not waiting at all if the versions haven't changed |     # image have been built, while not waiting at all if the versions haven't changed | ||||||
| @@ -274,7 +279,7 @@ jobs: | |||||||
|  |  | ||||||
|   # build and push image to docker hub. |   # build and push image to docker hub. | ||||||
|   build-docker-image: |   build-docker-image: | ||||||
|     runs-on: ubuntu-20.04 |     runs-on: ubuntu-22.04 | ||||||
|     concurrency: |     concurrency: | ||||||
|       group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }} |       group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }} | ||||||
|       cancel-in-progress: true |       cancel-in-progress: true | ||||||
| @@ -379,7 +384,7 @@ jobs: | |||||||
|   build-release: |   build-release: | ||||||
|     needs: |     needs: | ||||||
|       - build-docker-image |       - build-docker-image | ||||||
|     runs-on: ubuntu-20.04 |     runs-on: ubuntu-22.04 | ||||||
|     steps: |     steps: | ||||||
|       - |       - | ||||||
|         name: Checkout |         name: Checkout | ||||||
| @@ -458,7 +463,7 @@ jobs: | |||||||
|           path: dist/paperless-ngx.tar.xz |           path: dist/paperless-ngx.tar.xz | ||||||
|  |  | ||||||
|   publish-release: |   publish-release: | ||||||
|     runs-on: ubuntu-20.04 |     runs-on: ubuntu-22.04 | ||||||
|     outputs: |     outputs: | ||||||
|       prerelease: ${{ steps.get_version.outputs.prerelease }} |       prerelease: ${{ steps.get_version.outputs.prerelease }} | ||||||
|       changelog: ${{ steps.create-release.outputs.body }} |       changelog: ${{ steps.create-release.outputs.body }} | ||||||
| @@ -507,7 +512,7 @@ jobs: | |||||||
|           asset_content_type: application/x-xz |           asset_content_type: application/x-xz | ||||||
|  |  | ||||||
|   append-changelog: |   append-changelog: | ||||||
|     runs-on: ubuntu-20.04 |     runs-on: ubuntu-22.04 | ||||||
|     needs: |     needs: | ||||||
|       - publish-release |       - publish-release | ||||||
|     if: needs.publish-release.outputs.prerelease == 'false' |     if: needs.publish-release.outputs.prerelease == 'false' | ||||||
|   | |||||||
							
								
								
									
										12
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,17 +1,14 @@ | |||||||
| # This workflow runs on certain conditions to check for and potentially | # This workflow runs on certain conditions to check for and potentially | ||||||
| # delete container images from the GHCR which no longer have an associated | # delete container images from the GHCR which no longer have an associated | ||||||
| # code branch. | # code branch. | ||||||
| # Requires a PAT with the correct scope set in the secrets | # Requires a PAT with the correct scope set in the secrets. | ||||||
|  | # | ||||||
|  | # This workflow will not trigger runs on forked repos. | ||||||
|  |  | ||||||
| name: Cleanup Image Tags | name: Cleanup Image Tags | ||||||
|  |  | ||||||
| on: | on: | ||||||
|   schedule: |  | ||||||
|     - cron: '0 0 * * SAT' |  | ||||||
|   delete: |   delete: | ||||||
|   pull_request: |  | ||||||
|     types: |  | ||||||
|       - closed |  | ||||||
|   push: |   push: | ||||||
|     paths: |     paths: | ||||||
|       - ".github/workflows/cleanup-tags.yml" |       - ".github/workflows/cleanup-tags.yml" | ||||||
| @@ -26,7 +23,8 @@ concurrency: | |||||||
| jobs: | jobs: | ||||||
|   cleanup-images: |   cleanup-images: | ||||||
|     name: Cleanup Image Tags for ${{ matrix.primary-name }} |     name: Cleanup Image Tags for ${{ matrix.primary-name }} | ||||||
|     runs-on: ubuntu-latest |     if: github.repository_owner == 'paperless-ngx' | ||||||
|  |     runs-on: ubuntu-22.04 | ||||||
|     strategy: |     strategy: | ||||||
|       matrix: |       matrix: | ||||||
|         include: |         include: | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							| @@ -23,7 +23,7 @@ on: | |||||||
| jobs: | jobs: | ||||||
|   analyze: |   analyze: | ||||||
|     name: Analyze |     name: Analyze | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-22.04 | ||||||
|     permissions: |     permissions: | ||||||
|       actions: read |       actions: read | ||||||
|       contents: read |       contents: read | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								.github/workflows/installer-library.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/workflows/installer-library.yml
									
									
									
									
										vendored
									
									
								
							| @@ -34,7 +34,7 @@ concurrency: | |||||||
| jobs: | jobs: | ||||||
|   prepare-docker-build: |   prepare-docker-build: | ||||||
|     name: Prepare Docker Image Version Data |     name: Prepare Docker Image Version Data | ||||||
|     runs-on: ubuntu-20.04 |     runs-on: ubuntu-22.04 | ||||||
|     steps: |     steps: | ||||||
|       - |       - | ||||||
|         name: Set ghcr repository name |         name: Set ghcr repository name | ||||||
| @@ -127,6 +127,7 @@ jobs: | |||||||
|     uses: ./.github/workflows/reusable-workflow-builder.yml |     uses: ./.github/workflows/reusable-workflow-builder.yml | ||||||
|     with: |     with: | ||||||
|       dockerfile: ./docker-builders/Dockerfile.qpdf |       dockerfile: ./docker-builders/Dockerfile.qpdf | ||||||
|  |       build-platforms: linux/amd64 | ||||||
|       build-json: ${{ needs.prepare-docker-build.outputs.qpdf-json }} |       build-json: ${{ needs.prepare-docker-build.outputs.qpdf-json }} | ||||||
|       build-args: | |       build-args: | | ||||||
|         QPDF_VERSION=${{ fromJSON(needs.prepare-docker-build.outputs.qpdf-json).version }} |         QPDF_VERSION=${{ fromJSON(needs.prepare-docker-build.outputs.qpdf-json).version }} | ||||||
|   | |||||||
							
								
								
									
										4
									
								
								.github/workflows/project-actions.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/project-actions.yml
									
									
									
									
										vendored
									
									
								
							| @@ -24,7 +24,7 @@ env: | |||||||
| jobs: | jobs: | ||||||
|   issue_opened_or_reopened: |   issue_opened_or_reopened: | ||||||
|     name: issue_opened_or_reopened |     name: issue_opened_or_reopened | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-22.04 | ||||||
|     if: github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'reopened') |     if: github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'reopened') | ||||||
|     steps: |     steps: | ||||||
|       - name: Add issue to project and set status to ${{ env.todo }} |       - name: Add issue to project and set status to ${{ env.todo }} | ||||||
| @@ -37,7 +37,7 @@ jobs: | |||||||
|           status_value: ${{ env.todo }} # Target status |           status_value: ${{ env.todo }} # Target status | ||||||
|   pr_opened_or_reopened: |   pr_opened_or_reopened: | ||||||
|     name: pr_opened_or_reopened |     name: pr_opened_or_reopened | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-22.04 | ||||||
|     permissions: |     permissions: | ||||||
|       # write permission is required for autolabeler |       # write permission is required for autolabeler | ||||||
|       pull-requests: write |       pull-requests: write | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								.github/workflows/release-chart.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/release-chart.yml
									
									
									
									
										vendored
									
									
								
							| @@ -9,7 +9,7 @@ on: | |||||||
| jobs: | jobs: | ||||||
|   release_chart: |   release_chart: | ||||||
|     name: "Release Chart" |     name: "Release Chart" | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-22.04 | ||||||
|     steps: |     steps: | ||||||
|       - name: Checkout |       - name: Checkout | ||||||
|         uses: actions/checkout@v3 |         uses: actions/checkout@v3 | ||||||
|   | |||||||
| @@ -13,6 +13,10 @@ on: | |||||||
|         required: false |         required: false | ||||||
|         default: "" |         default: "" | ||||||
|         type: string |         type: string | ||||||
|  |       build-platforms: | ||||||
|  |         required: false | ||||||
|  |         default: linux/amd64,linux/arm64,linux/arm/v7 | ||||||
|  |         type: string | ||||||
|  |  | ||||||
| concurrency: | concurrency: | ||||||
|   group: ${{ github.workflow }}-${{ fromJSON(inputs.build-json).name }}-${{ fromJSON(inputs.build-json).version }} |   group: ${{ github.workflow }}-${{ fromJSON(inputs.build-json).name }}-${{ fromJSON(inputs.build-json).version }} | ||||||
| @@ -21,7 +25,7 @@ concurrency: | |||||||
| jobs: | jobs: | ||||||
|   build-image: |   build-image: | ||||||
|     name: Build ${{ fromJSON(inputs.build-json).name }} @ ${{ fromJSON(inputs.build-json).version }} |     name: Build ${{ fromJSON(inputs.build-json).name }} @ ${{ fromJSON(inputs.build-json).version }} | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-22.04 | ||||||
|     steps: |     steps: | ||||||
|       - |       - | ||||||
|         name: Checkout |         name: Checkout | ||||||
| @@ -46,7 +50,7 @@ jobs: | |||||||
|           context: . |           context: . | ||||||
|           file: ${{ inputs.dockerfile }} |           file: ${{ inputs.dockerfile }} | ||||||
|           tags: ${{ fromJSON(inputs.build-json).image_tag }} |           tags: ${{ fromJSON(inputs.build-json).image_tag }} | ||||||
|           platforms: linux/amd64,linux/arm64,linux/arm/v7 |           platforms: ${{ inputs.build-platforms }} | ||||||
|           build-args: ${{ inputs.build-args }} |           build-args: ${{ inputs.build-args }} | ||||||
|           push: true |           push: true | ||||||
|           cache-from: type=registry,ref=${{ fromJSON(inputs.build-json).cache_tag }} |           cache-from: type=registry,ref=${{ fromJSON(inputs.build-json).cache_tag }} | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ | |||||||
| repos: | repos: | ||||||
|   # General hooks |   # General hooks | ||||||
|   - repo: https://github.com/pre-commit/pre-commit-hooks |   - repo: https://github.com/pre-commit/pre-commit-hooks | ||||||
|     rev: v4.3.0 |     rev: v4.4.0 | ||||||
|     hooks: |     hooks: | ||||||
|       - id: check-docstring-first |       - id: check-docstring-first | ||||||
|       - id: check-json |       - id: check-json | ||||||
| @@ -48,23 +48,23 @@ repos: | |||||||
|       - id: yesqa |       - id: yesqa | ||||||
|         exclude: "(migrations)" |         exclude: "(migrations)" | ||||||
|   - repo: https://github.com/asottile/add-trailing-comma |   - repo: https://github.com/asottile/add-trailing-comma | ||||||
|     rev: "v2.3.0" |     rev: "v2.4.0" | ||||||
|     hooks: |     hooks: | ||||||
|       - id: add-trailing-comma |       - id: add-trailing-comma | ||||||
|         exclude: "(migrations)" |         exclude: "(migrations)" | ||||||
|   - repo: https://github.com/PyCQA/flake8 |   - repo: https://github.com/PyCQA/flake8 | ||||||
|     rev: 5.0.4 |     rev: 6.0.0 | ||||||
|     hooks: |     hooks: | ||||||
|       - id: flake8 |       - id: flake8 | ||||||
|         files: ^src/ |         files: ^src/ | ||||||
|         args: |         args: | ||||||
|           - "--config=./src/setup.cfg" |           - "--config=./src/setup.cfg" | ||||||
|   - repo: https://github.com/psf/black |   - repo: https://github.com/psf/black | ||||||
|     rev: 22.10.0 |     rev: 22.12.0 | ||||||
|     hooks: |     hooks: | ||||||
|       - id: black |       - id: black | ||||||
|   - repo: https://github.com/asottile/pyupgrade |   - repo: https://github.com/asottile/pyupgrade | ||||||
|     rev: v3.2.2 |     rev: v3.3.1 | ||||||
|     hooks: |     hooks: | ||||||
|       - id: pyupgrade |       - id: pyupgrade | ||||||
|         exclude: "(migrations)" |         exclude: "(migrations)" | ||||||
| @@ -83,6 +83,6 @@ repos: | |||||||
|         args: |         args: | ||||||
|           - "--tab" |           - "--tab" | ||||||
|   - repo: https://github.com/shellcheck-py/shellcheck-py |   - repo: https://github.com/shellcheck-py/shellcheck-py | ||||||
|     rev: "v0.8.0.4" |     rev: "v0.9.0.2" | ||||||
|     hooks: |     hooks: | ||||||
|       - id: shellcheck |       - id: shellcheck | ||||||
|   | |||||||
							
								
								
									
										22
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										22
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -45,7 +45,7 @@ COPY Pipfile* ./ | |||||||
|  |  | ||||||
| RUN set -eux \ | RUN set -eux \ | ||||||
|   && echo "Installing pipenv" \ |   && echo "Installing pipenv" \ | ||||||
|     && python3 -m pip install --no-cache-dir --upgrade pipenv \ |     && python3 -m pip install --no-cache-dir --upgrade pipenv==2022.11.30 \ | ||||||
|   && echo "Generating requirement.txt" \ |   && echo "Generating requirement.txt" \ | ||||||
|     && pipenv requirements > requirements.txt |     && pipenv requirements > requirements.txt | ||||||
|  |  | ||||||
| @@ -58,6 +58,12 @@ LABEL org.opencontainers.image.url="https://github.com/paperless-ngx/paperless-n | |||||||
| LABEL org.opencontainers.image.licenses="GPL-3.0-only" | LABEL org.opencontainers.image.licenses="GPL-3.0-only" | ||||||
|  |  | ||||||
| ARG DEBIAN_FRONTEND=noninteractive | ARG DEBIAN_FRONTEND=noninteractive | ||||||
|  | # Buildx provided | ||||||
|  | ARG TARGETARCH | ||||||
|  | ARG TARGETVARIANT | ||||||
|  |  | ||||||
|  | # Workflow provided | ||||||
|  | ARG QPDF_VERSION | ||||||
|  |  | ||||||
| # | # | ||||||
| # Begin installation and configuration | # Begin installation and configuration | ||||||
| @@ -194,14 +200,10 @@ RUN --mount=type=bind,from=qpdf-builder,target=/qpdf \ | |||||||
|     --mount=type=bind,from=pikepdf-builder,target=/pikepdf \ |     --mount=type=bind,from=pikepdf-builder,target=/pikepdf \ | ||||||
|   set -eux \ |   set -eux \ | ||||||
|   && echo "Installing qpdf" \ |   && echo "Installing qpdf" \ | ||||||
|     && apt-get install --yes --no-install-recommends /qpdf/usr/src/qpdf/libqpdf29_*.deb \ |     && apt-get install --yes --no-install-recommends /qpdf/usr/src/qpdf/${QPDF_VERSION}/${TARGETARCH}${TARGETVARIANT}/libqpdf29_*.deb \ | ||||||
|     && apt-get install --yes --no-install-recommends /qpdf/usr/src/qpdf/qpdf_*.deb \ |     && apt-get install --yes --no-install-recommends /qpdf/usr/src/qpdf/${QPDF_VERSION}/${TARGETARCH}${TARGETVARIANT}/qpdf_*.deb \ | ||||||
|   && echo "Installing pikepdf and dependencies" \ |   && echo "Installing pikepdf and dependencies" \ | ||||||
|     && python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/pyparsing*.whl \ |     && python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/*.whl \ | ||||||
|     && python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/packaging*.whl \ |  | ||||||
|     && python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/lxml*.whl \ |  | ||||||
|     && python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/Pillow*.whl \ |  | ||||||
|     && python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/pikepdf*.whl \ |  | ||||||
|     && python3 -m pip list \ |     && python3 -m pip list \ | ||||||
|   && echo "Installing psycopg2" \ |   && echo "Installing psycopg2" \ | ||||||
|     && python3 -m pip install --no-cache-dir /psycopg2/usr/src/wheels/psycopg2*.whl \ |     && python3 -m pip install --no-cache-dir /psycopg2/usr/src/wheels/psycopg2*.whl \ | ||||||
| @@ -228,6 +230,10 @@ RUN set -eux \ | |||||||
|     && python3 -m pip install --no-cache-dir --upgrade wheel \ |     && python3 -m pip install --no-cache-dir --upgrade wheel \ | ||||||
|   && echo "Installing Python requirements" \ |   && echo "Installing Python requirements" \ | ||||||
|     && python3 -m pip install --default-timeout=1000 --no-cache-dir --requirement requirements.txt \ |     && python3 -m pip install --default-timeout=1000 --no-cache-dir --requirement requirements.txt \ | ||||||
|  |   && echo "Installing NLTK data" \ | ||||||
|  |     && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/local/share/nltk_data" snowball_data \ | ||||||
|  |     && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/local/share/nltk_data" stopwords \ | ||||||
|  |     && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/local/share/nltk_data" punkt \ | ||||||
|   && echo "Cleaning up image" \ |   && echo "Cleaning up image" \ | ||||||
|     && apt-get -y purge ${BUILD_PACKAGES} \ |     && apt-get -y purge ${BUILD_PACKAGES} \ | ||||||
|     && apt-get -y autoremove --purge \ |     && apt-get -y autoremove --purge \ | ||||||
|   | |||||||
							
								
								
									
										21
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								Pipfile
									
									
									
									
									
								
							| @@ -30,8 +30,6 @@ psycopg2 = "*" | |||||||
| rapidfuzz = "*" | rapidfuzz = "*" | ||||||
| redis = {extras = ["hiredis"], version = "*"} | redis = {extras = ["hiredis"], version = "*"} | ||||||
| scikit-learn = "~=1.1" | scikit-learn = "~=1.1" | ||||||
| # Pin this until piwheels is building 1.9 (see https://www.piwheels.org/project/scipy/) |  | ||||||
| scipy = "==1.8.1" |  | ||||||
| numpy = "*" | numpy = "*" | ||||||
| whitenoise = "~=6.2" | whitenoise = "~=6.2" | ||||||
| watchdog = "~=2.1" | watchdog = "~=2.1" | ||||||
| @@ -43,9 +41,6 @@ tika = "*" | |||||||
| # TODO: This will sadly also install daphne+dependencies, | # TODO: This will sadly also install daphne+dependencies, | ||||||
| #  which an ASGI server we don't need. Adds about 15MB image size. | #  which an ASGI server we don't need. Adds about 15MB image size. | ||||||
| channels = "~=3.0" | channels = "~=3.0" | ||||||
| # Locked version until https://github.com/django/channels_redis/issues/332 |  | ||||||
| # is resolved |  | ||||||
| channels-redis = "==3.4.1" |  | ||||||
| uvicorn = {extras = ["standard"], version = "*"} | uvicorn = {extras = ["standard"], version = "*"} | ||||||
| concurrent-log-handler = "*" | concurrent-log-handler = "*" | ||||||
| "pdfminer.six" = "*" | "pdfminer.six" = "*" | ||||||
| @@ -60,6 +55,21 @@ setproctitle = "*" | |||||||
| nltk = "*" | nltk = "*" | ||||||
| pdf2image = "*" | pdf2image = "*" | ||||||
| flower = "*" | flower = "*" | ||||||
|  | bleach = "*" | ||||||
|  |  | ||||||
|  | # | ||||||
|  | # Packages locked due to issues (try to check if these are fixed in a release every so often) | ||||||
|  | # | ||||||
|  |  | ||||||
|  | # Pin this until piwheels is building 1.9 (see https://www.piwheels.org/project/scipy/) | ||||||
|  | scipy = "==1.8.1" | ||||||
|  |  | ||||||
|  | # Newer versions aren't builting yet (see https://www.piwheels.org/project/cryptography/) | ||||||
|  | cryptography = "==38.0.1" | ||||||
|  |  | ||||||
|  | # Locked version until https://github.com/django/channels_redis/issues/332 | ||||||
|  | # is resolved | ||||||
|  | channels-redis = "==3.4.1" | ||||||
|  |  | ||||||
| [dev-packages] | [dev-packages] | ||||||
| coveralls = "*" | coveralls = "*" | ||||||
| @@ -76,4 +86,5 @@ black = "*" | |||||||
| pre-commit = "*" | pre-commit = "*" | ||||||
| sphinx-autobuild = "*" | sphinx-autobuild = "*" | ||||||
| myst-parser = "*" | myst-parser = "*" | ||||||
|  | imagehash = "*" | ||||||
| mkdocs-material = "*" | mkdocs-material = "*" | ||||||
|   | |||||||
							
								
								
									
										1508
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1508
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -10,9 +10,9 @@ | |||||||
| # Example Usage: | # Example Usage: | ||||||
| #	./build-docker-image.sh Dockerfile -t paperless-ngx:my-awesome-feature | #	./build-docker-image.sh Dockerfile -t paperless-ngx:my-awesome-feature | ||||||
|  |  | ||||||
| set -eux | set -eu | ||||||
|  |  | ||||||
| if ! command -v jq;  then | if ! command -v jq &> /dev/null ;  then | ||||||
| 	echo "jq required" | 	echo "jq required" | ||||||
| 	exit 1 | 	exit 1 | ||||||
| elif [ ! -f "$1" ]; then | elif [ ! -f "$1" ]; then | ||||||
| @@ -20,28 +20,62 @@ elif [ ! -f "$1" ]; then | |||||||
| 	exit 1 | 	exit 1 | ||||||
| fi | fi | ||||||
|  |  | ||||||
| # Parse what we can from Pipfile.lock |  | ||||||
| pikepdf_version=$(jq ".default.pikepdf.version" Pipfile.lock  | sed 's/=//g' | sed 's/"//g') |  | ||||||
| psycopg2_version=$(jq ".default.psycopg2.version" Pipfile.lock | sed 's/=//g' | sed 's/"//g') |  | ||||||
| pillow_version=$(jq ".default.pillow.version" Pipfile.lock | sed 's/=//g' | sed 's/"//g') |  | ||||||
| lxml_version=$(jq ".default.lxml.version" Pipfile.lock | sed 's/=//g' | sed 's/"//g') |  | ||||||
| # Read this from the other config file |  | ||||||
| qpdf_version=$(jq ".qpdf.version" .build-config.json | sed 's/"//g') |  | ||||||
| jbig2enc_version=$(jq ".jbig2enc.version" .build-config.json | sed 's/"//g') |  | ||||||
| # Get the branch name (used for caching) | # Get the branch name (used for caching) | ||||||
| branch_name=$(git rev-parse --abbrev-ref HEAD) | branch_name=$(git rev-parse --abbrev-ref HEAD) | ||||||
|  |  | ||||||
| # https://docs.docker.com/develop/develop-images/build_enhancements/ | # Parse eithe Pipfile.lock or the .build-config.json | ||||||
| # Required to use cache-from | jbig2enc_version=$(jq ".jbig2enc.version" .build-config.json | sed 's/"//g') | ||||||
| export DOCKER_BUILDKIT=1 | qpdf_version=$(jq ".qpdf.version" .build-config.json | sed 's/"//g') | ||||||
|  | psycopg2_version=$(jq ".default.psycopg2.version" Pipfile.lock | sed 's/=//g' | sed 's/"//g') | ||||||
|  | pikepdf_version=$(jq ".default.pikepdf.version" Pipfile.lock  | sed 's/=//g' | sed 's/"//g') | ||||||
|  | pillow_version=$(jq ".default.pillow.version" Pipfile.lock | sed 's/=//g' | sed 's/"//g') | ||||||
|  | lxml_version=$(jq ".default.lxml.version" Pipfile.lock | sed 's/=//g' | sed 's/"//g') | ||||||
|  |  | ||||||
| docker build --file "$1" \ | base_filename="$(basename -- "${1}")" | ||||||
|  | build_args_str="" | ||||||
|  | cache_from_str="" | ||||||
|  |  | ||||||
|  | case "${base_filename}" in | ||||||
|  |  | ||||||
|  | 	*.jbig2enc) | ||||||
|  | 		build_args_str="--build-arg JBIG2ENC_VERSION=${jbig2enc_version}" | ||||||
|  | 		cache_from_str="--cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/jbig2enc:${jbig2enc_version}" | ||||||
|  | 		;; | ||||||
|  |  | ||||||
|  | 	*.psycopg2) | ||||||
|  | 		build_args_str="--build-arg PSYCOPG2_VERSION=${psycopg2_version}" | ||||||
|  | 		cache_from_str="--cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/psycopg2:${psycopg2_version}" | ||||||
|  | 		;; | ||||||
|  |  | ||||||
|  | 	*.qpdf) | ||||||
|  | 		build_args_str="--build-arg QPDF_VERSION=${qpdf_version}" | ||||||
|  | 		cache_from_str="--cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/qpdf:${qpdf_version}" | ||||||
|  | 		;; | ||||||
|  |  | ||||||
|  | 	*.pikepdf) | ||||||
|  | 		build_args_str="--build-arg QPDF_VERSION=${qpdf_version} --build-arg PIKEPDF_VERSION=${pikepdf_version} --build-arg PILLOW_VERSION=${pillow_version} --build-arg LXML_VERSION=${lxml_version}" | ||||||
|  | 		cache_from_str="--cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/pikepdf:${pikepdf_version}" | ||||||
|  | 		;; | ||||||
|  |  | ||||||
|  | 	Dockerfile) | ||||||
|  | 		build_args_str="--build-arg QPDF_VERSION=${qpdf_version} --build-arg PIKEPDF_VERSION=${pikepdf_version} --build-arg PSYCOPG2_VERSION=${psycopg2_version} --build-arg JBIG2ENC_VERSION=${jbig2enc_version}" | ||||||
|  | 		cache_from_str="--cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/app:${branch_name} --cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/app:dev" | ||||||
|  | 		;; | ||||||
|  |  | ||||||
|  | 	*) | ||||||
|  | 		echo "Unable to match ${base_filename}" | ||||||
|  | 		exit 1 | ||||||
|  | 		;; | ||||||
|  | esac | ||||||
|  |  | ||||||
|  | read -r -a build_args_arr <<< "${build_args_str}" | ||||||
|  | read -r -a cache_from_arr <<< "${cache_from_str}" | ||||||
|  |  | ||||||
|  | set -eux | ||||||
|  |  | ||||||
|  | docker buildx build --file "${1}" \ | ||||||
| 	--progress=plain \ | 	--progress=plain \ | ||||||
| 	--cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/app:"${branch_name}" \ | 	--output=type=docker \ | ||||||
| 	--cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/app:dev \ | 	"${cache_from_arr[@]}" \ | ||||||
| 	--build-arg JBIG2ENC_VERSION="${jbig2enc_version}" \ | 	"${build_args_arr[@]}" \ | ||||||
| 	--build-arg QPDF_VERSION="${qpdf_version}" \ | 	"${@:2}" . | ||||||
| 	--build-arg PIKEPDF_VERSION="${pikepdf_version}" \ |  | ||||||
| 	--build-arg PILLOW_VERSION="${pillow_version}" \ |  | ||||||
| 	--build-arg LXML_VERSION="${lxml_version}" \ |  | ||||||
| 	--build-arg PSYCOPG2_VERSION="${psycopg2_version}" "${@:2}" . |  | ||||||
|   | |||||||
| @@ -16,7 +16,13 @@ FROM python:3.9-slim-bullseye as main | |||||||
|  |  | ||||||
| LABEL org.opencontainers.image.description="A intermediate image with pikepdf wheel built" | LABEL org.opencontainers.image.description="A intermediate image with pikepdf wheel built" | ||||||
|  |  | ||||||
|  | # Buildx provided | ||||||
|  | ARG TARGETARCH | ||||||
|  | ARG TARGETVARIANT | ||||||
|  |  | ||||||
| ARG DEBIAN_FRONTEND=noninteractive | ARG DEBIAN_FRONTEND=noninteractive | ||||||
|  | # Workflow provided | ||||||
|  | ARG QPDF_VERSION | ||||||
| ARG PIKEPDF_VERSION | ARG PIKEPDF_VERSION | ||||||
| # These are not used, but will still bust the cache if one changes | # These are not used, but will still bust the cache if one changes | ||||||
| # Otherwise, the main image will try to build thing (and fail) | # Otherwise, the main image will try to build thing (and fail) | ||||||
| @@ -54,7 +60,7 @@ ARG BUILD_PACKAGES="\ | |||||||
|  |  | ||||||
| WORKDIR /usr/src | WORKDIR /usr/src | ||||||
|  |  | ||||||
| COPY --from=qpdf-builder /usr/src/qpdf/*.deb ./ | COPY --from=qpdf-builder /usr/src/qpdf/${QPDF_VERSION}/${TARGETARCH}${TARGETVARIANT}/*.deb ./ | ||||||
|  |  | ||||||
| # As this is an base image for a multi-stage final image | # As this is an base image for a multi-stage final image | ||||||
| # the added size of the install is basically irrelevant | # the added size of the install is basically irrelevant | ||||||
| @@ -77,6 +83,8 @@ RUN set -eux \ | |||||||
|     && python3 -m pip wheel \ |     && python3 -m pip wheel \ | ||||||
|       # Build the package at the required version |       # Build the package at the required version | ||||||
|       pikepdf==${PIKEPDF_VERSION} \ |       pikepdf==${PIKEPDF_VERSION} \ | ||||||
|  |       # Look to piwheels for additional pre-built wheels | ||||||
|  |       --extra-index-url https://www.piwheels.org/simple \ | ||||||
|       # Output the *.whl into this directory |       # Output the *.whl into this directory | ||||||
|       --wheel-dir wheels \ |       --wheel-dir wheels \ | ||||||
|       # Do not use a binary packge for the package being built |       # Do not use a binary packge for the package being built | ||||||
| @@ -86,6 +94,8 @@ RUN set -eux \ | |||||||
|       # Don't cache build files |       # Don't cache build files | ||||||
|       --no-cache-dir \ |       --no-cache-dir \ | ||||||
|     && ls -ahl wheels \ |     && ls -ahl wheels \ | ||||||
|  |   && echo "Gathering package data" \ | ||||||
|  |     && dpkg-query -f '${Package;-40}${Version}\n' -W > ./wheels/pkg-list.txt \ | ||||||
|   && echo "Cleaning up image" \ |   && echo "Cleaning up image" \ | ||||||
|     && apt-get -y purge ${BUILD_PACKAGES} \ |     && apt-get -y purge ${BUILD_PACKAGES} \ | ||||||
|     && apt-get -y autoremove --purge \ |     && apt-get -y autoremove --purge \ | ||||||
|   | |||||||
| @@ -42,6 +42,8 @@ RUN set -eux \ | |||||||
|       # Don't cache build files |       # Don't cache build files | ||||||
|       --no-cache-dir \ |       --no-cache-dir \ | ||||||
|     && ls -ahl wheels/ \ |     && ls -ahl wheels/ \ | ||||||
|  |   && echo "Gathering package data" \ | ||||||
|  |     && dpkg-query -f '${Package;-40}${Version}\n' -W > ./wheels/pkg-list.txt \ | ||||||
|   && echo "Cleaning up image" \ |   && echo "Cleaning up image" \ | ||||||
|     && apt-get -y purge ${BUILD_PACKAGES} \ |     && apt-get -y purge ${BUILD_PACKAGES} \ | ||||||
|     && apt-get -y autoremove --purge \ |     && apt-get -y autoremove --purge \ | ||||||
|   | |||||||
| @@ -1,48 +1,156 @@ | |||||||
| # This Dockerfile compiles the jbig2enc library | # | ||||||
| # Inputs: | # Stage: pre-build | ||||||
| #    - QPDF_VERSION - the version of qpdf to build a .deb. | # Purpose: | ||||||
| #                     Must be present as a deb-src in bookworm | #  - Installs common packages | ||||||
|  | #  - Sets common environment variables related to dpkg | ||||||
|  | #  - Aquires the qpdf source from bookwork | ||||||
|  | # Useful Links: | ||||||
|  | #  - https://qpdf.readthedocs.io/en/stable/installation.html#system-requirements | ||||||
|  | #  - https://wiki.debian.org/Multiarch/HOWTO | ||||||
|  | #  - https://wiki.debian.org/CrossCompiling | ||||||
|  | # | ||||||
|  |  | ||||||
| FROM debian:bullseye-slim as main | FROM debian:bullseye-slim as pre-build | ||||||
|  |  | ||||||
| LABEL org.opencontainers.image.description="A intermediate image with qpdf built" |  | ||||||
|  |  | ||||||
| ARG DEBIAN_FRONTEND=noninteractive |  | ||||||
| # This must match to pikepdf's minimum at least |  | ||||||
| ARG QPDF_VERSION | ARG QPDF_VERSION | ||||||
|  |  | ||||||
| ARG BUILD_PACKAGES="\ | ARG COMMON_BUILD_PACKAGES="\ | ||||||
|   build-essential \ |   cmake \ | ||||||
|   debhelper \ |   debhelper\ | ||||||
|   debian-keyring \ |   debian-keyring \ | ||||||
|   devscripts \ |   devscripts \ | ||||||
|   equivs  \ |   dpkg-dev \ | ||||||
|   libtool \ |   equivs \ | ||||||
|   # https://qpdf.readthedocs.io/en/stable/installation.html#system-requirements |  | ||||||
|   libjpeg62-turbo-dev \ |  | ||||||
|   libgnutls28-dev \ |  | ||||||
|   packaging-dev \ |   packaging-dev \ | ||||||
|   cmake \ |   libtool" | ||||||
|   zlib1g-dev" |  | ||||||
|  | ENV DEB_BUILD_OPTIONS="terse nocheck nodoc parallel=2" | ||||||
|  |  | ||||||
| WORKDIR /usr/src | WORKDIR /usr/src | ||||||
|  |  | ||||||
| RUN set -eux \ | RUN set -eux \ | ||||||
|   && echo "Installing build tools" \ |   && echo "Installing common packages" \ | ||||||
|     && apt-get update --quiet \ |     && apt-get update --quiet \ | ||||||
|     && apt-get install --yes --quiet --no-install-recommends $BUILD_PACKAGES \ |     && apt-get install --yes --quiet --no-install-recommends ${COMMON_BUILD_PACKAGES} \ | ||||||
|   && echo "Getting qpdf src" \ |   && echo "Getting qpdf source" \ | ||||||
|     && echo "deb-src http://deb.debian.org/debian/ bookworm main" > /etc/apt/sources.list.d/bookworm-src.list \ |     && echo "deb-src http://deb.debian.org/debian/ bookworm main" > /etc/apt/sources.list.d/bookworm-src.list \ | ||||||
|     && apt-get update \ |     && apt-get update --quiet \ | ||||||
|     && mkdir qpdf \ |     && apt-get source --yes --quiet qpdf=${QPDF_VERSION}-1/bookworm | ||||||
|     && cd qpdf \ |  | ||||||
|     && apt-get source --yes --quiet qpdf=${QPDF_VERSION}-1/bookworm \ | # | ||||||
|   && echo "Building qpdf" \ | # Stage: amd64-builder | ||||||
|     && cd qpdf-$QPDF_VERSION \ | # Purpose: Builds qpdf for x86_64 (native build) | ||||||
|     && export DEB_BUILD_OPTIONS="terse nocheck nodoc parallel=2" \ | # | ||||||
|     && dpkg-buildpackage --build=binary --unsigned-source --unsigned-changes --post-clean \ | FROM pre-build as amd64-builder | ||||||
|     && ls -ahl ../*.deb \ |  | ||||||
|   && echo "Cleaning up image" \ | ARG AMD64_BUILD_PACKAGES="\ | ||||||
|     && apt-get -y purge ${BUILD_PACKAGES} \ |   build-essential \ | ||||||
|     && apt-get -y autoremove --purge \ |   libjpeg62-turbo-dev:amd64 \ | ||||||
|     && rm -rf /var/lib/apt/lists/* |   libgnutls28-dev:amd64 \ | ||||||
|  |   zlib1g-dev:amd64" | ||||||
|  |  | ||||||
|  | WORKDIR /usr/src/qpdf-${QPDF_VERSION} | ||||||
|  |  | ||||||
|  | RUN set -eux \ | ||||||
|  |   && echo "Beginning amd64" \ | ||||||
|  |     && echo "Install amd64 packages" \ | ||||||
|  |       && apt-get update --quiet \ | ||||||
|  |       && apt-get install --yes --quiet --no-install-recommends ${AMD64_BUILD_PACKAGES} \ | ||||||
|  |     && echo "Building amd64" \ | ||||||
|  |       && dpkg-buildpackage --build=binary --unsigned-source --unsigned-changes --post-clean \ | ||||||
|  |     && echo "Removing debug files" \ | ||||||
|  |       && rm -f ../libqpdf29-dbgsym* \ | ||||||
|  |       && rm -f ../qpdf-dbgsym* \ | ||||||
|  |     && echo "Gathering package data" \ | ||||||
|  |       && dpkg-query -f '${Package;-40}${Version}\n' -W > ../pkg-list.txt | ||||||
|  | # | ||||||
|  | # Stage: armhf-builder | ||||||
|  | # Purpose: | ||||||
|  | #  - Sets armhf specific environment | ||||||
|  | #  - Builds qpdf for armhf (cross compile) | ||||||
|  | # | ||||||
|  | FROM pre-build as armhf-builder | ||||||
|  |  | ||||||
|  | ARG ARMHF_PACKAGES="\ | ||||||
|  |   crossbuild-essential-armhf \ | ||||||
|  |   libjpeg62-turbo-dev:armhf \ | ||||||
|  |   libgnutls28-dev:armhf \ | ||||||
|  |   zlib1g-dev:armhf" | ||||||
|  |  | ||||||
|  | WORKDIR /usr/src/qpdf-${QPDF_VERSION} | ||||||
|  |  | ||||||
|  | ENV CXX="/usr/bin/arm-linux-gnueabihf-g++" \ | ||||||
|  |     CC="/usr/bin/arm-linux-gnueabihf-gcc" | ||||||
|  |  | ||||||
|  | RUN set -eux \ | ||||||
|  |   && echo "Beginning armhf" \ | ||||||
|  |     && echo "Install armhf packages" \ | ||||||
|  |       && dpkg --add-architecture armhf \ | ||||||
|  |       && apt-get update --quiet \ | ||||||
|  |       && apt-get install --yes --quiet --no-install-recommends ${ARMHF_PACKAGES} \ | ||||||
|  |     && echo "Building armhf" \ | ||||||
|  |       && dpkg-buildpackage --build=binary --unsigned-source --unsigned-changes --post-clean --host-arch armhf \ | ||||||
|  |     && echo "Removing debug files" \ | ||||||
|  |       && rm -f ../libqpdf29-dbgsym* \ | ||||||
|  |       && rm -f ../qpdf-dbgsym* \ | ||||||
|  |     && echo "Gathering package data" \ | ||||||
|  |       && dpkg-query -f '${Package;-40}${Version}\n' -W > ../pkg-list.txt | ||||||
|  |  | ||||||
|  | # | ||||||
|  | # Stage: aarch64-builder | ||||||
|  | # Purpose: | ||||||
|  | #  - Sets aarch64 specific environment | ||||||
|  | #  - Builds qpdf for aarch64 (cross compile) | ||||||
|  | # | ||||||
|  | FROM pre-build as aarch64-builder | ||||||
|  |  | ||||||
|  | ARG ARM64_PACKAGES="\ | ||||||
|  |   crossbuild-essential-arm64 \ | ||||||
|  |   libjpeg62-turbo-dev:arm64 \ | ||||||
|  |   libgnutls28-dev:arm64 \ | ||||||
|  |   zlib1g-dev:arm64" | ||||||
|  |  | ||||||
|  | ENV CXX="/usr/bin/aarch64-linux-gnu-g++" \ | ||||||
|  |     CC="/usr/bin/aarch64-linux-gnu-gcc" | ||||||
|  |  | ||||||
|  | WORKDIR /usr/src/qpdf-${QPDF_VERSION} | ||||||
|  |  | ||||||
|  | RUN set -eux \ | ||||||
|  |   && echo "Beginning arm64" \ | ||||||
|  |     && echo "Install arm64 packages" \ | ||||||
|  |       && dpkg --add-architecture arm64 \ | ||||||
|  |       && apt-get update --quiet \ | ||||||
|  |       && apt-get install --yes --quiet --no-install-recommends ${ARM64_PACKAGES} \ | ||||||
|  |     && echo "Building arm64" \ | ||||||
|  |       && dpkg-buildpackage --build=binary --unsigned-source --unsigned-changes --post-clean --host-arch arm64 \ | ||||||
|  |     && echo "Removing debug files" \ | ||||||
|  |       && rm -f ../libqpdf29-dbgsym* \ | ||||||
|  |       && rm -f ../qpdf-dbgsym* \ | ||||||
|  |     && echo "Gathering package data" \ | ||||||
|  |       && dpkg-query -f '${Package;-40}${Version}\n' -W > ../pkg-list.txt | ||||||
|  |  | ||||||
|  | # | ||||||
|  | # Stage: package | ||||||
|  | # Purpose: Holds the compiled .deb files in arch/variant specific folders | ||||||
|  | # | ||||||
|  | FROM alpine:3.17 as package | ||||||
|  |  | ||||||
|  | LABEL org.opencontainers.image.description="A image with qpdf installers stored in architecture & version specific folders" | ||||||
|  |  | ||||||
|  | ARG QPDF_VERSION | ||||||
|  |  | ||||||
|  | WORKDIR /usr/src/qpdf/${QPDF_VERSION}/amd64 | ||||||
|  |  | ||||||
|  | COPY --from=amd64-builder /usr/src/*.deb ./ | ||||||
|  | COPY --from=amd64-builder /usr/src/pkg-list.txt ./ | ||||||
|  |  | ||||||
|  | # Note this is ${TARGETARCH}${TARGETVARIANT} for armv7 | ||||||
|  | WORKDIR /usr/src/qpdf/${QPDF_VERSION}/armv7 | ||||||
|  |  | ||||||
|  | COPY --from=armhf-builder /usr/src/*.deb ./ | ||||||
|  | COPY --from=armhf-builder /usr/src/pkg-list.txt ./ | ||||||
|  |  | ||||||
|  | WORKDIR /usr/src/qpdf/${QPDF_VERSION}/arm64 | ||||||
|  |  | ||||||
|  | COPY --from=aarch64-builder /usr/src/*.deb ./ | ||||||
|  | COPY --from=aarch64-builder /usr/src/pkg-list.txt ./ | ||||||
|   | |||||||
| @@ -11,9 +11,12 @@ services: | |||||||
|     container_name: gotenberg |     container_name: gotenberg | ||||||
|     network_mode: host |     network_mode: host | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|  |     # The gotenberg chromium route is used to convert .eml files. We do not | ||||||
|  |     # want to allow external content like tracking pixels or even javascript. | ||||||
|     command: |     command: | ||||||
|       - "gotenberg" |       - "gotenberg" | ||||||
|       - "--chromium-disable-routes=true" |       - "--chromium-disable-javascript=true" | ||||||
|  |       - "--chromium-allow-list=file:///tmp/.*" | ||||||
|   tika: |   tika: | ||||||
|     image: ghcr.io/paperless-ngx/tika:latest |     image: ghcr.io/paperless-ngx/tika:latest | ||||||
|     hostname: tika |     hostname: tika | ||||||
|   | |||||||
| @@ -85,9 +85,12 @@ services: | |||||||
|   gotenberg: |   gotenberg: | ||||||
|     image: docker.io/gotenberg/gotenberg:7.6 |     image: docker.io/gotenberg/gotenberg:7.6 | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|  |     # The gotenberg chromium route is used to convert .eml files. We do not | ||||||
|  |     # want to allow external content like tracking pixels or even javascript. | ||||||
|     command: |     command: | ||||||
|       - "gotenberg" |       - "gotenberg" | ||||||
|       - "--chromium-disable-routes=true" |       - "--chromium-disable-javascript=true" | ||||||
|  |       - "--chromium-allow-list=file:///tmp/.*" | ||||||
|  |  | ||||||
|   tika: |   tika: | ||||||
|     image: ghcr.io/paperless-ngx/tika:latest |     image: ghcr.io/paperless-ngx/tika:latest | ||||||
|   | |||||||
| @@ -79,9 +79,13 @@ services: | |||||||
|   gotenberg: |   gotenberg: | ||||||
|     image: docker.io/gotenberg/gotenberg:7.6 |     image: docker.io/gotenberg/gotenberg:7.6 | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|  |  | ||||||
|  |     # The gotenberg chromium route is used to convert .eml files. We do not | ||||||
|  |     # want to allow external content like tracking pixels or even javascript. | ||||||
|     command: |     command: | ||||||
|       - "gotenberg" |       - "gotenberg" | ||||||
|       - "--chromium-disable-routes=true" |       - "--chromium-disable-javascript=true" | ||||||
|  |       - "--chromium-allow-list=file:///tmp/.*" | ||||||
|  |  | ||||||
|   tika: |   tika: | ||||||
|     image: ghcr.io/paperless-ngx/tika:latest |     image: ghcr.io/paperless-ngx/tika:latest | ||||||
|   | |||||||
| @@ -67,9 +67,13 @@ services: | |||||||
|   gotenberg: |   gotenberg: | ||||||
|     image: docker.io/gotenberg/gotenberg:7.6 |     image: docker.io/gotenberg/gotenberg:7.6 | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|  |  | ||||||
|  |     # The gotenberg chromium route is used to convert .eml files. We do not | ||||||
|  |     # want to allow external content like tracking pixels or even javascript. | ||||||
|     command: |     command: | ||||||
|       - "gotenberg" |       - "gotenberg" | ||||||
|       - "--chromium-disable-routes=true" |       - "--chromium-disable-javascript=true" | ||||||
|  |       - "--chromium-allow-list=file:///tmp/.*" | ||||||
|  |  | ||||||
|   tika: |   tika: | ||||||
|     image: ghcr.io/paperless-ngx/tika:latest |     image: ghcr.io/paperless-ngx/tika:latest | ||||||
|   | |||||||
| @@ -53,30 +53,6 @@ map_folders() { | |||||||
| 	export CONSUME_DIR="${PAPERLESS_CONSUMPTION_DIR:-/usr/src/paperless/consume}" | 	export CONSUME_DIR="${PAPERLESS_CONSUMPTION_DIR:-/usr/src/paperless/consume}" | ||||||
| } | } | ||||||
|  |  | ||||||
| nltk_data () { |  | ||||||
| 	# Store the NLTK data outside the Docker container |  | ||||||
| 	local -r nltk_data_dir="${DATA_DIR}/nltk" |  | ||||||
| 	local -r truthy_things=("yes y 1 t true") |  | ||||||
|  |  | ||||||
| 	# If not set, or it looks truthy |  | ||||||
| 	if [[ -z "${PAPERLESS_ENABLE_NLTK}" ]] || [[ "${truthy_things[*]}" =~ ${PAPERLESS_ENABLE_NLTK,} ]]; then |  | ||||||
|  |  | ||||||
| 		# Download or update the snowball stemmer data |  | ||||||
| 		python3 -W ignore::RuntimeWarning -m nltk.downloader -d "${nltk_data_dir}" snowball_data |  | ||||||
|  |  | ||||||
| 		# Download or update the stopwords corpus |  | ||||||
| 		python3 -W ignore::RuntimeWarning -m nltk.downloader -d "${nltk_data_dir}" stopwords |  | ||||||
|  |  | ||||||
| 		# Download or update the punkt tokenizer data |  | ||||||
| 		python3 -W ignore::RuntimeWarning -m nltk.downloader -d "${nltk_data_dir}" punkt |  | ||||||
|  |  | ||||||
| 	else |  | ||||||
| 		echo "Skipping NLTK data download" |  | ||||||
|  |  | ||||||
| 	fi |  | ||||||
|  |  | ||||||
| } |  | ||||||
|  |  | ||||||
| custom_container_init() { | custom_container_init() { | ||||||
| 	# Mostly borrowed from the LinuxServer.io base image | 	# Mostly borrowed from the LinuxServer.io base image | ||||||
| 	# https://github.com/linuxserver/docker-baseimage-ubuntu/tree/bionic/root/etc/cont-init.d | 	# https://github.com/linuxserver/docker-baseimage-ubuntu/tree/bionic/root/etc/cont-init.d | ||||||
| @@ -157,8 +133,6 @@ initialize() { | |||||||
| 	echo "Creating directory ${tmp_dir}" | 	echo "Creating directory ${tmp_dir}" | ||||||
| 	mkdir -p "${tmp_dir}" | 	mkdir -p "${tmp_dir}" | ||||||
|  |  | ||||||
| 	nltk_data |  | ||||||
|  |  | ||||||
| 	set +e | 	set +e | ||||||
| 	echo "Adjusting permissions of paperless files. This may take a while." | 	echo "Adjusting permissions of paperless files. This may take a while." | ||||||
| 	chown -R paperless:paperless ${tmp_dir} | 	chown -R paperless:paperless ${tmp_dir} | ||||||
| @@ -191,10 +165,6 @@ install_languages() { | |||||||
|  |  | ||||||
| 	for lang in "${langs[@]}"; do | 	for lang in "${langs[@]}"; do | ||||||
| 		pkg="tesseract-ocr-$lang" | 		pkg="tesseract-ocr-$lang" | ||||||
| 		# English is installed by default |  | ||||||
| 		#if [[ "$lang" ==  "eng" ]]; then |  | ||||||
| 		#    continue |  | ||||||
| 		#fi |  | ||||||
|  |  | ||||||
| 		if dpkg -s "$pkg" &>/dev/null; then | 		if dpkg -s "$pkg" &>/dev/null; then | ||||||
| 			echo "Package $pkg already installed!" | 			echo "Package $pkg already installed!" | ||||||
|   | |||||||
| @@ -20,7 +20,6 @@ wait_for_postgres() { | |||||||
| 			exit 1 | 			exit 1 | ||||||
| 		else | 		else | ||||||
| 			echo "Attempt $attempt_num failed! Trying again in 5 seconds..." | 			echo "Attempt $attempt_num failed! Trying again in 5 seconds..." | ||||||
|  |  | ||||||
| 		fi | 		fi | ||||||
|  |  | ||||||
| 		attempt_num=$(("$attempt_num" + 1)) | 		attempt_num=$(("$attempt_num" + 1)) | ||||||
| @@ -37,6 +36,8 @@ wait_for_mariadb() { | |||||||
| 	local attempt_num=1 | 	local attempt_num=1 | ||||||
| 	local -r max_attempts=5 | 	local -r max_attempts=5 | ||||||
|  |  | ||||||
|  | 	# Disable warning, host and port can't have spaces | ||||||
|  | 	# shellcheck disable=SC2086 | ||||||
| 	while ! true > /dev/tcp/$host/$port; do | 	while ! true > /dev/tcp/$host/$port; do | ||||||
|  |  | ||||||
| 		if [ $attempt_num -eq $max_attempts ]; then | 		if [ $attempt_num -eq $max_attempts ]; then | ||||||
| @@ -67,10 +68,16 @@ migrations() { | |||||||
| 		# of the current container starts. | 		# of the current container starts. | ||||||
| 		flock 200 | 		flock 200 | ||||||
| 		echo "Apply database migrations..." | 		echo "Apply database migrations..." | ||||||
| 		python3 manage.py migrate | 		python3 manage.py migrate --skip-checks --no-input | ||||||
| 	) 200>"${DATA_DIR}/migration_lock" | 	) 200>"${DATA_DIR}/migration_lock" | ||||||
| } | } | ||||||
|  |  | ||||||
|  | django_checks() { | ||||||
|  | 	# Explicitly run the Django system checks | ||||||
|  | 	echo "Running Django checks" | ||||||
|  | 	python3 manage.py check | ||||||
|  | } | ||||||
|  |  | ||||||
| search_index() { | search_index() { | ||||||
|  |  | ||||||
| 	local -r index_version=1 | 	local -r index_version=1 | ||||||
| @@ -100,6 +107,8 @@ do_work() { | |||||||
|  |  | ||||||
| 	migrations | 	migrations | ||||||
|  |  | ||||||
|  | 	django_checks | ||||||
|  |  | ||||||
| 	search_index | 	search_index | ||||||
|  |  | ||||||
| 	superuser | 	superuser | ||||||
|   | |||||||
| @@ -233,6 +233,7 @@ optional arguments: | |||||||
| -c, --compare-checksums | -c, --compare-checksums | ||||||
| -f, --use-filename-format | -f, --use-filename-format | ||||||
| -d, --delete | -d, --delete | ||||||
|  | -z  --zip | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| `target` is a folder to which the data gets written. This includes | `target` is a folder to which the data gets written. This includes | ||||||
| @@ -258,6 +259,9 @@ current export such as files from deleted documents, specify `--delete`. | |||||||
| Be careful when pointing paperless to a directory that already contains | Be careful when pointing paperless to a directory that already contains | ||||||
| other files. | other files. | ||||||
|  |  | ||||||
|  | If `-z` or `--zip` is provided, the export will be a zipfile | ||||||
|  | in the target directory, named according to the current date. | ||||||
|  |  | ||||||
| The filenames generated by this command follow the format | The filenames generated by this command follow the format | ||||||
| `[date created] [correspondent] [title].[extension]`. If you want | `[date created] [correspondent] [title].[extension]`. If you want | ||||||
| paperless to use `PAPERLESS_FILENAME_FORMAT` for exported filenames | paperless to use `PAPERLESS_FILENAME_FORMAT` for exported filenames | ||||||
|   | |||||||
| @@ -10,12 +10,10 @@ run paperless, these settings have to be defined in different places. | |||||||
| - If you are running paperless on anything else, paperless will search | - If you are running paperless on anything else, paperless will search | ||||||
|   for the configuration file in these locations and use the first one |   for the configuration file in these locations and use the first one | ||||||
|   it finds: |   it finds: | ||||||
|  |   - The environment variable `PAPERLESS_CONFIGURATION_PATH` | ||||||
|   ``` |   - `/path/to/paperless/paperless.conf` | ||||||
|   /path/to/paperless/paperless.conf |   - `/etc/paperless.conf` | ||||||
|   /etc/paperless.conf |   - `/usr/local/etc/paperless.conf` | ||||||
|   /usr/local/etc/paperless.conf |  | ||||||
|   ``` |  | ||||||
|  |  | ||||||
| ## Required services | ## Required services | ||||||
|  |  | ||||||
| @@ -170,6 +168,19 @@ details. | |||||||
|  |  | ||||||
|     Defaults to `PAPERLESS_DATA_DIR/log/`. |     Defaults to `PAPERLESS_DATA_DIR/log/`. | ||||||
|  |  | ||||||
|  | `PAPERLESS_NLTK_DIR=<path>` | ||||||
|  |  | ||||||
|  | : This is where paperless will search for the data required for NLTK | ||||||
|  | processing, if you are using it. If you are using the Docker image, | ||||||
|  | this should not be changed, as the data is included in the image | ||||||
|  | already. | ||||||
|  |  | ||||||
|  | Previously, the location defaulted to `PAPERLESS_DATA_DIR/nltk`. | ||||||
|  | Unless you are using this in a bare metal install or other setup, | ||||||
|  | this folder is no longer needed and can be removed manually. | ||||||
|  |  | ||||||
|  | Defaults to `/usr/local/share/nltk_data` | ||||||
|  |  | ||||||
| ## Logging | ## Logging | ||||||
|  |  | ||||||
| `PAPERLESS_LOGROTATE_MAX_SIZE=<num>` | `PAPERLESS_LOGROTATE_MAX_SIZE=<num>` | ||||||
| @@ -564,8 +575,10 @@ they use underscores instead of dashes. | |||||||
|  |  | ||||||
| Paperless can make use of [Tika](https://tika.apache.org/) and | Paperless can make use of [Tika](https://tika.apache.org/) and | ||||||
| [Gotenberg](https://gotenberg.dev/) for parsing and converting | [Gotenberg](https://gotenberg.dev/) for parsing and converting | ||||||
| "Office" documents (such as ".doc", ".xlsx" and ".odt"). If you | "Office" documents (such as ".doc", ".xlsx" and ".odt"). | ||||||
| wish to use this, you must provide a Tika server and a Gotenberg server, | Tika and Gotenberg are also needed to allow parsing of E-Mails (.eml). | ||||||
|  |  | ||||||
|  | If you wish to use this, you must provide a Tika server and a Gotenberg server, | ||||||
| configure their endpoints, and enable the feature. | configure their endpoints, and enable the feature. | ||||||
|  |  | ||||||
| `PAPERLESS_TIKA_ENABLED=<bool>` | `PAPERLESS_TIKA_ENABLED=<bool>` | ||||||
| @@ -604,14 +617,17 @@ services: | |||||||
|       PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 |       PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 | ||||||
|       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 |       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 | ||||||
|  |  | ||||||
|   # ... |     # ... | ||||||
|  |  | ||||||
|   gotenberg: |     gotenberg: | ||||||
|     image: gotenberg/gotenberg:7.6 |       image: gotenberg/gotenberg:7.6 | ||||||
|     restart: unless-stopped |       restart: unless-stopped | ||||||
|     command: |       # The gotenberg chromium route is used to convert .eml files. We do not | ||||||
|       - 'gotenberg' |       # want to allow external content like tracking pixels or even javascript. | ||||||
|       - '--chromium-disable-routes=true' |       command: | ||||||
|  |         - 'gotenberg' | ||||||
|  |         - '--chromium-disable-javascript=true' | ||||||
|  |         - '--chromium-allow-list=file:///tmp/.*' | ||||||
|  |  | ||||||
|   tika: |   tika: | ||||||
|     image: ghcr.io/paperless-ngx/tika:latest |     image: ghcr.io/paperless-ngx/tika:latest | ||||||
| @@ -658,7 +674,7 @@ paperless will process in parallel on a single document. | |||||||
|     count, with a slight favor towards threads per worker: |     count, with a slight favor towards threads per worker: | ||||||
|  |  | ||||||
|     | CPU core count | Workers | Threads | |     | CPU core count | Workers | Threads | | ||||||
|     |----------------|---------|---------| |     | -------------- | ------- | ------- | | ||||||
|     | > 1            | > 1     | > 1     | |     | > 1            | > 1     | > 1     | | ||||||
|     | > 2            | > 2     | > 1     | |     | > 2            | > 2     | > 1     | | ||||||
|     | > 4            | > 2     | > 2     | |     | > 4            | > 2     | > 2     | | ||||||
| @@ -691,6 +707,16 @@ for details on how to set it. | |||||||
|  |  | ||||||
|     Defaults to UTC. |     Defaults to UTC. | ||||||
|  |  | ||||||
|  | `PAPERLESS_ENABLE_NLTK=<bool>` | ||||||
|  |  | ||||||
|  | : Enables or disables the advanced natural language processing | ||||||
|  | used during automatic classification. If disabled, paperless will | ||||||
|  | still preform some basic text pre-processing before matching. | ||||||
|  |  | ||||||
|  | See also `PAPERLESS_NLTK_DIR`. | ||||||
|  |  | ||||||
|  |     Defaults to 1. | ||||||
|  |  | ||||||
| ## Polling {#polling} | ## Polling {#polling} | ||||||
|  |  | ||||||
| `PAPERLESS_CONSUMER_POLLING=<num>` | `PAPERLESS_CONSUMER_POLLING=<num>` | ||||||
|   | |||||||
| @@ -125,13 +125,13 @@ using docker-compose, this is achieved by the following configuration | |||||||
| change in the `docker-compose.yml` file: | change in the `docker-compose.yml` file: | ||||||
|  |  | ||||||
| ```yaml | ```yaml | ||||||
| gotenberg: | # The gotenberg chromium route is used to convert .eml files. We do not | ||||||
|   image: gotenberg/gotenberg:7.6 | # want to allow external content like tracking pixels or even javascript. | ||||||
|   restart: unless-stopped | command: | ||||||
|   command: |   - 'gotenberg' | ||||||
|     - 'gotenberg' |   - '--chromium-disable-javascript=true' | ||||||
|     - '--chromium-disable-routes=true' |   - '--chromium-allow-list=file:///tmp/.*' | ||||||
|     - '--api-timeout=60' |   - '--api-timeout=60' | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| ## Permission denied errors in the consumption directory | ## Permission denied errors in the consumption directory | ||||||
|   | |||||||
| @@ -2,5 +2,5 @@ | |||||||
|  |  | ||||||
| docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:13 | docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:13 | ||||||
| docker run -d -p 6379:6379 redis:latest | docker run -d -p 6379:6379 redis:latest | ||||||
| docker run -p 3000:3000 -d gotenberg/gotenberg:7.6 | docker run -p 3000:3000 -d gotenberg/gotenberg:7.6 gotenberg --chromium-disable-javascript=true --chromium-allow-list="file:///tmp/.*" | ||||||
| docker run -p 9998:9998 -d ghcr.io/paperless-ngx/tika:latest | docker run -p 9998:9998 -d ghcr.io/paperless-ngx/tika:latest | ||||||
|   | |||||||
							
								
								
									
										51
									
								
								src-ui/.eslintrc.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src-ui/.eslintrc.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | |||||||
|  | { | ||||||
|  |   "root": true, | ||||||
|  |   "ignorePatterns": [ | ||||||
|  |     "projects/**/*" | ||||||
|  |   ], | ||||||
|  |   "overrides": [ | ||||||
|  |     { | ||||||
|  |       "files": [ | ||||||
|  |         "*.ts" | ||||||
|  |       ], | ||||||
|  |       "parserOptions": { | ||||||
|  |         "project": [ | ||||||
|  |           "tsconfig.json", | ||||||
|  |           "e2e/tsconfig.json" | ||||||
|  |         ], | ||||||
|  |         "createDefaultProgram": true | ||||||
|  |       }, | ||||||
|  |       "extends": [ | ||||||
|  |         "plugin:@angular-eslint/recommended", | ||||||
|  |         "plugin:@angular-eslint/template/process-inline-templates" | ||||||
|  |       ], | ||||||
|  |       "rules": { | ||||||
|  |         "@angular-eslint/directive-selector": [ | ||||||
|  |           "error", | ||||||
|  |           { | ||||||
|  |             "type": "attribute", | ||||||
|  |             "prefix": "app", | ||||||
|  |             "style": "camelCase" | ||||||
|  |           } | ||||||
|  |         ], | ||||||
|  |         "@angular-eslint/component-selector": [ | ||||||
|  |           "error", | ||||||
|  |           { | ||||||
|  |             "type": "element", | ||||||
|  |             "prefix": "app", | ||||||
|  |             "style": "kebab-case" | ||||||
|  |           } | ||||||
|  |         ] | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "files": [ | ||||||
|  |         "*.html" | ||||||
|  |       ], | ||||||
|  |       "extends": [ | ||||||
|  |         "plugin:@angular-eslint/template/recommended" | ||||||
|  |       ], | ||||||
|  |       "rules": {} | ||||||
|  |     } | ||||||
|  |   ] | ||||||
|  | } | ||||||
| @@ -1,179 +1,196 @@ | |||||||
| { | { | ||||||
| 	"$schema": "./node_modules/@angular/cli/lib/config/schema.json", |   "$schema": "./node_modules/@angular/cli/lib/config/schema.json", | ||||||
| 	"version": 1, |   "version": 1, | ||||||
| 	"newProjectRoot": "projects", |   "newProjectRoot": "projects", | ||||||
| 	"projects": { |   "projects": { | ||||||
| 		"paperless-ui": { |     "paperless-ui": { | ||||||
| 			"projectType": "application", |       "projectType": "application", | ||||||
| 			"schematics": { |       "schematics": { | ||||||
| 				"@schematics/angular:component": { |         "@schematics/angular:component": { | ||||||
| 					"style": "scss" |           "style": "scss" | ||||||
| 				} |         } | ||||||
| 			}, |       }, | ||||||
| 			"root": "", |       "root": "", | ||||||
| 			"sourceRoot": "src", |       "sourceRoot": "src", | ||||||
| 			"prefix": "app", |       "prefix": "app", | ||||||
| 			"i18n": { |       "i18n": { | ||||||
| 				"sourceLocale": "en-US", |         "sourceLocale": "en-US", | ||||||
| 				"locales": { |         "locales": { | ||||||
| 					"be-BY": "src/locale/messages.be_BY.xlf", |           "be-BY": "src/locale/messages.be_BY.xlf", | ||||||
| 					"cs-CZ": "src/locale/messages.cs_CZ.xlf", |           "cs-CZ": "src/locale/messages.cs_CZ.xlf", | ||||||
| 					"da-DK": "src/locale/messages.da_DK.xlf", |           "da-DK": "src/locale/messages.da_DK.xlf", | ||||||
| 					"de-DE": "src/locale/messages.de_DE.xlf", |           "de-DE": "src/locale/messages.de_DE.xlf", | ||||||
| 					"en-GB": "src/locale/messages.en_GB.xlf", |           "en-GB": "src/locale/messages.en_GB.xlf", | ||||||
| 					"es-ES": "src/locale/messages.es_ES.xlf", |           "es-ES": "src/locale/messages.es_ES.xlf", | ||||||
| 					"fr-FR": "src/locale/messages.fr_FR.xlf", |           "fr-FR": "src/locale/messages.fr_FR.xlf", | ||||||
| 					"it-IT": "src/locale/messages.it_IT.xlf", |           "it-IT": "src/locale/messages.it_IT.xlf", | ||||||
| 					"lb-LU": "src/locale/messages.lb_LU.xlf", |           "lb-LU": "src/locale/messages.lb_LU.xlf", | ||||||
| 					"nl-NL": "src/locale/messages.nl_NL.xlf", |           "nl-NL": "src/locale/messages.nl_NL.xlf", | ||||||
| 					"pl-PL": "src/locale/messages.pl_PL.xlf", |           "pl-PL": "src/locale/messages.pl_PL.xlf", | ||||||
| 					"pt-BR": "src/locale/messages.pt_BR.xlf", |           "pt-BR": "src/locale/messages.pt_BR.xlf", | ||||||
| 					"pt-PT": "src/locale/messages.pt_PT.xlf", |           "pt-PT": "src/locale/messages.pt_PT.xlf", | ||||||
| 					"ro-RO": "src/locale/messages.ro_RO.xlf", |           "ro-RO": "src/locale/messages.ro_RO.xlf", | ||||||
| 					"ru-RU": "src/locale/messages.ru_RU.xlf", |           "ru-RU": "src/locale/messages.ru_RU.xlf", | ||||||
| 					"sl-SI": "src/locale/messages.sl_SI.xlf", |           "sl-SI": "src/locale/messages.sl_SI.xlf", | ||||||
| 					"sr-CS": "src/locale/messages.sr_CS.xlf", |           "sr-CS": "src/locale/messages.sr_CS.xlf", | ||||||
| 					"sv-SE": "src/locale/messages.sv_SE.xlf", |           "sv-SE": "src/locale/messages.sv_SE.xlf", | ||||||
| 					"tr-TR": "src/locale/messages.tr_TR.xlf", |           "tr-TR": "src/locale/messages.tr_TR.xlf", | ||||||
| 					"zh-CN": "src/locale/messages.zh_CN.xlf" |           "zh-CN": "src/locale/messages.zh_CN.xlf" | ||||||
| 				} |         } | ||||||
| 			}, |       }, | ||||||
| 			"architect": { |       "architect": { | ||||||
| 				"build": { |         "build": { | ||||||
| 					"builder": "@angular-devkit/build-angular:browser", |           "builder": "@angular-devkit/build-angular:browser", | ||||||
| 					"options": { |           "options": { | ||||||
| 						"outputPath": "dist/paperless-ui", |             "outputPath": "dist/paperless-ui", | ||||||
| 						"outputHashing": "none", |             "outputHashing": "none", | ||||||
| 						"index": "src/index.html", |             "index": "src/index.html", | ||||||
| 						"main": "src/main.ts", |             "main": "src/main.ts", | ||||||
| 						"polyfills": "src/polyfills.ts", |             "polyfills": "src/polyfills.ts", | ||||||
| 						"tsConfig": "tsconfig.app.json", |             "tsConfig": "tsconfig.app.json", | ||||||
| 						"localize": true, |             "localize": true, | ||||||
| 						"assets": [ |             "assets": [ | ||||||
| 							"src/favicon.ico", |               "src/favicon.ico", | ||||||
| 							"src/apple-touch-icon.png", |               "src/apple-touch-icon.png", | ||||||
| 							"src/assets", |               "src/assets", | ||||||
| 							"src/manifest.webmanifest", { |               "src/manifest.webmanifest", | ||||||
| 								"glob": "pdf.worker.min.js", |               { | ||||||
| 								"input": "node_modules/pdfjs-dist/build/", |                 "glob": "pdf.worker.min.js", | ||||||
| 								"output": "/assets/js/" |                 "input": "node_modules/pdfjs-dist/build/", | ||||||
| 							} |                 "output": "/assets/js/" | ||||||
| 						], |               } | ||||||
| 						"styles": [ |             ], | ||||||
| 							"src/styles.scss" |             "styles": [ | ||||||
| 						], |               "src/styles.scss" | ||||||
| 						"scripts": [], |             ], | ||||||
| 						"allowedCommonJsDependencies": [ |             "scripts": [], | ||||||
| 							"ng2-pdf-viewer" |             "allowedCommonJsDependencies": [ | ||||||
| 						], |               "ng2-pdf-viewer" | ||||||
| 						"vendorChunk": true, |             ], | ||||||
| 						"extractLicenses": false, |             "vendorChunk": true, | ||||||
| 						"buildOptimizer": false, |             "extractLicenses": false, | ||||||
| 						"sourceMap": true, |             "buildOptimizer": false, | ||||||
| 						"optimization": false, |             "sourceMap": true, | ||||||
| 						"namedChunks": true |             "optimization": false, | ||||||
| 					}, |             "namedChunks": true | ||||||
| 					"configurations": { |           }, | ||||||
| 						"production": { |           "configurations": { | ||||||
| 							"fileReplacements": [ |             "production": { | ||||||
| 								{ |               "fileReplacements": [ | ||||||
| 									"replace": "src/environments/environment.ts", |                 { | ||||||
| 									"with": "src/environments/environment.prod.ts" |                   "replace": "src/environments/environment.ts", | ||||||
| 								} |                   "with": "src/environments/environment.prod.ts" | ||||||
| 							], |                 } | ||||||
| 							"outputPath": "../src/documents/static/frontend/", |               ], | ||||||
| 							"optimization": true, |               "outputPath": "../src/documents/static/frontend/", | ||||||
| 							"outputHashing": "none", |               "optimization": true, | ||||||
| 							"sourceMap": false, |               "outputHashing": "none", | ||||||
| 							"namedChunks": false, |               "sourceMap": false, | ||||||
| 							"extractLicenses": true, |               "namedChunks": false, | ||||||
| 							"vendorChunk": false, |               "extractLicenses": true, | ||||||
| 							"buildOptimizer": true, |               "vendorChunk": false, | ||||||
| 							"budgets": [ |               "buildOptimizer": true, | ||||||
| 								{ |               "budgets": [ | ||||||
| 									"type": "initial", |                 { | ||||||
| 									"maximumWarning": "2mb", |                   "type": "initial", | ||||||
| 									"maximumError": "5mb" |                   "maximumWarning": "2mb", | ||||||
| 								}, |                   "maximumError": "5mb" | ||||||
| 								{ |                 }, | ||||||
| 									"type": "anyComponentStyle", |                 { | ||||||
| 									"maximumWarning": "6kb", |                   "type": "anyComponentStyle", | ||||||
| 									"maximumError": "10kb" |                   "maximumWarning": "6kb", | ||||||
| 								} |                   "maximumError": "10kb" | ||||||
| 							] |                 } | ||||||
| 						}, |               ] | ||||||
| 						"en-US": { |             }, | ||||||
| 							"localize": ["en-US"] |             "en-US": { | ||||||
| 						} |               "localize": [ | ||||||
| 					}, |                 "en-US" | ||||||
| 					"defaultConfiguration": "" |               ] | ||||||
| 				}, |             } | ||||||
| 				"serve": { |           }, | ||||||
| 					"builder": "@angular-devkit/build-angular:dev-server", |           "defaultConfiguration": "" | ||||||
| 					"options": { |         }, | ||||||
| 						"browserTarget": "paperless-ui:build:en-US" |         "serve": { | ||||||
| 					}, |           "builder": "@angular-devkit/build-angular:dev-server", | ||||||
| 					"configurations": { |           "options": { | ||||||
| 						"production": { |             "browserTarget": "paperless-ui:build:en-US" | ||||||
| 							"browserTarget": "paperless-ui:build:production" |           }, | ||||||
| 						} |           "configurations": { | ||||||
| 					} |             "production": { | ||||||
| 				}, |               "browserTarget": "paperless-ui:build:production" | ||||||
| 				"extract-i18n": { |             } | ||||||
| 					"builder": "@angular-devkit/build-angular:extract-i18n", |           } | ||||||
| 					"options": { |         }, | ||||||
| 						"browserTarget": "paperless-ui:build" |         "extract-i18n": { | ||||||
| 					} |           "builder": "@angular-devkit/build-angular:extract-i18n", | ||||||
| 				}, |           "options": { | ||||||
| 				"test": { |             "browserTarget": "paperless-ui:build" | ||||||
| 					"builder": "@angular-builders/jest:run", |           } | ||||||
| 					"options": { |         }, | ||||||
| 						"tsConfig": "tsconfig.spec.json", |         "test": { | ||||||
| 						"assets": [ |           "builder": "@angular-builders/jest:run", | ||||||
| 							"src/favicon.ico", |           "options": { | ||||||
| 							"src/apple-touch-icon.png", |             "tsConfig": "tsconfig.spec.json", | ||||||
| 							"src/assets", |             "assets": [ | ||||||
| 							"src/manifest.webmanifest" |               "src/favicon.ico", | ||||||
| 						], |               "src/apple-touch-icon.png", | ||||||
| 						"styles": [ |               "src/assets", | ||||||
| 							"src/styles.scss" |               "src/manifest.webmanifest" | ||||||
| 						], |             ], | ||||||
| 						"scripts": [] |             "styles": [ | ||||||
| 					} |               "src/styles.scss" | ||||||
| 				}, |             ], | ||||||
| 				"e2e": { |             "scripts": [] | ||||||
| 					"builder": "@cypress/schematic:cypress", |           } | ||||||
| 					"options": { |         }, | ||||||
| 						"devServerTarget": "paperless-ui:serve", |         "e2e": { | ||||||
| 						"watch": true, |           "builder": "@cypress/schematic:cypress", | ||||||
| 						"headless": false |           "options": { | ||||||
| 					}, |             "devServerTarget": "paperless-ui:serve", | ||||||
| 					"configurations": { |             "watch": true, | ||||||
| 						"production": { |             "headless": false | ||||||
| 							"devServerTarget": "paperless-ui:serve:production" |           }, | ||||||
| 						} |           "configurations": { | ||||||
| 					} |             "production": { | ||||||
| 				}, |               "devServerTarget": "paperless-ui:serve:production" | ||||||
| 				"cypress-run": { |             } | ||||||
| 					"builder": "@cypress/schematic:cypress", |           } | ||||||
| 					"options": { |         }, | ||||||
| 						"devServerTarget": "paperless-ui:serve" |         "cypress-run": { | ||||||
| 					}, |           "builder": "@cypress/schematic:cypress", | ||||||
| 					"configurations": { |           "options": { | ||||||
| 						"production": { |             "devServerTarget": "paperless-ui:serve" | ||||||
| 							"devServerTarget": "paperless-ui:serve:production" |           }, | ||||||
| 						} |           "configurations": { | ||||||
| 					} |             "production": { | ||||||
| 				}, |               "devServerTarget": "paperless-ui:serve:production" | ||||||
| 				"cypress-open": { |             } | ||||||
| 					"builder": "@cypress/schematic:cypress", |           } | ||||||
| 					"options": { |         }, | ||||||
| 						"watch": true, |         "cypress-open": { | ||||||
| 						"headless": false |           "builder": "@cypress/schematic:cypress", | ||||||
| 					} |           "options": { | ||||||
| 				} |             "watch": true, | ||||||
| 			} |             "headless": false | ||||||
| 		} |           } | ||||||
| 	}, |         }, | ||||||
| 	"defaultProject": "paperless-ui" |         "lint": { | ||||||
|  |           "builder": "@angular-eslint/builder:lint", | ||||||
|  |           "options": { | ||||||
|  |             "lintFilePatterns": [ | ||||||
|  |               "src/**/*.ts", | ||||||
|  |               "src/**/*.html" | ||||||
|  |             ] | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "defaultProject": "paperless-ui", | ||||||
|  |   "cli": { | ||||||
|  |     "schematicCollections": [ | ||||||
|  |       "@angular-eslint/schematics" | ||||||
|  |     ] | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -35,6 +35,16 @@ describe('settings', () => { | |||||||
|             req.reply(response) |             req.reply(response) | ||||||
|           } |           } | ||||||
|         ).as('savedViews') |         ).as('savedViews') | ||||||
|  |  | ||||||
|  |         cy.intercept('http://localhost:8000/api/mail_accounts/*', { | ||||||
|  |           fixture: 'mail_accounts/mail_accounts.json', | ||||||
|  |         }) | ||||||
|  |         cy.intercept('http://localhost:8000/api/mail_rules/*', { | ||||||
|  |           fixture: 'mail_rules/mail_rules.json', | ||||||
|  |         }).as('mailRules') | ||||||
|  |         cy.intercept('http://localhost:8000/api/tasks/', { | ||||||
|  |           fixture: 'tasks/tasks.json', | ||||||
|  |         }) | ||||||
|       }) |       }) | ||||||
|  |  | ||||||
|       cy.fixture('documents/documents.json').then((documentsJson) => { |       cy.fixture('documents/documents.json').then((documentsJson) => { | ||||||
| @@ -48,7 +58,6 @@ describe('settings', () => { | |||||||
|  |  | ||||||
|     cy.viewport(1024, 1600) |     cy.viewport(1024, 1600) | ||||||
|     cy.visit('/settings') |     cy.visit('/settings') | ||||||
|     cy.wait('@savedViews') |  | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should activate / deactivate save button when settings change and are saved', () => { |   it('should activate / deactivate save button when settings change and are saved', () => { | ||||||
| @@ -64,7 +73,7 @@ describe('settings', () => { | |||||||
|     cy.contains('a', 'Dashboard').click() |     cy.contains('a', 'Dashboard').click() | ||||||
|     cy.contains('You have unsaved changes') |     cy.contains('You have unsaved changes') | ||||||
|     cy.contains('button', 'Cancel').click() |     cy.contains('button', 'Cancel').click() | ||||||
|     cy.contains('button', 'Save').click().wait('@savedViews') |     cy.contains('button', 'Save').click().wait('@savedViews').wait(2000) | ||||||
|     cy.contains('a', 'Dashboard').click() |     cy.contains('a', 'Dashboard').click() | ||||||
|     cy.contains('You have unsaved changes').should('not.exist') |     cy.contains('You have unsaved changes').should('not.exist') | ||||||
|   }) |   }) | ||||||
| @@ -77,16 +86,16 @@ describe('settings', () => { | |||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should remove saved view from sidebar when unset', () => { |   it('should remove saved view from sidebar when unset', () => { | ||||||
|     cy.contains('a', 'Saved views').click() |     cy.contains('a', 'Saved views').click().wait(2000) | ||||||
|     cy.get('#show_in_sidebar_1').click() |     cy.get('#show_in_sidebar_1').click() | ||||||
|     cy.contains('button', 'Save').click().wait('@savedViews') |     cy.contains('button', 'Save').click().wait('@savedViews').wait(2000) | ||||||
|     cy.contains('li', 'Inbox').should('not.exist') |     cy.contains('li', 'Inbox').should('not.exist') | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   it('should remove saved view from dashboard when unset', () => { |   it('should remove saved view from dashboard when unset', () => { | ||||||
|     cy.contains('a', 'Saved views').click() |     cy.contains('a', 'Saved views').click() | ||||||
|     cy.get('#show_on_dashboard_1').click() |     cy.get('#show_on_dashboard_1').click() | ||||||
|     cy.contains('button', 'Save').click().wait('@savedViews') |     cy.contains('button', 'Save').click().wait('@savedViews').wait(2000) | ||||||
|     cy.visit('/dashboard') |     cy.visit('/dashboard') | ||||||
|     cy.get('app-saved-view-widget').contains('Inbox').should('not.exist') |     cy.get('app-saved-view-widget').contains('Inbox').should('not.exist') | ||||||
|   }) |   }) | ||||||
|   | |||||||
							
								
								
									
										27
									
								
								src-ui/cypress/fixtures/mail_accounts/mail_accounts.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src-ui/cypress/fixtures/mail_accounts/mail_accounts.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | |||||||
|  | { | ||||||
|  |     "count": 2, | ||||||
|  |     "next": null, | ||||||
|  |     "previous": null, | ||||||
|  |     "results": [ | ||||||
|  |         { | ||||||
|  |             "id": 1, | ||||||
|  |             "name": "IMAP Server", | ||||||
|  |             "imap_server": "imap.example.com", | ||||||
|  |             "imap_port": 993, | ||||||
|  |             "imap_security": 2, | ||||||
|  |             "username": "inbox@example.com", | ||||||
|  |             "password": "pass", | ||||||
|  |             "character_set": "UTF-8" | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "id": 2, | ||||||
|  |             "name": "Gmail", | ||||||
|  |             "imap_server": "imap.gmail.com", | ||||||
|  |             "imap_port": 993, | ||||||
|  |             "imap_security": 2, | ||||||
|  |             "username": "user@gmail.com", | ||||||
|  |             "password": "pass", | ||||||
|  |             "character_set": "UTF-8" | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  | } | ||||||
							
								
								
									
										29
									
								
								src-ui/cypress/fixtures/mail_rules/mail_rules.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src-ui/cypress/fixtures/mail_rules/mail_rules.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | |||||||
|  | { | ||||||
|  |     "count": 1, | ||||||
|  |     "next": null, | ||||||
|  |     "previous": null, | ||||||
|  |     "results": [ | ||||||
|  |         { | ||||||
|  |             "id": 1, | ||||||
|  |             "name": "Gmail", | ||||||
|  |             "account": 2, | ||||||
|  |             "folder": "INBOX", | ||||||
|  |             "filter_from": null, | ||||||
|  |             "filter_subject": "[paperless]", | ||||||
|  |             "filter_body": null, | ||||||
|  |             "filter_attachment_filename": null, | ||||||
|  |             "maximum_age": 30, | ||||||
|  |             "action": 3, | ||||||
|  |             "action_parameter": null, | ||||||
|  |             "assign_title_from": 1, | ||||||
|  |             "assign_tags": [ | ||||||
|  |                 9 | ||||||
|  |             ], | ||||||
|  |             "assign_correspondent_from": 1, | ||||||
|  |             "assign_correspondent": 2, | ||||||
|  |             "assign_document_type": null, | ||||||
|  |             "order": 0, | ||||||
|  |             "attachment_type": 2 | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  | } | ||||||
| @@ -1 +1,44 @@ | |||||||
| {"count":3,"next":null,"previous":null,"results":[{"id":1,"name":"Inbox","show_on_dashboard":true,"show_in_sidebar":true,"sort_field":"created","sort_reverse":true,"filter_rules":[{"rule_type":6,"value":"18"}]},{"id":2,"name":"Recently Added","show_on_dashboard":true,"show_in_sidebar":false,"sort_field":"created","sort_reverse":true,"filter_rules":[]},{"id":11,"name":"Taxes","show_on_dashboard":false,"show_in_sidebar":true,"sort_field":"created","sort_reverse":true,"filter_rules":[{"rule_type":6,"value":"39"}]}]} | { | ||||||
|  |     "count": 3, | ||||||
|  |     "next": null, | ||||||
|  |     "previous": null, | ||||||
|  |     "results": [ | ||||||
|  |         { | ||||||
|  |             "id": 1, | ||||||
|  |             "name": "Inbox", | ||||||
|  |             "show_on_dashboard": true, | ||||||
|  |             "show_in_sidebar": true, | ||||||
|  |             "sort_field": "created", | ||||||
|  |             "sort_reverse": true, | ||||||
|  |             "filter_rules": [ | ||||||
|  |                 { | ||||||
|  |                     "rule_type": 6, | ||||||
|  |                     "value": "18" | ||||||
|  |                 } | ||||||
|  |             ] | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "id": 2, | ||||||
|  |             "name": "Recently Added", | ||||||
|  |             "show_on_dashboard": true, | ||||||
|  |             "show_in_sidebar": false, | ||||||
|  |             "sort_field": "created", | ||||||
|  |             "sort_reverse": true, | ||||||
|  |             "filter_rules": [] | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "id": 11, | ||||||
|  |             "name": "Taxes", | ||||||
|  |             "show_on_dashboard": false, | ||||||
|  |             "show_in_sidebar": true, | ||||||
|  |             "sort_field": "created", | ||||||
|  |             "sort_reverse": true, | ||||||
|  |             "filter_rules": [ | ||||||
|  |                 { | ||||||
|  |                     "rule_type": 6, | ||||||
|  |                     "value": "39" | ||||||
|  |                 } | ||||||
|  |             ] | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										1018
									
								
								src-ui/messages.xlf
									
									
									
									
									
								
							
							
						
						
									
										1018
									
								
								src-ui/messages.xlf
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										3239
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3239
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -40,17 +40,23 @@ | |||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@angular-builders/jest": "14.1.0", |     "@angular-builders/jest": "14.1.0", | ||||||
|     "@angular-devkit/build-angular": "~14.2.7", |     "@angular-devkit/build-angular": "~14.2.7", | ||||||
|  |     "@angular-eslint/builder": "14.4.0", | ||||||
|  |     "@angular-eslint/eslint-plugin": "14.4.0", | ||||||
|  |     "@angular-eslint/eslint-plugin-template": "14.4.0", | ||||||
|  |     "@angular-eslint/schematics": "14.4.0", | ||||||
|  |     "@angular-eslint/template-parser": "14.4.0", | ||||||
|     "@angular/cli": "~14.2.7", |     "@angular/cli": "~14.2.7", | ||||||
|     "@angular/compiler-cli": "~14.2.8", |     "@angular/compiler-cli": "~14.2.8", | ||||||
|     "@types/jest": "28.1.6", |     "@types/jest": "28.1.6", | ||||||
|     "@types/node": "^18.7.23", |     "@types/node": "^18.7.23", | ||||||
|     "codelyzer": "^6.0.2", |     "@typescript-eslint/eslint-plugin": "5.43.0", | ||||||
|  |     "@typescript-eslint/parser": "5.43.0", | ||||||
|     "concurrently": "7.4.0", |     "concurrently": "7.4.0", | ||||||
|  |     "eslint": "^8.28.0", | ||||||
|     "jest": "28.1.3", |     "jest": "28.1.3", | ||||||
|     "jest-environment-jsdom": "^29.2.2", |     "jest-environment-jsdom": "^29.2.2", | ||||||
|     "jest-preset-angular": "^12.2.3", |     "jest-preset-angular": "^12.2.3", | ||||||
|     "ts-node": "~10.9.1", |     "ts-node": "~10.9.1", | ||||||
|     "tslint": "~6.1.3", |  | ||||||
|     "typescript": "~4.8.4", |     "typescript": "~4.8.4", | ||||||
|     "wait-on": "~6.0.1" |     "wait-on": "~6.0.1" | ||||||
|   }, |   }, | ||||||
|   | |||||||
| @@ -47,6 +47,11 @@ const routes: Routes = [ | |||||||
|         component: SettingsComponent, |         component: SettingsComponent, | ||||||
|         canDeactivate: [DirtyFormGuard], |         canDeactivate: [DirtyFormGuard], | ||||||
|       }, |       }, | ||||||
|  |       { | ||||||
|  |         path: 'settings/:section', | ||||||
|  |         component: SettingsComponent, | ||||||
|  |         canDeactivate: [DirtyFormGuard], | ||||||
|  |       }, | ||||||
|       { path: 'tasks', component: TasksComponent }, |       { path: 'tasks', component: TasksComponent }, | ||||||
|     ], |     ], | ||||||
|   }, |   }, | ||||||
|   | |||||||
| @@ -191,21 +191,13 @@ export class AppComponent implements OnInit, OnDestroy { | |||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         anchorId: 'tour.settings', |         anchorId: 'tour.settings', | ||||||
|         content: $localize`Check out the settings for various tweaks to the web app or to toggle settings for saved views.`, |         content: $localize`Check out the settings for various tweaks to the web app, toggle settings for saved views or setup e-mail checking.`, | ||||||
|         route: '/settings', |         route: '/settings', | ||||||
|         enableBackdrop: true, |         enableBackdrop: true, | ||||||
|         prevBtnTitle, |         prevBtnTitle, | ||||||
|         nextBtnTitle, |         nextBtnTitle, | ||||||
|         endBtnTitle, |         endBtnTitle, | ||||||
|       }, |       }, | ||||||
|       { |  | ||||||
|         anchorId: 'tour.admin', |  | ||||||
|         content: $localize`The Admin area contains more advanced controls as well as the settings for automatic e-mail fetching.`, |  | ||||||
|         enableBackdrop: true, |  | ||||||
|         prevBtnTitle, |  | ||||||
|         nextBtnTitle, |  | ||||||
|         endBtnTitle, |  | ||||||
|       }, |  | ||||||
|       { |       { | ||||||
|         anchorId: 'tour.outro', |         anchorId: 'tour.outro', | ||||||
|         title: $localize`Thank you! 🙏`, |         title: $localize`Thank you! 🙏`, | ||||||
|   | |||||||
| @@ -24,7 +24,7 @@ import { CorrespondentEditDialogComponent } from './components/common/edit-dialo | |||||||
| import { TagEditDialogComponent } from './components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' | import { TagEditDialogComponent } from './components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' | ||||||
| import { DocumentTypeEditDialogComponent } from './components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' | import { DocumentTypeEditDialogComponent } from './components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' | ||||||
| import { TagComponent } from './components/common/tag/tag.component' | import { TagComponent } from './components/common/tag/tag.component' | ||||||
| import { ClearableBadge } from './components/common/clearable-badge/clearable-badge.component' | import { ClearableBadgeComponent } from './components/common/clearable-badge/clearable-badge.component' | ||||||
| import { PageHeaderComponent } from './components/common/page-header/page-header.component' | import { PageHeaderComponent } from './components/common/page-header/page-header.component' | ||||||
| import { AppFrameComponent } from './components/app-frame/app-frame.component' | import { AppFrameComponent } from './components/app-frame/app-frame.component' | ||||||
| import { ToastsComponent } from './components/common/toasts/toasts.component' | import { ToastsComponent } from './components/common/toasts/toasts.component' | ||||||
| @@ -39,6 +39,7 @@ import { NgxFileDropModule } from 'ngx-file-drop' | |||||||
| import { TextComponent } from './components/common/input/text/text.component' | import { TextComponent } from './components/common/input/text/text.component' | ||||||
| import { SelectComponent } from './components/common/input/select/select.component' | import { SelectComponent } from './components/common/input/select/select.component' | ||||||
| import { CheckComponent } from './components/common/input/check/check.component' | import { CheckComponent } from './components/common/input/check/check.component' | ||||||
|  | import { PasswordComponent } from './components/common/input/password/password.component' | ||||||
| import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component' | import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component' | ||||||
| import { TagsComponent } from './components/common/input/tags/tags.component' | import { TagsComponent } from './components/common/input/tags/tags.component' | ||||||
| import { SortableDirective } from './directives/sortable.directive' | import { SortableDirective } from './directives/sortable.directive' | ||||||
| @@ -76,6 +77,8 @@ import { StoragePathEditDialogComponent } from './components/common/edit-dialog/ | |||||||
| import { SettingsService } from './services/settings.service' | import { SettingsService } from './services/settings.service' | ||||||
| import { TasksComponent } from './components/manage/tasks/tasks.component' | import { TasksComponent } from './components/manage/tasks/tasks.component' | ||||||
| import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap' | import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap' | ||||||
|  | import { MailAccountEditDialogComponent } from './components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component' | ||||||
|  | import { MailRuleEditDialogComponent } from './components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component' | ||||||
|  |  | ||||||
| import localeBe from '@angular/common/locales/be' | import localeBe from '@angular/common/locales/be' | ||||||
| import localeCs from '@angular/common/locales/cs' | import localeCs from '@angular/common/locales/cs' | ||||||
| @@ -143,7 +146,7 @@ function initializeApp(settings: SettingsService) { | |||||||
|     DocumentTypeEditDialogComponent, |     DocumentTypeEditDialogComponent, | ||||||
|     StoragePathEditDialogComponent, |     StoragePathEditDialogComponent, | ||||||
|     TagComponent, |     TagComponent, | ||||||
|     ClearableBadge, |     ClearableBadgeComponent, | ||||||
|     PageHeaderComponent, |     PageHeaderComponent, | ||||||
|     AppFrameComponent, |     AppFrameComponent, | ||||||
|     ToastsComponent, |     ToastsComponent, | ||||||
| @@ -157,6 +160,7 @@ function initializeApp(settings: SettingsService) { | |||||||
|     TextComponent, |     TextComponent, | ||||||
|     SelectComponent, |     SelectComponent, | ||||||
|     CheckComponent, |     CheckComponent, | ||||||
|  |     PasswordComponent, | ||||||
|     SaveViewConfigDialogComponent, |     SaveViewConfigDialogComponent, | ||||||
|     TagsComponent, |     TagsComponent, | ||||||
|     SortableDirective, |     SortableDirective, | ||||||
| @@ -180,6 +184,8 @@ function initializeApp(settings: SettingsService) { | |||||||
|     DocumentAsnComponent, |     DocumentAsnComponent, | ||||||
|     DocumentCommentsComponent, |     DocumentCommentsComponent, | ||||||
|     TasksComponent, |     TasksComponent, | ||||||
|  |     MailAccountEditDialogComponent, | ||||||
|  |     MailRuleEditDialogComponent, | ||||||
|   ], |   ], | ||||||
|   imports: [ |   imports: [ | ||||||
|     BrowserModule, |     BrowserModule, | ||||||
|   | |||||||
| @@ -174,13 +174,6 @@ | |||||||
|               </svg><span> <ng-container i18n>Settings</ng-container></span> |               </svg><span> <ng-container i18n>Settings</ng-container></span> | ||||||
|             </a> |             </a> | ||||||
|           </li> |           </li> | ||||||
|           <li class="nav-item" tourAnchor="tour.admin"> |  | ||||||
|             <a class="nav-link" href="admin/" ngbPopover="Admin" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> |  | ||||||
|               <svg class="sidebaricon" fill="currentColor"> |  | ||||||
|                 <use xlink:href="assets/bootstrap-icons.svg#toggles"/> |  | ||||||
|               </svg><span> <ng-container i18n>Admin</ng-container></span> |  | ||||||
|             </a> |  | ||||||
|           </li> |  | ||||||
|         </ul> |         </ul> | ||||||
|  |  | ||||||
|         <h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted"> |         <h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted"> | ||||||
|   | |||||||
| @@ -220,6 +220,12 @@ main { | |||||||
|   font-size: 1rem; |   font-size: 1rem; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @media screen and (min-width: 768px) { | ||||||
|  |   .navbar-brand.slim { | ||||||
|  |     max-width: 50px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
| .dropdown.show .dropdown-toggle, | .dropdown.show .dropdown-toggle, | ||||||
| .dropdown-toggle:hover { | .dropdown-toggle:hover { | ||||||
|   opacity: 0.7; |   opacity: 0.7; | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ import { Component, Input, Output, EventEmitter } from '@angular/core' | |||||||
|   templateUrl: './clearable-badge.component.html', |   templateUrl: './clearable-badge.component.html', | ||||||
|   styleUrls: ['./clearable-badge.component.scss'], |   styleUrls: ['./clearable-badge.component.scss'], | ||||||
| }) | }) | ||||||
| export class ClearableBadge { | export class ClearableBadgeComponent { | ||||||
|   constructor() {} |   constructor() {} | ||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
|   | |||||||
| @@ -5,7 +5,6 @@ import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit- | |||||||
| import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' | import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' | ||||||
| import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' | import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' | ||||||
| import { CorrespondentService } from 'src/app/services/rest/correspondent.service' | import { CorrespondentService } from 'src/app/services/rest/correspondent.service' | ||||||
| import { ToastService } from 'src/app/services/toast.service' |  | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-correspondent-edit-dialog', |   selector: 'app-correspondent-edit-dialog', | ||||||
| @@ -13,12 +12,8 @@ import { ToastService } from 'src/app/services/toast.service' | |||||||
|   styleUrls: ['./correspondent-edit-dialog.component.scss'], |   styleUrls: ['./correspondent-edit-dialog.component.scss'], | ||||||
| }) | }) | ||||||
| export class CorrespondentEditDialogComponent extends EditDialogComponent<PaperlessCorrespondent> { | export class CorrespondentEditDialogComponent extends EditDialogComponent<PaperlessCorrespondent> { | ||||||
|   constructor( |   constructor(service: CorrespondentService, activeModal: NgbActiveModal) { | ||||||
|     service: CorrespondentService, |     super(service, activeModal) | ||||||
|     activeModal: NgbActiveModal, |  | ||||||
|     toastService: ToastService |  | ||||||
|   ) { |  | ||||||
|     super(service, activeModal, toastService) |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   getCreateTitle() { |   getCreateTitle() { | ||||||
|   | |||||||
| @@ -5,7 +5,6 @@ import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit- | |||||||
| import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' | import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' | ||||||
| import { PaperlessDocumentType } from 'src/app/data/paperless-document-type' | import { PaperlessDocumentType } from 'src/app/data/paperless-document-type' | ||||||
| import { DocumentTypeService } from 'src/app/services/rest/document-type.service' | import { DocumentTypeService } from 'src/app/services/rest/document-type.service' | ||||||
| import { ToastService } from 'src/app/services/toast.service' |  | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-document-type-edit-dialog', |   selector: 'app-document-type-edit-dialog', | ||||||
| @@ -13,12 +12,8 @@ import { ToastService } from 'src/app/services/toast.service' | |||||||
|   styleUrls: ['./document-type-edit-dialog.component.scss'], |   styleUrls: ['./document-type-edit-dialog.component.scss'], | ||||||
| }) | }) | ||||||
| export class DocumentTypeEditDialogComponent extends EditDialogComponent<PaperlessDocumentType> { | export class DocumentTypeEditDialogComponent extends EditDialogComponent<PaperlessDocumentType> { | ||||||
|   constructor( |   constructor(service: DocumentTypeService, activeModal: NgbActiveModal) { | ||||||
|     service: DocumentTypeService, |     super(service, activeModal) | ||||||
|     activeModal: NgbActiveModal, |  | ||||||
|     toastService: ToastService |  | ||||||
|   ) { |  | ||||||
|     super(service, activeModal, toastService) |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   getCreateTitle() { |   getCreateTitle() { | ||||||
|   | |||||||
| @@ -2,11 +2,9 @@ import { Directive, EventEmitter, Input, OnInit, Output } from '@angular/core' | |||||||
| import { FormGroup } from '@angular/forms' | import { FormGroup } from '@angular/forms' | ||||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||||
| import { Observable } from 'rxjs' | import { Observable } from 'rxjs' | ||||||
| import { map } from 'rxjs/operators' |  | ||||||
| import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model' | import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model' | ||||||
| import { ObjectWithId } from 'src/app/data/object-with-id' | import { ObjectWithId } from 'src/app/data/object-with-id' | ||||||
| import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service' | import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service' | ||||||
| import { ToastService } from 'src/app/services/toast.service' |  | ||||||
|  |  | ||||||
| @Directive() | @Directive() | ||||||
| export abstract class EditDialogComponent<T extends ObjectWithId> | export abstract class EditDialogComponent<T extends ObjectWithId> | ||||||
| @@ -14,8 +12,7 @@ export abstract class EditDialogComponent<T extends ObjectWithId> | |||||||
| { | { | ||||||
|   constructor( |   constructor( | ||||||
|     private service: AbstractPaperlessService<T>, |     private service: AbstractPaperlessService<T>, | ||||||
|     private activeModal: NgbActiveModal, |     private activeModal: NgbActiveModal | ||||||
|     private toastService: ToastService |  | ||||||
|   ) {} |   ) {} | ||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
| @@ -25,7 +22,7 @@ export abstract class EditDialogComponent<T extends ObjectWithId> | |||||||
|   object: T |   object: T | ||||||
|  |  | ||||||
|   @Output() |   @Output() | ||||||
|   success = new EventEmitter() |   succeeded = new EventEmitter() | ||||||
|  |  | ||||||
|   networkActive = false |   networkActive = false | ||||||
|  |  | ||||||
| @@ -95,16 +92,16 @@ export abstract class EditDialogComponent<T extends ObjectWithId> | |||||||
|         break |         break | ||||||
|     } |     } | ||||||
|     this.networkActive = true |     this.networkActive = true | ||||||
|     serverResponse.subscribe( |     serverResponse.subscribe({ | ||||||
|       (result) => { |       next: (result) => { | ||||||
|         this.activeModal.close() |         this.activeModal.close() | ||||||
|         this.success.emit(result) |         this.succeeded.emit(result) | ||||||
|       }, |       }, | ||||||
|       (error) => { |       error: (error) => { | ||||||
|         this.error = error.error |         this.error = error.error | ||||||
|         this.networkActive = false |         this.networkActive = false | ||||||
|       } |       }, | ||||||
|     ) |     }) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   cancel() { |   cancel() { | ||||||
|   | |||||||
| @@ -0,0 +1,26 @@ | |||||||
|  | <form [formGroup]="objectForm" (ngSubmit)="save()"> | ||||||
|  |   <div class="modal-header"> | ||||||
|  |     <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> | ||||||
|  |     <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()"> | ||||||
|  |     </button> | ||||||
|  |   </div> | ||||||
|  |   <div class="modal-body"> | ||||||
|  |     <div class="row"> | ||||||
|  |       <div class="col"> | ||||||
|  |         <app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text> | ||||||
|  |         <app-input-text i18n-title title="IMAP Server" formControlName="imap_server" [error]="error?.imap_server"></app-input-text> | ||||||
|  |         <app-input-text i18n-title title="IMAP Port" formControlName="imap_port" [error]="error?.imap_port"></app-input-text> | ||||||
|  |         <app-input-select i18n-title title="IMAP Security" [items]="imapSecurityOptions" formControlName="imap_security"></app-input-select> | ||||||
|  |       </div> | ||||||
|  |       <div class="col"> | ||||||
|  |         <app-input-text i18n-title title="Username" formControlName="username" [error]="error?.username"></app-input-text> | ||||||
|  |         <app-input-password i18n-title title="Password" formControlName="password" [error]="error?.password"></app-input-password> | ||||||
|  |         <app-input-text i18n-title title="Character Set" formControlName="character_set" [error]="error?.character_set"></app-input-text> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  |   <div class="modal-footer"> | ||||||
|  |     <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> | ||||||
|  |     <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button> | ||||||
|  |   </div> | ||||||
|  | </form> | ||||||
| @@ -0,0 +1,50 @@ | |||||||
|  | import { Component } from '@angular/core' | ||||||
|  | import { FormControl, FormGroup } from '@angular/forms' | ||||||
|  | import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||||
|  | import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' | ||||||
|  | import { | ||||||
|  |   IMAPSecurity, | ||||||
|  |   PaperlessMailAccount, | ||||||
|  | } from 'src/app/data/paperless-mail-account' | ||||||
|  | import { MailAccountService } from 'src/app/services/rest/mail-account.service' | ||||||
|  |  | ||||||
|  | const IMAP_SECURITY_OPTIONS = [ | ||||||
|  |   { id: IMAPSecurity.None, name: $localize`No encryption` }, | ||||||
|  |   { id: IMAPSecurity.SSL, name: $localize`SSL` }, | ||||||
|  |   { id: IMAPSecurity.STARTTLS, name: $localize`STARTTLS` }, | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | @Component({ | ||||||
|  |   selector: 'app-mail-account-edit-dialog', | ||||||
|  |   templateUrl: './mail-account-edit-dialog.component.html', | ||||||
|  |   styleUrls: ['./mail-account-edit-dialog.component.scss'], | ||||||
|  | }) | ||||||
|  | export class MailAccountEditDialogComponent extends EditDialogComponent<PaperlessMailAccount> { | ||||||
|  |   constructor(service: MailAccountService, activeModal: NgbActiveModal) { | ||||||
|  |     super(service, activeModal) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   getCreateTitle() { | ||||||
|  |     return $localize`Create new mail account` | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   getEditTitle() { | ||||||
|  |     return $localize`Edit mail account` | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   getForm(): FormGroup { | ||||||
|  |     return new FormGroup({ | ||||||
|  |       name: new FormControl(null), | ||||||
|  |       imap_server: new FormControl(null), | ||||||
|  |       imap_port: new FormControl(null), | ||||||
|  |       imap_security: new FormControl(IMAPSecurity.SSL), | ||||||
|  |       username: new FormControl(null), | ||||||
|  |       password: new FormControl(null), | ||||||
|  |       character_set: new FormControl('UTF-8'), | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   get imapSecurityOptions() { | ||||||
|  |     return IMAP_SECURITY_OPTIONS | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1,39 @@ | |||||||
|  | <form [formGroup]="objectForm" (ngSubmit)="save()"> | ||||||
|  |   <div class="modal-header"> | ||||||
|  |     <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> | ||||||
|  |     <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()"> | ||||||
|  |     </button> | ||||||
|  |   </div> | ||||||
|  |   <div class="modal-body"> | ||||||
|  |     <div class="row"> | ||||||
|  |       <div class="col"> | ||||||
|  |         <app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text> | ||||||
|  |         <app-input-select i18n-title title="Account" [items]="accounts" formControlName="account"></app-input-select> | ||||||
|  |         <app-input-text i18n-title title="Folder" formControlName="folder" i18n-hint hint="Subfolders must be separated by a delimiter, often a dot ('.') or slash ('/'), but it varies by mail server." [error]="error?.folder"></app-input-text> | ||||||
|  |         <app-input-number i18n-title title="Maximum age (days)" formControlName="maximum_age" [showAdd]="false" [error]="error?.maximum_age"></app-input-number> | ||||||
|  |         <app-input-select i18n-title title="Attachment type" [items]="attachmentTypeOptions" formControlName="attachment_type"></app-input-select> | ||||||
|  |       </div> | ||||||
|  |       <div class="col"> | ||||||
|  |         <p class="small" i18n>Paperless will only process mails that match <em>all</em> of the filters specified below.</p> | ||||||
|  |         <app-input-text i18n-title title="Filter from" formControlName="filter_from" [error]="error?.filter_from"></app-input-text> | ||||||
|  |         <app-input-text i18n-title title="Filter subject" formControlName="filter_subject" [error]="error?.filter_subject"></app-input-text> | ||||||
|  |         <app-input-text i18n-title title="Filter body" formControlName="filter_body" [error]="error?.filter_body"></app-input-text> | ||||||
|  |         <app-input-text i18n-title title="Filter attachment filename" formControlName="filter_attachment_filename" i18n-hint hint="Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_attachment_filename"></app-input-text> | ||||||
|  |       </div> | ||||||
|  |       <div class="col"> | ||||||
|  |         <app-input-select i18n-title title="Action" [items]="actionOptions" formControlName="action" i18n-hint hint="Action is only performed when documents are consumed from the mail. Mails without attachments remain entirely untouched."></app-input-select> | ||||||
|  |         <app-input-text i18n-title title="Action parameter" *ngIf="showActionParamField" formControlName="action_parameter" [error]="error?.action_parameter"></app-input-text> | ||||||
|  |         <app-input-select i18n-title title="Assign title from" [items]="metadataTitleOptions" formControlName="assign_title_from"></app-input-select> | ||||||
|  |         <app-input-tags [allowCreate]="false" formControlName="assign_tags"></app-input-tags> | ||||||
|  |         <app-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></app-input-select> | ||||||
|  |         <app-input-select i18n-title title="Assign correspondent from" [items]="metadataCorrespondentOptions" formControlName="assign_correspondent_from"></app-input-select> | ||||||
|  |         <app-input-select *ngIf="showCorrespondentField" i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></app-input-select> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  |   <div class="modal-footer"> | ||||||
|  |     <span class="text-danger" *ngIf="error?.non_field_errors"><ng-container i18n>Error</ng-container>: {{error.non_field_errors}}</span> | ||||||
|  |     <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> | ||||||
|  |     <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button> | ||||||
|  |   </div> | ||||||
|  | </form> | ||||||
| @@ -0,0 +1,180 @@ | |||||||
|  | import { Component } from '@angular/core' | ||||||
|  | import { FormControl, FormGroup } from '@angular/forms' | ||||||
|  | import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||||
|  | import { first } from 'rxjs' | ||||||
|  | import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' | ||||||
|  | import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' | ||||||
|  | import { PaperlessDocumentType } from 'src/app/data/paperless-document-type' | ||||||
|  | import { PaperlessMailAccount } from 'src/app/data/paperless-mail-account' | ||||||
|  | import { | ||||||
|  |   MailAction, | ||||||
|  |   MailFilterAttachmentType, | ||||||
|  |   MailMetadataCorrespondentOption, | ||||||
|  |   MailMetadataTitleOption, | ||||||
|  |   PaperlessMailRule, | ||||||
|  | } from 'src/app/data/paperless-mail-rule' | ||||||
|  | import { CorrespondentService } from 'src/app/services/rest/correspondent.service' | ||||||
|  | import { DocumentTypeService } from 'src/app/services/rest/document-type.service' | ||||||
|  | import { MailAccountService } from 'src/app/services/rest/mail-account.service' | ||||||
|  | import { MailRuleService } from 'src/app/services/rest/mail-rule.service' | ||||||
|  |  | ||||||
|  | const ATTACHMENT_TYPE_OPTIONS = [ | ||||||
|  |   { | ||||||
|  |     id: MailFilterAttachmentType.Attachments, | ||||||
|  |     name: $localize`Only process attachments.`, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: MailFilterAttachmentType.Everything, | ||||||
|  |     name: $localize`Process all files, including 'inline' attachments.`, | ||||||
|  |   }, | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | const ACTION_OPTIONS = [ | ||||||
|  |   { | ||||||
|  |     id: MailAction.Delete, | ||||||
|  |     name: $localize`Delete`, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: MailAction.Move, | ||||||
|  |     name: $localize`Move to specified folder`, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: MailAction.MarkRead, | ||||||
|  |     name: $localize`Mark as read, don't process read mails`, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: MailAction.Flag, | ||||||
|  |     name: $localize`Flag the mail, don't process flagged mails`, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: MailAction.Tag, | ||||||
|  |     name: $localize`Tag the mail with specified tag, don't process tagged mails`, | ||||||
|  |   }, | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | const METADATA_TITLE_OPTIONS = [ | ||||||
|  |   { | ||||||
|  |     id: MailMetadataTitleOption.FromSubject, | ||||||
|  |     name: $localize`Use subject as title`, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: MailMetadataTitleOption.FromFilename, | ||||||
|  |     name: $localize`Use attachment filename as title`, | ||||||
|  |   }, | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | const METADATA_CORRESPONDENT_OPTIONS = [ | ||||||
|  |   { | ||||||
|  |     id: MailMetadataCorrespondentOption.FromNothing, | ||||||
|  |     name: $localize`Do not assign a correspondent`, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: MailMetadataCorrespondentOption.FromEmail, | ||||||
|  |     name: $localize`Use mail address`, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: MailMetadataCorrespondentOption.FromName, | ||||||
|  |     name: $localize`Use name (or mail address if not available)`, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     id: MailMetadataCorrespondentOption.FromCustom, | ||||||
|  |     name: $localize`Use correspondent selected below`, | ||||||
|  |   }, | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | @Component({ | ||||||
|  |   selector: 'app-mail-rule-edit-dialog', | ||||||
|  |   templateUrl: './mail-rule-edit-dialog.component.html', | ||||||
|  |   styleUrls: ['./mail-rule-edit-dialog.component.scss'], | ||||||
|  | }) | ||||||
|  | export class MailRuleEditDialogComponent extends EditDialogComponent<PaperlessMailRule> { | ||||||
|  |   accounts: PaperlessMailAccount[] | ||||||
|  |   correspondents: PaperlessCorrespondent[] | ||||||
|  |   documentTypes: PaperlessDocumentType[] | ||||||
|  |  | ||||||
|  |   constructor( | ||||||
|  |     service: MailRuleService, | ||||||
|  |     activeModal: NgbActiveModal, | ||||||
|  |     accountService: MailAccountService, | ||||||
|  |     correspondentService: CorrespondentService, | ||||||
|  |     documentTypeService: DocumentTypeService | ||||||
|  |   ) { | ||||||
|  |     super(service, activeModal) | ||||||
|  |  | ||||||
|  |     accountService | ||||||
|  |       .listAll() | ||||||
|  |       .pipe(first()) | ||||||
|  |       .subscribe((result) => (this.accounts = result.results)) | ||||||
|  |  | ||||||
|  |     correspondentService | ||||||
|  |       .listAll() | ||||||
|  |       .pipe(first()) | ||||||
|  |       .subscribe((result) => (this.correspondents = result.results)) | ||||||
|  |  | ||||||
|  |     documentTypeService | ||||||
|  |       .listAll() | ||||||
|  |       .pipe(first()) | ||||||
|  |       .subscribe((result) => (this.documentTypes = result.results)) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   getCreateTitle() { | ||||||
|  |     return $localize`Create new mail rule` | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   getEditTitle() { | ||||||
|  |     return $localize`Edit mail rule` | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   getForm(): FormGroup { | ||||||
|  |     return new FormGroup({ | ||||||
|  |       name: new FormControl(null), | ||||||
|  |       account: new FormControl(null), | ||||||
|  |       folder: new FormControl('INBOX'), | ||||||
|  |       filter_from: new FormControl(null), | ||||||
|  |       filter_subject: new FormControl(null), | ||||||
|  |       filter_body: new FormControl(null), | ||||||
|  |       filter_attachment_filename: new FormControl(null), | ||||||
|  |       maximum_age: new FormControl(null), | ||||||
|  |       attachment_type: new FormControl(MailFilterAttachmentType.Attachments), | ||||||
|  |       action: new FormControl(MailAction.MarkRead), | ||||||
|  |       action_parameter: new FormControl(null), | ||||||
|  |       assign_title_from: new FormControl(MailMetadataTitleOption.FromSubject), | ||||||
|  |       assign_tags: new FormControl([]), | ||||||
|  |       assign_document_type: new FormControl(null), | ||||||
|  |       assign_correspondent_from: new FormControl( | ||||||
|  |         MailMetadataCorrespondentOption.FromNothing | ||||||
|  |       ), | ||||||
|  |       assign_correspondent: new FormControl(null), | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   get showCorrespondentField(): boolean { | ||||||
|  |     return ( | ||||||
|  |       this.objectForm?.get('assign_correspondent_from')?.value == | ||||||
|  |       MailMetadataCorrespondentOption.FromCustom | ||||||
|  |     ) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   get showActionParamField(): boolean { | ||||||
|  |     return ( | ||||||
|  |       this.objectForm?.get('action')?.value == MailAction.Move || | ||||||
|  |       this.objectForm?.get('action')?.value == MailAction.Tag | ||||||
|  |     ) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   get attachmentTypeOptions() { | ||||||
|  |     return ATTACHMENT_TYPE_OPTIONS | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   get actionOptions() { | ||||||
|  |     return ACTION_OPTIONS | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   get metadataTitleOptions() { | ||||||
|  |     return METADATA_TITLE_OPTIONS | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   get metadataCorrespondentOptions() { | ||||||
|  |     return METADATA_CORRESPONDENT_OPTIONS | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -6,7 +6,7 @@ | |||||||
|   </div> |   </div> | ||||||
|   <div class="modal-body"> |   <div class="modal-body"> | ||||||
|  |  | ||||||
|     <p *ngIf="this.dialogMode == 'edit'" i18n> |     <p *ngIf="this.dialogMode === 'edit'" i18n> | ||||||
|       <em>Note that editing a path does not apply changes to stored files until you have run the 'document_renamer' utility. See the <a target="_blank" href="https://docs.paperless-ngx.com/administration/#renamer">documentation</a>.</em> |       <em>Note that editing a path does not apply changes to stored files until you have run the 'document_renamer' utility. See the <a target="_blank" href="https://docs.paperless-ngx.com/administration/#renamer">documentation</a>.</em> | ||||||
|     </p> |     </p> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,7 +5,6 @@ import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit- | |||||||
| import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' | import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' | ||||||
| import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' | import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' | ||||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.service' | import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||||
| import { ToastService } from 'src/app/services/toast.service' |  | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-storage-path-edit-dialog', |   selector: 'app-storage-path-edit-dialog', | ||||||
| @@ -13,12 +12,8 @@ import { ToastService } from 'src/app/services/toast.service' | |||||||
|   styleUrls: ['./storage-path-edit-dialog.component.scss'], |   styleUrls: ['./storage-path-edit-dialog.component.scss'], | ||||||
| }) | }) | ||||||
| export class StoragePathEditDialogComponent extends EditDialogComponent<PaperlessStoragePath> { | export class StoragePathEditDialogComponent extends EditDialogComponent<PaperlessStoragePath> { | ||||||
|   constructor( |   constructor(service: StoragePathService, activeModal: NgbActiveModal) { | ||||||
|     service: StoragePathService, |     super(service, activeModal) | ||||||
|     activeModal: NgbActiveModal, |  | ||||||
|     toastService: ToastService |  | ||||||
|   ) { |  | ||||||
|     super(service, activeModal, toastService) |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   get pathHint() { |   get pathHint() { | ||||||
|   | |||||||
| @@ -4,7 +4,6 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | |||||||
| import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' | import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' | ||||||
| import { PaperlessTag } from 'src/app/data/paperless-tag' | import { PaperlessTag } from 'src/app/data/paperless-tag' | ||||||
| import { TagService } from 'src/app/services/rest/tag.service' | import { TagService } from 'src/app/services/rest/tag.service' | ||||||
| import { ToastService } from 'src/app/services/toast.service' |  | ||||||
| import { randomColor } from 'src/app/utils/color' | import { randomColor } from 'src/app/utils/color' | ||||||
| import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' | import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' | ||||||
|  |  | ||||||
| @@ -14,12 +13,8 @@ import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' | |||||||
|   styleUrls: ['./tag-edit-dialog.component.scss'], |   styleUrls: ['./tag-edit-dialog.component.scss'], | ||||||
| }) | }) | ||||||
| export class TagEditDialogComponent extends EditDialogComponent<PaperlessTag> { | export class TagEditDialogComponent extends EditDialogComponent<PaperlessTag> { | ||||||
|   constructor( |   constructor(service: TagService, activeModal: NgbActiveModal) { | ||||||
|     service: TagService, |     super(service, activeModal) | ||||||
|     activeModal: NgbActiveModal, |  | ||||||
|     toastService: ToastService |  | ||||||
|   ) { |  | ||||||
|     super(service, activeModal, toastService) |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   getCreateTitle() { |   getCreateTitle() { | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ | |||||||
|       <use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" /> |       <use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" /> | ||||||
|     </svg> |     </svg> | ||||||
|     <div class="d-none d-sm-inline"> {{title}}</div> |     <div class="d-none d-sm-inline"> {{title}}</div> | ||||||
|     <ng-container *ngIf="!editing && selectionModel.selectionSize() > 0"> |     <ng-container *ngIf="!editing && selectionModel.totalCount > 0"> | ||||||
|       <app-clearable-badge [number]="multiple ? selectionModel.totalCount : undefined" [selected]="!multiple && selectionModel.selectionSize() > 0" (cleared)="reset()"></app-clearable-badge> |       <app-clearable-badge [number]="multiple ? selectionModel.totalCount : undefined" [selected]="!multiple && selectionModel.selectionSize() > 0" (cleared)="reset()"></app-clearable-badge> | ||||||
|     </ng-container> |     </ng-container> | ||||||
|   </button> |   </button> | ||||||
|   | |||||||
| @@ -321,7 +321,7 @@ export class FilterableDropdownComponent { | |||||||
|   apply = new EventEmitter<ChangedItems>() |   apply = new EventEmitter<ChangedItems>() | ||||||
|  |  | ||||||
|   @Output() |   @Output() | ||||||
|   open = new EventEmitter() |   opened = new EventEmitter() | ||||||
|  |  | ||||||
|   get operatorToggleEnabled(): boolean { |   get operatorToggleEnabled(): boolean { | ||||||
|     return ( |     return ( | ||||||
| @@ -356,7 +356,7 @@ export class FilterableDropdownComponent { | |||||||
|       if (this.editing) { |       if (this.editing) { | ||||||
|         this.selectionModel.reset() |         this.selectionModel.reset() | ||||||
|       } |       } | ||||||
|       this.open.next(this) |       this.opened.next(this) | ||||||
|     } else { |     } else { | ||||||
|       this.filterText = '' |       this.filterText = '' | ||||||
|       if (this.applyOnClose && this.selectionModel.isDirty()) { |       if (this.applyOnClose && this.selectionModel.isDirty()) { | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ | |||||||
|   <label class="form-label" [for]="inputId">{{title}}</label> |   <label class="form-label" [for]="inputId">{{title}}</label> | ||||||
|   <div class="input-group" [class.is-invalid]="error"> |   <div class="input-group" [class.is-invalid]="error"> | ||||||
|     <input type="number" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error"> |     <input type="number" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error"> | ||||||
|     <button class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="value">+1</button> |     <button *ngIf="showAdd" class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="value">+1</button> | ||||||
|   </div> |   </div> | ||||||
|   <div class="invalid-feedback"> |   <div class="invalid-feedback"> | ||||||
|     {{error}} |     {{error}} | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { Component, forwardRef } from '@angular/core' | import { Component, forwardRef, Input } from '@angular/core' | ||||||
| import { NG_VALUE_ACCESSOR } from '@angular/forms' | import { NG_VALUE_ACCESSOR } from '@angular/forms' | ||||||
| import { FILTER_ASN_ISNULL } from 'src/app/data/filter-rule-type' | import { FILTER_ASN_ISNULL } from 'src/app/data/filter-rule-type' | ||||||
| import { DocumentService } from 'src/app/services/rest/document.service' | import { DocumentService } from 'src/app/services/rest/document.service' | ||||||
| @@ -17,6 +17,9 @@ import { AbstractInputComponent } from '../abstract-input' | |||||||
|   styleUrls: ['./number.component.scss'], |   styleUrls: ['./number.component.scss'], | ||||||
| }) | }) | ||||||
| export class NumberComponent extends AbstractInputComponent<number> { | export class NumberComponent extends AbstractInputComponent<number> { | ||||||
|  |   @Input() | ||||||
|  |   showAdd: boolean = true | ||||||
|  |  | ||||||
|   constructor(private documentService: DocumentService) { |   constructor(private documentService: DocumentService) { | ||||||
|     super() |     super() | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -0,0 +1,8 @@ | |||||||
|  | <div class="mb-3"> | ||||||
|  |   <label class="form-label" [for]="inputId">{{title}}</label> | ||||||
|  |   <input #inputField type="password" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)"> | ||||||
|  |   <small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small> | ||||||
|  |   <div class="invalid-feedback"> | ||||||
|  |     {{error}} | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
| @@ -0,0 +1,21 @@ | |||||||
|  | import { Component, forwardRef } from '@angular/core' | ||||||
|  | import { NG_VALUE_ACCESSOR } from '@angular/forms' | ||||||
|  | import { AbstractInputComponent } from '../abstract-input' | ||||||
|  |  | ||||||
|  | @Component({ | ||||||
|  |   providers: [ | ||||||
|  |     { | ||||||
|  |       provide: NG_VALUE_ACCESSOR, | ||||||
|  |       useExisting: forwardRef(() => PasswordComponent), | ||||||
|  |       multi: true, | ||||||
|  |     }, | ||||||
|  |   ], | ||||||
|  |   selector: 'app-input-password', | ||||||
|  |   templateUrl: './password.component.html', | ||||||
|  |   styleUrls: ['./password.component.scss'], | ||||||
|  | }) | ||||||
|  | export class PasswordComponent extends AbstractInputComponent<string> { | ||||||
|  |   constructor() { | ||||||
|  |     super() | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -7,7 +7,7 @@ | |||||||
|       [closeOnSelect]="false" |       [closeOnSelect]="false" | ||||||
|       [clearSearchOnAdd]="true" |       [clearSearchOnAdd]="true" | ||||||
|       [hideSelected]="true" |       [hideSelected]="true" | ||||||
|       [addTag]="createTagRef" |       [addTag]="allowCreate ? createTagRef : false" | ||||||
|       addTagText="Add tag" |       addTagText="Add tag" | ||||||
|       i18n-addTagText |       i18n-addTagText | ||||||
|       (change)="onChange(value)" |       (change)="onChange(value)" | ||||||
| @@ -31,7 +31,7 @@ | |||||||
|       </ng-template> |       </ng-template> | ||||||
|     </ng-select> |     </ng-select> | ||||||
|  |  | ||||||
|     <button class="btn btn-outline-secondary" type="button" (click)="createTag()"> |     <button *ngIf="allowCreate" class="btn btn-outline-secondary" type="button" (click)="createTag()"> | ||||||
|       <svg class="buttonicon" fill="currentColor"> |       <svg class="buttonicon" fill="currentColor"> | ||||||
|         <use xlink:href="assets/bootstrap-icons.svg#plus" /> |         <use xlink:href="assets/bootstrap-icons.svg#plus" /> | ||||||
|       </svg> |       </svg> | ||||||
|   | |||||||
| @@ -54,6 +54,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor { | |||||||
|   @Input() |   @Input() | ||||||
|   suggestions: number[] |   suggestions: number[] | ||||||
|  |  | ||||||
|  |   @Input() | ||||||
|  |   allowCreate: boolean = true | ||||||
|  |  | ||||||
|   value: number[] |   value: number[] | ||||||
|  |  | ||||||
|   tags: PaperlessTag[] |   tags: PaperlessTag[] | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' | import { Component, EventEmitter, Input, Output } from '@angular/core' | ||||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||||
| import { ObjectWithId } from 'src/app/data/object-with-id' | import { ObjectWithId } from 'src/app/data/object-with-id' | ||||||
|  |  | ||||||
| @@ -7,7 +7,7 @@ import { ObjectWithId } from 'src/app/data/object-with-id' | |||||||
|   templateUrl: './select-dialog.component.html', |   templateUrl: './select-dialog.component.html', | ||||||
|   styleUrls: ['./select-dialog.component.scss'], |   styleUrls: ['./select-dialog.component.scss'], | ||||||
| }) | }) | ||||||
| export class SelectDialogComponent implements OnInit { | export class SelectDialogComponent { | ||||||
|   constructor(public activeModal: NgbActiveModal) {} |   constructor(public activeModal: NgbActiveModal) {} | ||||||
|  |  | ||||||
|   @Output() |   @Output() | ||||||
| @@ -24,8 +24,6 @@ export class SelectDialogComponent implements OnInit { | |||||||
|  |  | ||||||
|   selected: number |   selected: number | ||||||
|  |  | ||||||
|   ngOnInit(): void {} |  | ||||||
|  |  | ||||||
|   cancelClicked() { |   cancelClicked() { | ||||||
|     this.activeModal.close() |     this.activeModal.close() | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { Component, Input, OnInit } from '@angular/core' | import { Component, Input } from '@angular/core' | ||||||
| import { PaperlessTag } from 'src/app/data/paperless-tag' | import { PaperlessTag } from 'src/app/data/paperless-tag' | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
| @@ -6,7 +6,7 @@ import { PaperlessTag } from 'src/app/data/paperless-tag' | |||||||
|   templateUrl: './tag.component.html', |   templateUrl: './tag.component.html', | ||||||
|   styleUrls: ['./tag.component.scss'], |   styleUrls: ['./tag.component.scss'], | ||||||
| }) | }) | ||||||
| export class TagComponent implements OnInit { | export class TagComponent { | ||||||
|   constructor() {} |   constructor() {} | ||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
| @@ -17,6 +17,4 @@ export class TagComponent implements OnInit { | |||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
|   clickable: boolean = false |   clickable: boolean = false | ||||||
|  |  | ||||||
|   ngOnInit(): void {} |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -11,9 +11,9 @@ | |||||||
|       </tr> |       </tr> | ||||||
|     </thead> |     </thead> | ||||||
|     <tbody> |     <tbody> | ||||||
|       <tr *ngFor="let doc of documents" (click)="openDocumentsService.openDocument(doc)"> |       <tr *ngFor="let doc of documents"> | ||||||
|         <td>{{doc.created_date | customDate}}</td> |         <td><a routerLink="/documents/{{doc.id}}" class="d-block text-dark text-decoration-none">{{doc.created_date | customDate}}</a></td> | ||||||
|         <td>{{doc.title | documentTitle}}<app-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t); $event.stopPropagation();"></app-tag></td> |         <td><a routerLink="/documents/{{doc.id}}" class="d-block text-dark text-decoration-none">{{doc.title | documentTitle}}<app-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t, $event)"></app-tag></a></td> | ||||||
|       </tr> |       </tr> | ||||||
|     </tbody> |     </tbody> | ||||||
|   </table> |   </table> | ||||||
|   | |||||||
| @@ -7,6 +7,6 @@ th:first-child { | |||||||
|   width: 25%; |   width: 25%; | ||||||
| } | } | ||||||
|  |  | ||||||
| tbody tr { | tbody app-tag { | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -72,7 +72,9 @@ export class SavedViewWidgetComponent implements OnInit, OnDestroy { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   clickTag(tag: PaperlessTag) { |   clickTag(tag: PaperlessTag, event: MouseEvent) { | ||||||
|  |     event.preventDefault() | ||||||
|  |  | ||||||
|     this.list.quickFilter([ |     this.list.quickFilter([ | ||||||
|       { rule_type: FILTER_HAS_TAGS_ALL, value: tag.id.toString() }, |       { rule_type: FILTER_HAS_TAGS_ALL, value: tag.id.toString() }, | ||||||
|     ]) |     ]) | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| <app-widget-frame title="Statistics" [loading]="loading" i18n-title> | <app-widget-frame title="Statistics" [loading]="loading" i18n-title> | ||||||
|   <ng-container content> |   <ng-container content> | ||||||
|     <p class="card-text" i18n *ngIf="statistics?.documents_inbox != null">Documents in inbox: {{statistics?.documents_inbox}}</p> |     <p class="card-text" i18n *ngIf="statistics?.documents_inbox !== null">Documents in inbox: {{statistics?.documents_inbox}}</p> | ||||||
|     <p class="card-text" i18n>Total documents: {{statistics?.documents_total}}</p> |     <p class="card-text" i18n>Total documents: {{statistics?.documents_total}}</p> | ||||||
|   </ng-container> |   </ng-container> | ||||||
| </app-widget-frame> | </app-widget-frame> | ||||||
|   | |||||||
| @@ -1,6 +1,5 @@ | |||||||
| import { HttpEventType } from '@angular/common/http' | import { Component } from '@angular/core' | ||||||
| import { Component, OnInit } from '@angular/core' | import { NgxFileDropEntry } from 'ngx-file-drop' | ||||||
| import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop' |  | ||||||
| import { | import { | ||||||
|   ConsumerStatusService, |   ConsumerStatusService, | ||||||
|   FileStatus, |   FileStatus, | ||||||
| @@ -15,7 +14,7 @@ const MAX_ALERTS = 5 | |||||||
|   templateUrl: './upload-file-widget.component.html', |   templateUrl: './upload-file-widget.component.html', | ||||||
|   styleUrls: ['./upload-file-widget.component.scss'], |   styleUrls: ['./upload-file-widget.component.scss'], | ||||||
| }) | }) | ||||||
| export class UploadFileWidgetComponent implements OnInit { | export class UploadFileWidgetComponent { | ||||||
|   alertsExpanded = false |   alertsExpanded = false | ||||||
|  |  | ||||||
|   constructor( |   constructor( | ||||||
| @@ -109,8 +108,6 @@ export class UploadFileWidgetComponent implements OnInit { | |||||||
|     this.consumerStatusService.dismissCompleted() |     this.consumerStatusService.dismissCompleted() | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   ngOnInit(): void {} |  | ||||||
|  |  | ||||||
|   public fileOver(event) {} |   public fileOver(event) {} | ||||||
|  |  | ||||||
|   public fileLeave(event) {} |   public fileLeave(event) {} | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { Component, OnInit } from '@angular/core' | import { Component } from '@angular/core' | ||||||
| import { TourService } from 'ngx-ui-tour-ng-bootstrap' | import { TourService } from 'ngx-ui-tour-ng-bootstrap' | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
| @@ -6,8 +6,6 @@ import { TourService } from 'ngx-ui-tour-ng-bootstrap' | |||||||
|   templateUrl: './welcome-widget.component.html', |   templateUrl: './welcome-widget.component.html', | ||||||
|   styleUrls: ['./welcome-widget.component.scss'], |   styleUrls: ['./welcome-widget.component.scss'], | ||||||
| }) | }) | ||||||
| export class WelcomeWidgetComponent implements OnInit { | export class WelcomeWidgetComponent { | ||||||
|   constructor(public readonly tourService: TourService) {} |   constructor(public readonly tourService: TourService) {} | ||||||
|  |  | ||||||
|   ngOnInit(): void {} |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,11 +1,11 @@ | |||||||
| import { Component, Input, OnInit } from '@angular/core' | import { Component, Input } from '@angular/core' | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-widget-frame', |   selector: 'app-widget-frame', | ||||||
|   templateUrl: './widget-frame.component.html', |   templateUrl: './widget-frame.component.html', | ||||||
|   styleUrls: ['./widget-frame.component.scss'], |   styleUrls: ['./widget-frame.component.scss'], | ||||||
| }) | }) | ||||||
| export class WidgetFrameComponent implements OnInit { | export class WidgetFrameComponent { | ||||||
|   constructor() {} |   constructor() {} | ||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
| @@ -13,6 +13,4 @@ export class WidgetFrameComponent implements OnInit { | |||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
|   loading: boolean = false |   loading: boolean = false | ||||||
|  |  | ||||||
|   ngOnInit(): void {} |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| <app-page-header [(title)]="title"> | <app-page-header [(title)]="title"> | ||||||
|     <div class="input-group input-group-sm me-5 d-none d-md-flex" *ngIf="getContentType() == 'application/pdf' && !useNativePdfViewer"> |     <div class="input-group input-group-sm me-5 d-none d-md-flex" *ngIf="getContentType() === 'application/pdf' && !useNativePdfViewer"> | ||||||
|       <div class="input-group-text" i18n>Page</div> |       <div class="input-group-text" i18n>Page</div> | ||||||
|       <input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" /> |       <input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" /> | ||||||
|       <div class="input-group-text" i18n>of {{previewNumPages}}</div> |       <div class="input-group-text" i18n>of {{previewNumPages}}</div> | ||||||
| @@ -149,9 +149,9 @@ | |||||||
|  |  | ||||||
|                 <li [ngbNavItem]="4" class="d-md-none"> |                 <li [ngbNavItem]="4" class="d-md-none"> | ||||||
|                     <a ngbNavLink>Preview</a> |                     <a ngbNavLink>Preview</a> | ||||||
|                     <ng-template ngbNavContent *ngIf="pdfPreview.offsetParent == undefined"> |                     <ng-template ngbNavContent *ngIf="pdfPreview.offsetParent === undefined"> | ||||||
|                         <div class="position-relative"> |                         <div class="position-relative"> | ||||||
|                             <ng-container *ngIf="getContentType() == 'application/pdf'"> |                             <ng-container *ngIf="getContentType() === 'application/pdf'"> | ||||||
|                                 <div class="preview-sticky pdf-viewer-container" *ngIf="!useNativePdfViewer ; else nativePdfViewer"> |                                 <div class="preview-sticky pdf-viewer-container" *ngIf="!useNativePdfViewer ; else nativePdfViewer"> | ||||||
|                                     <pdf-viewer [src]="{ url: previewUrl, password: password }" [original-size]="false" [show-borders]="true" [show-all]="true" [(page)]="previewCurrentPage" [render-text-mode]="2" (error)="onError($event)" (after-load-complete)="pdfPreviewLoaded($event)"></pdf-viewer> |                                     <pdf-viewer [src]="{ url: previewUrl, password: password }" [original-size]="false" [show-borders]="true" [show-all]="true" [(page)]="previewCurrentPage" [render-text-mode]="2" (error)="onError($event)" (after-load-complete)="pdfPreviewLoaded($event)"></pdf-viewer> | ||||||
|                                 </div> |                                 </div> | ||||||
| @@ -159,7 +159,7 @@ | |||||||
|                                     <object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object> |                                     <object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object> | ||||||
|                                 </ng-template> |                                 </ng-template> | ||||||
|                             </ng-container> |                             </ng-container> | ||||||
|                             <ng-container *ngIf="getContentType() == 'text/plain'"> |                             <ng-container *ngIf="getContentType() === 'text/plain'"> | ||||||
|                                 <object [data]="previewUrl | safeUrl" type="text/plain" class="preview-sticky bg-white" width="100%"></object> |                                 <object [data]="previewUrl | safeUrl" type="text/plain" class="preview-sticky bg-white" width="100%"></object> | ||||||
|                             </ng-container> |                             </ng-container> | ||||||
|                             <div *ngIf="requiresPassword" class="password-prompt"> |                             <div *ngIf="requiresPassword" class="password-prompt"> | ||||||
| @@ -180,14 +180,14 @@ | |||||||
|  |  | ||||||
|             <div [ngbNavOutlet]="nav" class="mt-2"></div> |             <div [ngbNavOutlet]="nav" class="mt-2"></div> | ||||||
|  |  | ||||||
|             <button type="button" class="btn btn-outline-secondary" (click)="discard()" i18n [disabled]="networkActive || !(isDirty$ | async)">Discard</button>  |             <button type="button" class="btn btn-outline-secondary" (click)="discard()" i18n [disabled]="networkActive || (isDirty$ | async) === false">Discard</button>  | ||||||
|             <button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n [disabled]="networkActive || !(isDirty$ | async) || error">Save & next</button>  |             <button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n [disabled]="networkActive || (isDirty$ | async) === false || error">Save & next</button>  | ||||||
|             <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive || !(isDirty$ | async) || error">Save</button>  |             <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive || (isDirty$ | async) === false || error">Save</button>  | ||||||
|         </form> |         </form> | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|     <div class="col-md-6 col-xl-8 mb-3 d-none d-md-block position-relative" #pdfPreview> |     <div class="col-md-6 col-xl-8 mb-3 d-none d-md-block position-relative" #pdfPreview> | ||||||
|         <ng-container *ngIf="getContentType() == 'application/pdf'"> |         <ng-container *ngIf="getContentType() === 'application/pdf'"> | ||||||
|             <div class="preview-sticky pdf-viewer-container" *ngIf="!useNativePdfViewer ; else nativePdfViewer"> |             <div class="preview-sticky pdf-viewer-container" *ngIf="!useNativePdfViewer ; else nativePdfViewer"> | ||||||
|                 <pdf-viewer [src]="{ url: previewUrl, password: password }" [original-size]="false" [show-borders]="true" [show-all]="true" [(page)]="previewCurrentPage" [render-text-mode]="2" (error)="onError($event)" (after-load-complete)="pdfPreviewLoaded($event)"></pdf-viewer> |                 <pdf-viewer [src]="{ url: previewUrl, password: password }" [original-size]="false" [show-borders]="true" [show-all]="true" [(page)]="previewCurrentPage" [render-text-mode]="2" (error)="onError($event)" (after-load-complete)="pdfPreviewLoaded($event)"></pdf-viewer> | ||||||
|             </div> |             </div> | ||||||
| @@ -195,7 +195,7 @@ | |||||||
|                 <object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object> |                 <object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object> | ||||||
|             </ng-template> |             </ng-template> | ||||||
|         </ng-container> |         </ng-container> | ||||||
|         <ng-container *ngIf="getContentType() == 'text/plain'"> |         <ng-container *ngIf="getContentType() === 'text/plain'"> | ||||||
|             <object [data]="previewUrl | safeUrl" type="text/plain" class="preview-sticky bg-white" width="100%"></object> |             <object [data]="previewUrl | safeUrl" type="text/plain" class="preview-sticky bg-white" width="100%"></object> | ||||||
|         </ng-container> |         </ng-container> | ||||||
|         <div *ngIf="requiresPassword" class="password-prompt"> |         <div *ngIf="requiresPassword" class="password-prompt"> | ||||||
|   | |||||||
| @@ -184,7 +184,7 @@ export class DocumentDetailComponent | |||||||
|               this.openDocumentService.getOpenDocument(this.documentId) |               this.openDocumentService.getOpenDocument(this.documentId) | ||||||
|             ) |             ) | ||||||
|           } else { |           } else { | ||||||
|             this.openDocumentService.openDocument(doc, false) |             this.openDocumentService.openDocument(doc) | ||||||
|             this.updateComponent(doc) |             this.updateComponent(doc) | ||||||
|           } |           } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,11 +1,11 @@ | |||||||
| import { Component, Input, OnInit } from '@angular/core' | import { Component, Input } from '@angular/core' | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-metadata-collapse', |   selector: 'app-metadata-collapse', | ||||||
|   templateUrl: './metadata-collapse.component.html', |   templateUrl: './metadata-collapse.component.html', | ||||||
|   styleUrls: ['./metadata-collapse.component.scss'], |   styleUrls: ['./metadata-collapse.component.scss'], | ||||||
| }) | }) | ||||||
| export class MetadataCollapseComponent implements OnInit { | export class MetadataCollapseComponent { | ||||||
|   constructor() {} |   constructor() {} | ||||||
|  |  | ||||||
|   expand = false |   expand = false | ||||||
| @@ -15,6 +15,4 @@ export class MetadataCollapseComponent implements OnInit { | |||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
|   title = $localize`Metadata` |   title = $localize`Metadata` | ||||||
|  |  | ||||||
|   ngOnInit(): void {} |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -66,7 +66,6 @@ | |||||||
|   </div> |   </div> | ||||||
|   <div class="col-auto ms-auto mb-2 mb-xl-0 d-flex"> |   <div class="col-auto ms-auto mb-2 mb-xl-0 d-flex"> | ||||||
|     <div class="btn-group btn-group-sm me-2"> |     <div class="btn-group btn-group-sm me-2"> | ||||||
|  |  | ||||||
|       <div ngbDropdown class="me-2 d-flex"> |       <div ngbDropdown class="me-2 d-flex"> | ||||||
|         <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle> |         <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle> | ||||||
|           <svg class="toolbaricon" fill="currentColor"> |           <svg class="toolbaricon" fill="currentColor"> | ||||||
| @@ -75,26 +74,57 @@ | |||||||
|           <div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div> |           <div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div> | ||||||
|         </button> |         </button> | ||||||
|         <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> |         <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||||
|           <button ngbDropdownItem [disabled]="awaitingDownload" (click)="downloadSelected()" i18n> |  | ||||||
|             Download |  | ||||||
|             <div *ngIf="awaitingDownload" class="spinner-border spinner-border-sm" role="status"> |  | ||||||
|               <span class="visually-hidden">Preparing download...</span> |  | ||||||
|             </div> |  | ||||||
|           </button> |  | ||||||
|           <button ngbDropdownItem [disabled]="awaitingDownload" (click)="downloadSelected('originals')" i18n> |  | ||||||
|             Download originals |  | ||||||
|             <div *ngIf="awaitingDownload" class="spinner-border spinner-border-sm" role="status"> |  | ||||||
|               <span class="visually-hidden">Preparing download...</span> |  | ||||||
|             </div> |  | ||||||
|           </button> |  | ||||||
|           <button ngbDropdownItem (click)="redoOcrSelected()" i18n>Redo OCR</button> |           <button ngbDropdownItem (click)="redoOcrSelected()" i18n>Redo OCR</button> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|     <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()"> |     <div class="btn-group btn-group-sm me-2"> | ||||||
|       <svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor"> |       <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()"> | ||||||
|         <use xlink:href="assets/bootstrap-icons.svg#trash" /> |         <svg *ngIf="!awaitingDownload" class="toolbaricon" fill="currentColor"> | ||||||
|       </svg> <ng-container i18n>Delete</ng-container> |           <use xlink:href="assets/bootstrap-icons.svg#arrow-down" /> | ||||||
|     </button> |         </svg> | ||||||
|  |         <div *ngIf="awaitingDownload" 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 me-2"> | ||||||
|  |       <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()"> | ||||||
|  |         <svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor"> | ||||||
|  |           <use xlink:href="assets/bootstrap-icons.svg#trash" /> | ||||||
|  |         </svg> <ng-container i18n>Delete</ng-container> | ||||||
|  |       </button> | ||||||
|  |     </div> | ||||||
|   </div> |   </div> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -0,0 +1,7 @@ | |||||||
|  | .dropdown-toggle-split { | ||||||
|  |     --bs-border-radius: .25rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .dropdown-menu{ | ||||||
|  |     --bs-dropdown-min-width: 12rem; | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { Component } from '@angular/core' | import { Component, OnDestroy, OnInit } from '@angular/core' | ||||||
| import { PaperlessTag } from 'src/app/data/paperless-tag' | import { PaperlessTag } from 'src/app/data/paperless-tag' | ||||||
| import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' | import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' | ||||||
| import { PaperlessDocumentType } from 'src/app/data/paperless-document-type' | import { PaperlessDocumentType } from 'src/app/data/paperless-document-type' | ||||||
| @@ -25,13 +25,15 @@ import { saveAs } from 'file-saver' | |||||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.service' | import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||||
| import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' | import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' | ||||||
| import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||||
|  | import { FormControl, FormGroup } from '@angular/forms' | ||||||
|  | import { first, Subject, takeUntil } from 'rxjs' | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-bulk-editor', |   selector: 'app-bulk-editor', | ||||||
|   templateUrl: './bulk-editor.component.html', |   templateUrl: './bulk-editor.component.html', | ||||||
|   styleUrls: ['./bulk-editor.component.scss'], |   styleUrls: ['./bulk-editor.component.scss'], | ||||||
| }) | }) | ||||||
| export class BulkEditorComponent { | export class BulkEditorComponent implements OnInit, OnDestroy { | ||||||
|   tags: PaperlessTag[] |   tags: PaperlessTag[] | ||||||
|   correspondents: PaperlessCorrespondent[] |   correspondents: PaperlessCorrespondent[] | ||||||
|   documentTypes: PaperlessDocumentType[] |   documentTypes: PaperlessDocumentType[] | ||||||
| @@ -43,6 +45,14 @@ export class BulkEditorComponent { | |||||||
|   storagePathsSelectionModel = new FilterableDropdownSelectionModel() |   storagePathsSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|   awaitingDownload: boolean |   awaitingDownload: boolean | ||||||
|  |  | ||||||
|  |   unsubscribeNotifier: Subject<any> = new Subject() | ||||||
|  |  | ||||||
|  |   downloadForm = new FormGroup({ | ||||||
|  |     downloadFileTypeArchive: new FormControl(true), | ||||||
|  |     downloadFileTypeOriginals: new FormControl(false), | ||||||
|  |     downloadUseFormatting: new FormControl(false), | ||||||
|  |   }) | ||||||
|  |  | ||||||
|   constructor( |   constructor( | ||||||
|     private documentTypeService: DocumentTypeService, |     private documentTypeService: DocumentTypeService, | ||||||
|     private tagService: TagService, |     private tagService: TagService, | ||||||
| @@ -66,16 +76,46 @@ export class BulkEditorComponent { | |||||||
|   ngOnInit() { |   ngOnInit() { | ||||||
|     this.tagService |     this.tagService | ||||||
|       .listAll() |       .listAll() | ||||||
|  |       .pipe(first()) | ||||||
|       .subscribe((result) => (this.tags = result.results)) |       .subscribe((result) => (this.tags = result.results)) | ||||||
|     this.correspondentService |     this.correspondentService | ||||||
|       .listAll() |       .listAll() | ||||||
|  |       .pipe(first()) | ||||||
|       .subscribe((result) => (this.correspondents = result.results)) |       .subscribe((result) => (this.correspondents = result.results)) | ||||||
|     this.documentTypeService |     this.documentTypeService | ||||||
|       .listAll() |       .listAll() | ||||||
|  |       .pipe(first()) | ||||||
|       .subscribe((result) => (this.documentTypes = result.results)) |       .subscribe((result) => (this.documentTypes = result.results)) | ||||||
|     this.storagePathService |     this.storagePathService | ||||||
|       .listAll() |       .listAll() | ||||||
|  |       .pipe(first()) | ||||||
|       .subscribe((result) => (this.storagePaths = result.results)) |       .subscribe((result) => (this.storagePaths = result.results)) | ||||||
|  |  | ||||||
|  |     this.downloadForm | ||||||
|  |       .get('downloadFileTypeArchive') | ||||||
|  |       .valueChanges.pipe(takeUntil(this.unsubscribeNotifier)) | ||||||
|  |       .subscribe((newValue) => { | ||||||
|  |         if (!newValue) { | ||||||
|  |           this.downloadForm | ||||||
|  |             .get('downloadFileTypeOriginals') | ||||||
|  |             .patchValue(true, { emitEvent: false }) | ||||||
|  |         } | ||||||
|  |       }) | ||||||
|  |     this.downloadForm | ||||||
|  |       .get('downloadFileTypeOriginals') | ||||||
|  |       .valueChanges.pipe(takeUntil(this.unsubscribeNotifier)) | ||||||
|  |       .subscribe((newValue) => { | ||||||
|  |         if (!newValue) { | ||||||
|  |           this.downloadForm | ||||||
|  |             .get('downloadFileTypeArchive') | ||||||
|  |             .patchValue(true, { emitEvent: false }) | ||||||
|  |         } | ||||||
|  |       }) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   ngOnDestroy(): void { | ||||||
|  |     this.unsubscribeNotifier.next(this) | ||||||
|  |     this.unsubscribeNotifier.complete() | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private executeBulkOperation(modal, method: string, args) { |   private executeBulkOperation(modal, method: string, args) { | ||||||
| @@ -84,8 +124,9 @@ export class BulkEditorComponent { | |||||||
|     } |     } | ||||||
|     this.documentService |     this.documentService | ||||||
|       .bulkEdit(Array.from(this.list.selected), method, args) |       .bulkEdit(Array.from(this.list.selected), method, args) | ||||||
|       .subscribe( |       .pipe(first()) | ||||||
|         (response) => { |       .subscribe({ | ||||||
|  |         next: () => { | ||||||
|           this.list.reload() |           this.list.reload() | ||||||
|           this.list.reduceSelectionToFilter() |           this.list.reduceSelectionToFilter() | ||||||
|           this.list.selected.forEach((id) => { |           this.list.selected.forEach((id) => { | ||||||
| @@ -95,7 +136,7 @@ export class BulkEditorComponent { | |||||||
|             modal.close() |             modal.close() | ||||||
|           } |           } | ||||||
|         }, |         }, | ||||||
|         (error) => { |         error: (error) => { | ||||||
|           if (modal) { |           if (modal) { | ||||||
|             modal.componentInstance.buttonsEnabled = true |             modal.componentInstance.buttonsEnabled = true | ||||||
|           } |           } | ||||||
| @@ -104,8 +145,8 @@ export class BulkEditorComponent { | |||||||
|               error.error |               error.error | ||||||
|             )}` |             )}` | ||||||
|           ) |           ) | ||||||
|         } |         }, | ||||||
|       ) |       }) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private applySelectionData( |   private applySelectionData( | ||||||
| @@ -126,6 +167,7 @@ export class BulkEditorComponent { | |||||||
|   openTagsDropdown() { |   openTagsDropdown() { | ||||||
|     this.documentService |     this.documentService | ||||||
|       .getSelectionData(Array.from(this.list.selected)) |       .getSelectionData(Array.from(this.list.selected)) | ||||||
|  |       .pipe(first()) | ||||||
|       .subscribe((s) => { |       .subscribe((s) => { | ||||||
|         this.applySelectionData(s.selected_tags, this.tagSelectionModel) |         this.applySelectionData(s.selected_tags, this.tagSelectionModel) | ||||||
|       }) |       }) | ||||||
| @@ -134,6 +176,7 @@ export class BulkEditorComponent { | |||||||
|   openDocumentTypeDropdown() { |   openDocumentTypeDropdown() { | ||||||
|     this.documentService |     this.documentService | ||||||
|       .getSelectionData(Array.from(this.list.selected)) |       .getSelectionData(Array.from(this.list.selected)) | ||||||
|  |       .pipe(first()) | ||||||
|       .subscribe((s) => { |       .subscribe((s) => { | ||||||
|         this.applySelectionData( |         this.applySelectionData( | ||||||
|           s.selected_document_types, |           s.selected_document_types, | ||||||
| @@ -145,6 +188,7 @@ export class BulkEditorComponent { | |||||||
|   openCorrespondentDropdown() { |   openCorrespondentDropdown() { | ||||||
|     this.documentService |     this.documentService | ||||||
|       .getSelectionData(Array.from(this.list.selected)) |       .getSelectionData(Array.from(this.list.selected)) | ||||||
|  |       .pipe(first()) | ||||||
|       .subscribe((s) => { |       .subscribe((s) => { | ||||||
|         this.applySelectionData( |         this.applySelectionData( | ||||||
|           s.selected_correspondents, |           s.selected_correspondents, | ||||||
| @@ -156,6 +200,7 @@ export class BulkEditorComponent { | |||||||
|   openStoragePathDropdown() { |   openStoragePathDropdown() { | ||||||
|     this.documentService |     this.documentService | ||||||
|       .getSelectionData(Array.from(this.list.selected)) |       .getSelectionData(Array.from(this.list.selected)) | ||||||
|  |       .pipe(first()) | ||||||
|       .subscribe((s) => { |       .subscribe((s) => { | ||||||
|         this.applySelectionData( |         this.applySelectionData( | ||||||
|           s.selected_storage_paths, |           s.selected_storage_paths, | ||||||
| @@ -232,12 +277,14 @@ export class BulkEditorComponent { | |||||||
|  |  | ||||||
|       modal.componentInstance.btnClass = 'btn-warning' |       modal.componentInstance.btnClass = 'btn-warning' | ||||||
|       modal.componentInstance.btnCaption = $localize`Confirm` |       modal.componentInstance.btnCaption = $localize`Confirm` | ||||||
|       modal.componentInstance.confirmClicked.subscribe(() => { |       modal.componentInstance.confirmClicked | ||||||
|         this.executeBulkOperation(modal, 'modify_tags', { |         .pipe(takeUntil(this.unsubscribeNotifier)) | ||||||
|           add_tags: changedTags.itemsToAdd.map((t) => t.id), |         .subscribe(() => { | ||||||
|           remove_tags: changedTags.itemsToRemove.map((t) => t.id), |           this.executeBulkOperation(modal, 'modify_tags', { | ||||||
|  |             add_tags: changedTags.itemsToAdd.map((t) => t.id), | ||||||
|  |             remove_tags: changedTags.itemsToRemove.map((t) => t.id), | ||||||
|  |           }) | ||||||
|         }) |         }) | ||||||
|       }) |  | ||||||
|     } else { |     } else { | ||||||
|       this.executeBulkOperation(null, 'modify_tags', { |       this.executeBulkOperation(null, 'modify_tags', { | ||||||
|         add_tags: changedTags.itemsToAdd.map((t) => t.id), |         add_tags: changedTags.itemsToAdd.map((t) => t.id), | ||||||
| @@ -270,11 +317,13 @@ export class BulkEditorComponent { | |||||||
|       } |       } | ||||||
|       modal.componentInstance.btnClass = 'btn-warning' |       modal.componentInstance.btnClass = 'btn-warning' | ||||||
|       modal.componentInstance.btnCaption = $localize`Confirm` |       modal.componentInstance.btnCaption = $localize`Confirm` | ||||||
|       modal.componentInstance.confirmClicked.subscribe(() => { |       modal.componentInstance.confirmClicked | ||||||
|         this.executeBulkOperation(modal, 'set_correspondent', { |         .pipe(takeUntil(this.unsubscribeNotifier)) | ||||||
|           correspondent: correspondent ? correspondent.id : null, |         .subscribe(() => { | ||||||
|  |           this.executeBulkOperation(modal, 'set_correspondent', { | ||||||
|  |             correspondent: correspondent ? correspondent.id : null, | ||||||
|  |           }) | ||||||
|         }) |         }) | ||||||
|       }) |  | ||||||
|     } else { |     } else { | ||||||
|       this.executeBulkOperation(null, 'set_correspondent', { |       this.executeBulkOperation(null, 'set_correspondent', { | ||||||
|         correspondent: correspondent ? correspondent.id : null, |         correspondent: correspondent ? correspondent.id : null, | ||||||
| @@ -306,11 +355,13 @@ export class BulkEditorComponent { | |||||||
|       } |       } | ||||||
|       modal.componentInstance.btnClass = 'btn-warning' |       modal.componentInstance.btnClass = 'btn-warning' | ||||||
|       modal.componentInstance.btnCaption = $localize`Confirm` |       modal.componentInstance.btnCaption = $localize`Confirm` | ||||||
|       modal.componentInstance.confirmClicked.subscribe(() => { |       modal.componentInstance.confirmClicked | ||||||
|         this.executeBulkOperation(modal, 'set_document_type', { |         .pipe(takeUntil(this.unsubscribeNotifier)) | ||||||
|           document_type: documentType ? documentType.id : null, |         .subscribe(() => { | ||||||
|  |           this.executeBulkOperation(modal, 'set_document_type', { | ||||||
|  |             document_type: documentType ? documentType.id : null, | ||||||
|  |           }) | ||||||
|         }) |         }) | ||||||
|       }) |  | ||||||
|     } else { |     } else { | ||||||
|       this.executeBulkOperation(null, 'set_document_type', { |       this.executeBulkOperation(null, 'set_document_type', { | ||||||
|         document_type: documentType ? documentType.id : null, |         document_type: documentType ? documentType.id : null, | ||||||
| @@ -342,11 +393,13 @@ export class BulkEditorComponent { | |||||||
|       } |       } | ||||||
|       modal.componentInstance.btnClass = 'btn-warning' |       modal.componentInstance.btnClass = 'btn-warning' | ||||||
|       modal.componentInstance.btnCaption = $localize`Confirm` |       modal.componentInstance.btnCaption = $localize`Confirm` | ||||||
|       modal.componentInstance.confirmClicked.subscribe(() => { |       modal.componentInstance.confirmClicked | ||||||
|         this.executeBulkOperation(modal, 'set_storage_path', { |         .pipe(takeUntil(this.unsubscribeNotifier)) | ||||||
|           storage_path: storagePath ? storagePath.id : null, |         .subscribe(() => { | ||||||
|  |           this.executeBulkOperation(modal, 'set_storage_path', { | ||||||
|  |             storage_path: storagePath ? storagePath.id : null, | ||||||
|  |           }) | ||||||
|         }) |         }) | ||||||
|       }) |  | ||||||
|     } else { |     } else { | ||||||
|       this.executeBulkOperation(null, 'set_storage_path', { |       this.executeBulkOperation(null, 'set_storage_path', { | ||||||
|         storage_path: storagePath ? storagePath.id : null, |         storage_path: storagePath ? storagePath.id : null, | ||||||
| @@ -364,16 +417,30 @@ export class BulkEditorComponent { | |||||||
|     modal.componentInstance.message = $localize`This operation cannot be undone.` |     modal.componentInstance.message = $localize`This operation cannot be undone.` | ||||||
|     modal.componentInstance.btnClass = 'btn-danger' |     modal.componentInstance.btnClass = 'btn-danger' | ||||||
|     modal.componentInstance.btnCaption = $localize`Delete document(s)` |     modal.componentInstance.btnCaption = $localize`Delete document(s)` | ||||||
|     modal.componentInstance.confirmClicked.subscribe(() => { |     modal.componentInstance.confirmClicked | ||||||
|       modal.componentInstance.buttonsEnabled = false |       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||||
|       this.executeBulkOperation(modal, 'delete', {}) |       .subscribe(() => { | ||||||
|     }) |         modal.componentInstance.buttonsEnabled = false | ||||||
|  |         this.executeBulkOperation(modal, 'delete', {}) | ||||||
|  |       }) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   downloadSelected(content = 'archive') { |   downloadSelected() { | ||||||
|     this.awaitingDownload = true |     this.awaitingDownload = true | ||||||
|  |     let downloadFileType: string = | ||||||
|  |       this.downloadForm.get('downloadFileTypeArchive').value && | ||||||
|  |       this.downloadForm.get('downloadFileTypeOriginals').value | ||||||
|  |         ? 'both' | ||||||
|  |         : this.downloadForm.get('downloadFileTypeArchive').value | ||||||
|  |         ? 'archive' | ||||||
|  |         : 'originals' | ||||||
|     this.documentService |     this.documentService | ||||||
|       .bulkDownload(Array.from(this.list.selected), content) |       .bulkDownload( | ||||||
|  |         Array.from(this.list.selected), | ||||||
|  |         downloadFileType, | ||||||
|  |         this.downloadForm.get('downloadUseFormatting').value | ||||||
|  |       ) | ||||||
|  |       .pipe(first()) | ||||||
|       .subscribe((result: any) => { |       .subscribe((result: any) => { | ||||||
|         saveAs(result, 'documents.zip') |         saveAs(result, 'documents.zip') | ||||||
|         this.awaitingDownload = false |         this.awaitingDownload = false | ||||||
| @@ -389,9 +456,11 @@ export class BulkEditorComponent { | |||||||
|     modal.componentInstance.message = $localize`This operation cannot be undone.` |     modal.componentInstance.message = $localize`This operation cannot be undone.` | ||||||
|     modal.componentInstance.btnClass = 'btn-danger' |     modal.componentInstance.btnClass = 'btn-danger' | ||||||
|     modal.componentInstance.btnCaption = $localize`Proceed` |     modal.componentInstance.btnCaption = $localize`Proceed` | ||||||
|     modal.componentInstance.confirmClicked.subscribe(() => { |     modal.componentInstance.confirmClicked | ||||||
|       modal.componentInstance.buttonsEnabled = false |       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||||
|       this.executeBulkOperation(modal, 'redo_ocr', {}) |       .subscribe(() => { | ||||||
|     }) |         modal.componentInstance.buttonsEnabled = false | ||||||
|  |         this.executeBulkOperation(modal, 'redo_ocr', {}) | ||||||
|  |       }) | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -37,7 +37,7 @@ | |||||||
|                 <use xlink:href="assets/bootstrap-icons.svg#diagram-3"/> |                 <use xlink:href="assets/bootstrap-icons.svg#diagram-3"/> | ||||||
|               </svg> <span class="d-none d-md-inline" i18n>More like this</span> |               </svg> <span class="d-none d-md-inline" i18n>More like this</span> | ||||||
|             </a> |             </a> | ||||||
|             <a (click)="openDocumentsService.openDocument(document)" class="btn btn-sm btn-outline-secondary"> |             <a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary"> | ||||||
|               <svg class="sidebaricon" fill="currentColor" class="sidebaricon"> |               <svg class="sidebaricon" fill="currentColor" class="sidebaricon"> | ||||||
|                 <use xlink:href="assets/bootstrap-icons.svg#pencil"/> |                 <use xlink:href="assets/bootstrap-icons.svg#pencil"/> | ||||||
|               </svg> <span class="d-none d-md-inline" i18n>Edit</span> |               </svg> <span class="d-none d-md-inline" i18n>Edit</span> | ||||||
|   | |||||||
| @@ -2,7 +2,6 @@ import { | |||||||
|   Component, |   Component, | ||||||
|   EventEmitter, |   EventEmitter, | ||||||
|   Input, |   Input, | ||||||
|   OnInit, |  | ||||||
|   Output, |   Output, | ||||||
|   ViewChild, |   ViewChild, | ||||||
| } from '@angular/core' | } from '@angular/core' | ||||||
| @@ -10,9 +9,6 @@ import { PaperlessDocument } from 'src/app/data/paperless-document' | |||||||
| import { DocumentService } from 'src/app/services/rest/document.service' | import { DocumentService } from 'src/app/services/rest/document.service' | ||||||
| import { SettingsService } from 'src/app/services/settings.service' | import { SettingsService } from 'src/app/services/settings.service' | ||||||
| import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' | import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' | ||||||
| import { OpenDocumentsService } from 'src/app/services/open-documents.service' |  | ||||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service' |  | ||||||
| import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type' |  | ||||||
| import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
| @@ -23,11 +19,10 @@ import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | |||||||
|     '../popover-preview/popover-preview.scss', |     '../popover-preview/popover-preview.scss', | ||||||
|   ], |   ], | ||||||
| }) | }) | ||||||
| export class DocumentCardLargeComponent implements OnInit { | export class DocumentCardLargeComponent { | ||||||
|   constructor( |   constructor( | ||||||
|     private documentService: DocumentService, |     private documentService: DocumentService, | ||||||
|     private settingsService: SettingsService, |     private settingsService: SettingsService | ||||||
|     public openDocumentsService: OpenDocumentsService |  | ||||||
|   ) {} |   ) {} | ||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
| @@ -75,8 +70,6 @@ export class DocumentCardLargeComponent implements OnInit { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   ngOnInit(): void {} |  | ||||||
|  |  | ||||||
|   getIsThumbInverted() { |   getIsThumbInverted() { | ||||||
|     return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED) |     return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED) | ||||||
|   } |   } | ||||||
| @@ -119,6 +112,9 @@ export class DocumentCardLargeComponent implements OnInit { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   get contentTrimmed() { |   get contentTrimmed() { | ||||||
|     return this.document.content.substr(0, 500) |     return ( | ||||||
|  |       this.document.content.substr(0, 500) + | ||||||
|  |       (this.document.content.length > 500 ? '...' : '') | ||||||
|  |     ) | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -67,7 +67,7 @@ | |||||||
|       </div> |       </div> | ||||||
|       <div class="d-flex justify-content-between align-items-center"> |       <div class="d-flex justify-content-between align-items-center"> | ||||||
|         <div class="btn-group w-100"> |         <div class="btn-group w-100"> | ||||||
|           <a (click)="openDocumentsService.openDocument(document)" class="btn btn-sm btn-outline-secondary" title="Edit" i18n-title> |           <a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" title="Edit" i18n-title> | ||||||
|             <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> |             <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | ||||||
|               <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/> |               <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/> | ||||||
|             </svg> |             </svg> | ||||||
|   | |||||||
| @@ -2,7 +2,6 @@ import { | |||||||
|   Component, |   Component, | ||||||
|   EventEmitter, |   EventEmitter, | ||||||
|   Input, |   Input, | ||||||
|   OnInit, |  | ||||||
|   Output, |   Output, | ||||||
|   ViewChild, |   ViewChild, | ||||||
| } from '@angular/core' | } from '@angular/core' | ||||||
| @@ -11,7 +10,6 @@ import { PaperlessDocument } from 'src/app/data/paperless-document' | |||||||
| import { DocumentService } from 'src/app/services/rest/document.service' | import { DocumentService } from 'src/app/services/rest/document.service' | ||||||
| import { SettingsService } from 'src/app/services/settings.service' | import { SettingsService } from 'src/app/services/settings.service' | ||||||
| import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' | import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' | ||||||
| import { OpenDocumentsService } from 'src/app/services/open-documents.service' |  | ||||||
| import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
| @@ -22,11 +20,10 @@ import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | |||||||
|     '../popover-preview/popover-preview.scss', |     '../popover-preview/popover-preview.scss', | ||||||
|   ], |   ], | ||||||
| }) | }) | ||||||
| export class DocumentCardSmallComponent implements OnInit { | export class DocumentCardSmallComponent { | ||||||
|   constructor( |   constructor( | ||||||
|     private documentService: DocumentService, |     private documentService: DocumentService, | ||||||
|     private settingsService: SettingsService, |     private settingsService: SettingsService | ||||||
|     public openDocumentsService: OpenDocumentsService |  | ||||||
|   ) {} |   ) {} | ||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
| @@ -57,8 +54,6 @@ export class DocumentCardSmallComponent implements OnInit { | |||||||
|   mouseOnPreview = false |   mouseOnPreview = false | ||||||
|   popoverHidden = true |   popoverHidden = true | ||||||
|  |  | ||||||
|   ngOnInit(): void {} |  | ||||||
|  |  | ||||||
|   getIsThumbInverted() { |   getIsThumbInverted() { | ||||||
|     return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED) |     return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED) | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -53,7 +53,7 @@ | |||||||
|       </div> |       </div> | ||||||
|       <div> |       <div> | ||||||
|         <button *ngFor="let f of getSortFields()" ngbDropdownItem (click)="setSortField(f.field)" |         <button *ngFor="let f of getSortFields()" ngbDropdownItem (click)="setSortField(f.field)" | ||||||
|           [class.active]="list.sortField == f.field">{{f.name}} |           [class.active]="list.sortField === f.field">{{f.name}} | ||||||
|         </button> |         </button> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
| @@ -94,7 +94,7 @@ | |||||||
|       </ng-container> |       </ng-container> | ||||||
|       <span i18n *ngIf="list.selected.size > 0">{list.collectionSize, plural, =1 {Selected {{list.selected.size}} of one document} other {Selected {{list.selected.size}} of {{list.collectionSize || 0}} documents}}</span> |       <span i18n *ngIf="list.selected.size > 0">{list.collectionSize, plural, =1 {Selected {{list.selected.size}} of one document} other {Selected {{list.selected.size}} of {{list.collectionSize || 0}} documents}}</span> | ||||||
|       <ng-container *ngIf="!list.isReloading"> |       <ng-container *ngIf="!list.isReloading"> | ||||||
|         <span i18n *ngIf="list.selected.size == 0">{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span> <span i18n *ngIf="isFiltered">(filtered)</span> |         <span i18n *ngIf="list.selected.size === 0">{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span> <span i18n *ngIf="isFiltered">(filtered)</span> | ||||||
|       </ng-container> |       </ng-container> | ||||||
|     </p> |     </p> | ||||||
|     <ngb-pagination *ngIf="list.collectionSize" [pageSize]="list.currentPageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5" |     <ngb-pagination *ngIf="list.collectionSize" [pageSize]="list.currentPageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5" | ||||||
| @@ -111,52 +111,52 @@ | |||||||
| </ng-container> | </ng-container> | ||||||
|  |  | ||||||
| <ng-template #documentListNoError> | <ng-template #documentListNoError> | ||||||
|   <div *ngIf="displayMode == 'largeCards'"> |   <div *ngIf="displayMode === 'largeCards'"> | ||||||
|     <app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)" (clickStoragePath)="clickStoragePath($event)" (clickMoreLike)="clickMoreLike(d.id)"> |     <app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)" (clickStoragePath)="clickStoragePath($event)" (clickMoreLike)="clickMoreLike(d.id)"> | ||||||
|     </app-document-card-large> |     </app-document-card-large> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|   <table class="table table-sm align-middle border shadow-sm" *ngIf="displayMode == 'details'"> |   <table class="table table-sm align-middle border shadow-sm" *ngIf="displayMode === 'details'"> | ||||||
|     <thead> |     <thead> | ||||||
|       <th></th> |       <th></th> | ||||||
|       <th class="d-none d-lg-table-cell" |       <th class="d-none d-lg-table-cell" | ||||||
|         sortable="archive_serial_number" |         appSortable="archive_serial_number" | ||||||
|         [currentSortField]="list.sortField" |         [currentSortField]="list.sortField" | ||||||
|         [currentSortReverse]="list.sortReverse" |         [currentSortReverse]="list.sortReverse" | ||||||
|         (sort)="onSort($event)" |         (sort)="onSort($event)" | ||||||
|         i18n>ASN</th> |         i18n>ASN</th> | ||||||
|       <th class="d-none d-md-table-cell" |       <th class="d-none d-md-table-cell" | ||||||
|         sortable="correspondent__name" |         appSortable="correspondent__name" | ||||||
|         [currentSortField]="list.sortField" |         [currentSortField]="list.sortField" | ||||||
|         [currentSortReverse]="list.sortReverse" |         [currentSortReverse]="list.sortReverse" | ||||||
|         (sort)="onSort($event)" |         (sort)="onSort($event)" | ||||||
|         i18n>Correspondent</th> |         i18n>Correspondent</th> | ||||||
|       <th |       <th | ||||||
|         sortable="title" |         appSortable="title" | ||||||
|         [currentSortField]="list.sortField" |         [currentSortField]="list.sortField" | ||||||
|         [currentSortReverse]="list.sortReverse" |         [currentSortReverse]="list.sortReverse" | ||||||
|         (sort)="onSort($event)" |         (sort)="onSort($event)" | ||||||
|         i18n>Title</th> |         i18n>Title</th> | ||||||
|       <th class="d-none d-xl-table-cell" |       <th class="d-none d-xl-table-cell" | ||||||
|         sortable="document_type__name" |         appSortable="document_type__name" | ||||||
|         [currentSortField]="list.sortField" |         [currentSortField]="list.sortField" | ||||||
|         [currentSortReverse]="list.sortReverse" |         [currentSortReverse]="list.sortReverse" | ||||||
|         (sort)="onSort($event)" |         (sort)="onSort($event)" | ||||||
|         i18n>Document type</th> |         i18n>Document type</th> | ||||||
|       <th class="d-none d-xl-table-cell" |       <th class="d-none d-xl-table-cell" | ||||||
|         sortable="storage_path__name" |         appSortable="storage_path__name" | ||||||
|         [currentSortField]="list.sortField" |         [currentSortField]="list.sortField" | ||||||
|         [currentSortReverse]="list.sortReverse" |         [currentSortReverse]="list.sortReverse" | ||||||
|         (sort)="onSort($event)" |         (sort)="onSort($event)" | ||||||
|         i18n>Storage path</th> |         i18n>Storage path</th> | ||||||
|       <th |       <th | ||||||
|         sortable="created" |         appSortable="created" | ||||||
|         [currentSortField]="list.sortField" |         [currentSortField]="list.sortField" | ||||||
|         [currentSortReverse]="list.sortReverse" |         [currentSortReverse]="list.sortReverse" | ||||||
|         (sort)="onSort($event)" |         (sort)="onSort($event)" | ||||||
|         i18n>Created</th> |         i18n>Created</th> | ||||||
|       <th class="d-none d-xl-table-cell" |       <th class="d-none d-xl-table-cell" | ||||||
|         sortable="added" |         appSortable="added" | ||||||
|         [currentSortField]="list.sortField" |         [currentSortField]="list.sortField" | ||||||
|         [currentSortReverse]="list.sortReverse" |         [currentSortReverse]="list.sortReverse" | ||||||
|         (sort)="onSort($event)" |         (sort)="onSort($event)" | ||||||
| @@ -179,7 +179,7 @@ | |||||||
|           </ng-container> |           </ng-container> | ||||||
|         </td> |         </td> | ||||||
|         <td> |         <td> | ||||||
|           <a (click)="openDocumentsService.openDocument(d)" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a> |           <a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a> | ||||||
|           <app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(t.id);$event.stopPropagation()"></app-tag> |           <app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(t.id);$event.stopPropagation()"></app-tag> | ||||||
|         </td> |         </td> | ||||||
|         <td class="d-none d-xl-table-cell"> |         <td class="d-none d-xl-table-cell"> | ||||||
| @@ -202,7 +202,7 @@ | |||||||
|     </tbody> |     </tbody> | ||||||
|   </table> |   </table> | ||||||
|  |  | ||||||
|   <div class="row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'"> |   <div class="row row-cols-paperless-cards" *ngIf="displayMode === 'smallCards'"> | ||||||
|     <app-document-card-small class="p-0" [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickStoragePath)="clickStoragePath($event)" (clickDocumentType)="clickDocumentType($event)"></app-document-card-small> |     <app-document-card-small class="p-0" [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickStoragePath)="clickStoragePath($event)" (clickDocumentType)="clickDocumentType($event)"></app-document-card-small> | ||||||
|   </div> |   </div> | ||||||
|   <div *ngIf="list.documents?.length > 15" class="mt-3"> |   <div *ngIf="list.documents?.length > 15" class="mt-3"> | ||||||
|   | |||||||
| @@ -5,10 +5,10 @@ | |||||||
|            <div ngbDropdown> |            <div ngbDropdown> | ||||||
|             <button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{textFilterTargetName}}</button> |             <button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{textFilterTargetName}}</button> | ||||||
|             <div class="dropdown-menu shadow" ngbDropdownMenu> |             <div class="dropdown-menu shadow" ngbDropdownMenu> | ||||||
|               <button *ngFor="let t of textFilterTargets" ngbDropdownItem [class.active]="textFilterTarget == t.id" (click)="changeTextFilterTarget(t.id)">{{t.name}}</button> |               <button *ngFor="let t of textFilterTargets" ngbDropdownItem [class.active]="textFilterTarget === t.id" (click)="changeTextFilterTarget(t.id)">{{t.name}}</button> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|           <select *ngIf="textFilterTarget == 'asn'" class="form-select flex-grow-0 w-auto" [(ngModel)]="textFilterModifier" (change)="textFilterModifierChange()"> |           <select *ngIf="textFilterTarget === 'asn'" class="form-select flex-grow-0 w-auto" [(ngModel)]="textFilterModifier" (change)="textFilterModifierChange()"> | ||||||
|             <option *ngFor="let m of textFilterModifiers" ngbDropdownItem [value]="m.id">{{m.label}}</option> |             <option *ngFor="let m of textFilterModifiers" ngbDropdownItem [value]="m.id">{{m.label}}</option> | ||||||
|           </select> |           </select> | ||||||
|           <button *ngIf="_textFilter" class="btn btn-link btn-sm px-0 position-absolute top-0 end-0 z-10" (click)="resetTextField()"> |           <button *ngIf="_textFilter" class="btn btn-link btn-sm px-0 position-absolute top-0 end-0 z-10" (click)="resetTextField()"> | ||||||
| @@ -16,7 +16,7 @@ | |||||||
|               <path fill-rule="evenodd" d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/> |               <path fill-rule="evenodd" d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/> | ||||||
|             </svg> |             </svg> | ||||||
|           </button> |           </button> | ||||||
|           <input #textFilterInput class="form-control form-control-sm" type="text" [disabled]="textFilterModifierIsNull" [(ngModel)]="textFilter" (keyup)="textFilterKeyup($event)" [readonly]="textFilterTarget == 'fulltext-morelike'"> |           <input #textFilterInput class="form-control form-control-sm" type="text" [disabled]="textFilterModifierIsNull" [(ngModel)]="textFilter" (keyup)="textFilterKeyup($event)" [readonly]="textFilterTarget === 'fulltext-morelike'"> | ||||||
|          </div> |          </div> | ||||||
|      </div> |      </div> | ||||||
|   </div> |   </div> | ||||||
|   | |||||||
| @@ -16,10 +16,10 @@ | |||||||
| <table class="table table-striped align-middle border shadow-sm"> | <table class="table table-striped align-middle border shadow-sm"> | ||||||
|   <thead> |   <thead> | ||||||
|     <tr> |     <tr> | ||||||
|       <th scope="col" sortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th> |       <th scope="col" appSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th> | ||||||
|       <th scope="col" class="d-none d-sm-table-cell" sortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th> |       <th scope="col" class="d-none d-sm-table-cell" appSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th> | ||||||
|       <th scope="col" sortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th> |       <th scope="col" appSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th> | ||||||
|       <th scope="col" *ngFor="let column of extraColumns" sortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th> |       <th scope="col" *ngFor="let column of extraColumns" appSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th> | ||||||
|       <th scope="col" i18n>Actions</th> |       <th scope="col" i18n>Actions</th> | ||||||
|     </tr> |     </tr> | ||||||
|   </thead> |   </thead> | ||||||
|   | |||||||
| @@ -120,8 +120,20 @@ export abstract class ManagementListComponent<T extends ObjectWithId> | |||||||
|       backdrop: 'static', |       backdrop: 'static', | ||||||
|     }) |     }) | ||||||
|     activeModal.componentInstance.dialogMode = 'create' |     activeModal.componentInstance.dialogMode = 'create' | ||||||
|     activeModal.componentInstance.success.subscribe((o) => { |     activeModal.componentInstance.success.subscribe({ | ||||||
|       this.reloadData() |       next: () => { | ||||||
|  |         this.reloadData() | ||||||
|  |         this.toastService.showInfo( | ||||||
|  |           $localize`Successfully created ${this.typeName}.` | ||||||
|  |         ) | ||||||
|  |       }, | ||||||
|  |       error: (e) => { | ||||||
|  |         this.toastService.showInfo( | ||||||
|  |           $localize`Error occurred while creating ${ | ||||||
|  |             this.typeName | ||||||
|  |           } : ${e.toString()}.` | ||||||
|  |         ) | ||||||
|  |       }, | ||||||
|     }) |     }) | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -131,8 +143,20 @@ export abstract class ManagementListComponent<T extends ObjectWithId> | |||||||
|     }) |     }) | ||||||
|     activeModal.componentInstance.object = object |     activeModal.componentInstance.object = object | ||||||
|     activeModal.componentInstance.dialogMode = 'edit' |     activeModal.componentInstance.dialogMode = 'edit' | ||||||
|     activeModal.componentInstance.success.subscribe((o) => { |     activeModal.componentInstance.success.subscribe({ | ||||||
|       this.reloadData() |       next: () => { | ||||||
|  |         this.reloadData() | ||||||
|  |         this.toastService.showInfo( | ||||||
|  |           $localize`Successfully updated ${this.typeName}.` | ||||||
|  |         ) | ||||||
|  |       }, | ||||||
|  |       error: (e) => { | ||||||
|  |         this.toastService.showInfo( | ||||||
|  |           $localize`Error occurred while saving ${ | ||||||
|  |             this.typeName | ||||||
|  |           } : ${e.toString()}.` | ||||||
|  |         ) | ||||||
|  |       }, | ||||||
|     }) |     }) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,12 +1,17 @@ | |||||||
| <app-page-header title="Settings" i18n-title> | <app-page-header title="Settings" i18n-title> | ||||||
|   <button class="btn btn-sm btn-outline-primary" (click)="tourService.start()"><ng-container i18n>Start tour</ng-container></button> |   <button class="btn btn-sm btn-outline-primary" (click)="tourService.start()"><ng-container i18n>Start tour</ng-container></button> | ||||||
|  |   <a class="btn btn-sm btn-primary ms-3" href="admin/" target="_blank"> | ||||||
|  |       <ng-container i18n>Open Django Admin</ng-container> | ||||||
|  |       <svg class="sidebaricon ms-1" fill="currentColor"> | ||||||
|  |         <use xlink:href="assets/bootstrap-icons.svg#arrow-up-right"/> | ||||||
|  |       </svg> | ||||||
|  |   </a> | ||||||
| </app-page-header> | </app-page-header> | ||||||
|  |  | ||||||
| <!-- <p>items per page, documents per view type</p> --> |  | ||||||
| <form [formGroup]="settingsForm" (ngSubmit)="saveSettings()"> | <form [formGroup]="settingsForm" (ngSubmit)="saveSettings()"> | ||||||
|  |  | ||||||
|   <ul ngbNav #nav="ngbNav" class="nav-tabs"> |   <ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-tabs"> | ||||||
|     <li [ngbNavItem]="1"> |     <li [ngbNavItem]="SettingsNavIDs.General"> | ||||||
|       <a ngbNavLink i18n>General</a> |       <a ngbNavLink i18n>General</a> | ||||||
|       <ng-template ngbNavContent> |       <ng-template ngbNavContent> | ||||||
|  |  | ||||||
| @@ -19,7 +24,7 @@ | |||||||
|           <div class="col"> |           <div class="col"> | ||||||
|  |  | ||||||
|             <select class="form-select" formControlName="displayLanguage"> |             <select class="form-select" formControlName="displayLanguage"> | ||||||
|               <option *ngFor="let lang of displayLanguageOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code && currentLocale != 'en-US'"> - {{lang.englishName}}</span></option> |               <option *ngFor="let lang of displayLanguageOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code && currentLocale !== 'en-US'"> - {{lang.englishName}}</span></option> | ||||||
|             </select> |             </select> | ||||||
|  |  | ||||||
|             <small *ngIf="displayLanguageIsDirty" class="form-text text-primary" i18n>You need to reload the page after applying a new language.</small> |             <small *ngIf="displayLanguageIsDirty" class="form-text text-primary" i18n>You need to reload the page after applying a new language.</small> | ||||||
| @@ -162,7 +167,7 @@ | |||||||
|       </ng-template> |       </ng-template> | ||||||
|     </li> |     </li> | ||||||
|  |  | ||||||
|     <li [ngbNavItem]="2"> |     <li [ngbNavItem]="SettingsNavIDs.Notifications"> | ||||||
|       <a ngbNavLink i18n>Notifications</a> |       <a ngbNavLink i18n>Notifications</a> | ||||||
|       <ng-template ngbNavContent> |       <ng-template ngbNavContent> | ||||||
|  |  | ||||||
| @@ -180,7 +185,7 @@ | |||||||
|       </ng-template> |       </ng-template> | ||||||
|     </li> |     </li> | ||||||
|  |  | ||||||
|     <li [ngbNavItem]="3"> |     <li [ngbNavItem]="SettingsNavIDs.SavedViews" (mouseover)="maybeInitializeTab(SettingsNavIDs.SavedViews)" (focusin)="maybeInitializeTab(SettingsNavIDs.SavedViews)"> | ||||||
|       <a ngbNavLink i18n>Saved views</a> |       <a ngbNavLink i18n>Saved views</a> | ||||||
|       <ng-template ngbNavContent> |       <ng-template ngbNavContent> | ||||||
|  |  | ||||||
| @@ -210,8 +215,97 @@ | |||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|             <div *ngIf="savedViews.length == 0" i18n>No saved views defined.</div> |             <div *ngIf="savedViews && savedViews.length === 0" i18n>No saved views defined.</div> | ||||||
|  |  | ||||||
|  |             <div *ngIf="!savedViews"> | ||||||
|  |               <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> | ||||||
|  |               <div class="visually-hidden" i18n>Loading...</div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |       </ng-template> | ||||||
|  |     </li> | ||||||
|  |  | ||||||
|  |     <li [ngbNavItem]="SettingsNavIDs.Mail" (mouseover)="maybeInitializeTab(SettingsNavIDs.Mail)" (focusin)="maybeInitializeTab(SettingsNavIDs.Mail)"> | ||||||
|  |       <a ngbNavLink i18n>Mail</a> | ||||||
|  |       <ng-template ngbNavContent> | ||||||
|  |  | ||||||
|  |         <ng-container *ngIf="mailAccounts && mailRules"> | ||||||
|  |           <h4> | ||||||
|  |             <ng-container i18n>Mail accounts</ng-container> | ||||||
|  |             <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailAccount()"> | ||||||
|  |               <svg class="sidebaricon me-1" fill="currentColor"> | ||||||
|  |                 <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||||
|  |               </svg> | ||||||
|  |               <ng-container i18n>Add Account</ng-container> | ||||||
|  |             </button> | ||||||
|  |           </h4> | ||||||
|  |           <ul class="list-group" formGroupName="mailAccounts"> | ||||||
|  |  | ||||||
|  |               <li class="list-group-item"> | ||||||
|  |                 <div class="row"> | ||||||
|  |                   <div class="col" i18n>Name</div> | ||||||
|  |                   <div class="col" i18n>Server</div> | ||||||
|  |                   <div class="col" i18n>Actions</div> | ||||||
|  |                 </div> | ||||||
|  |               </li> | ||||||
|  |  | ||||||
|  |               <li *ngFor="let account of mailAccounts" class="list-group-item" [formGroupName]="account.id"> | ||||||
|  |                 <div class="row"> | ||||||
|  |                   <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)">{{account.name}}</button></div> | ||||||
|  |                   <div class="col d-flex align-items-center">{{account.imap_server}}</div> | ||||||
|  |                   <div class="col"> | ||||||
|  |                     <div class="btn-group"> | ||||||
|  |                       <button class="btn btn-sm btn-primary" type="button" (click)="editMailAccount(account)" i18n>Edit</button> | ||||||
|  |                       <button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailAccount(account)" i18n>Delete</button> | ||||||
|  |                     </div> | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |               </li> | ||||||
|  |  | ||||||
|  |               <div *ngIf="mailAccounts.length === 0" i18n>No mail accounts defined.</div> | ||||||
|  |           </ul> | ||||||
|  |  | ||||||
|  |           <h4 class="mt-4"> | ||||||
|  |             <ng-container i18n>Mail rules</ng-container> | ||||||
|  |             <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailRule()"> | ||||||
|  |               <svg class="sidebaricon me-1" fill="currentColor"> | ||||||
|  |                 <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> | ||||||
|  |               </svg> | ||||||
|  |               <ng-container i18n>Add Rule</ng-container> | ||||||
|  |             </button> | ||||||
|  |           </h4> | ||||||
|  |           <ul class="list-group" formGroupName="mailRules"> | ||||||
|  |  | ||||||
|  |               <li class="list-group-item"> | ||||||
|  |                 <div class="row"> | ||||||
|  |                   <div class="col" i18n>Name</div> | ||||||
|  |                   <div class="col" i18n>Account</div> | ||||||
|  |                   <div class="col" i18n>Actions</div> | ||||||
|  |                 </div> | ||||||
|  |               </li> | ||||||
|  |  | ||||||
|  |               <li *ngFor="let rule of mailRules" class="list-group-item" [formGroupName]="rule.id"> | ||||||
|  |                 <div class="row"> | ||||||
|  |                   <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)">{{rule.name}}</button></div> | ||||||
|  |                   <div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div> | ||||||
|  |                   <div class="col"> | ||||||
|  |                     <div class="btn-group"> | ||||||
|  |                       <button class="btn btn-sm btn-primary" type="button" (click)="editMailRule(rule)" i18n>Edit</button> | ||||||
|  |                       <button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailRule(rule)" i18n>Delete</button> | ||||||
|  |                     </div> | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |               </li> | ||||||
|  |  | ||||||
|  |               <div *ngIf="mailRules.length === 0" i18n>No mail rules defined.</div> | ||||||
|  |           </ul> | ||||||
|  |         </ng-container> | ||||||
|  |  | ||||||
|  |         <div *ngIf="!mailAccounts || !mailRules"> | ||||||
|  |           <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> | ||||||
|  |           <div class="visually-hidden" i18n>Loading...</div> | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|       </ng-template> |       </ng-template> | ||||||
| @@ -220,5 +314,5 @@ | |||||||
|  |  | ||||||
|   <div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div> |   <div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div> | ||||||
|  |  | ||||||
|   <button type="submit" class="btn btn-primary mb-2" [disabled]="!(isDirty$ | async)" i18n>Save</button> |   <button type="submit" class="btn btn-primary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button> | ||||||
| </form> | </form> | ||||||
|   | |||||||
| @@ -26,9 +26,26 @@ import { | |||||||
|   Subject, |   Subject, | ||||||
| } from 'rxjs' | } from 'rxjs' | ||||||
| import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||||
| import { ActivatedRoute } from '@angular/router' | import { ActivatedRoute, Router } from '@angular/router' | ||||||
| import { ViewportScroller } from '@angular/common' | import { ViewportScroller } from '@angular/common' | ||||||
| import { TourService } from 'ngx-ui-tour-ng-bootstrap' | import { TourService } from 'ngx-ui-tour-ng-bootstrap' | ||||||
|  | import { PaperlessMailAccount } from 'src/app/data/paperless-mail-account' | ||||||
|  | import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule' | ||||||
|  | import { MailAccountService } from 'src/app/services/rest/mail-account.service' | ||||||
|  | import { MailRuleService } from 'src/app/services/rest/mail-rule.service' | ||||||
|  | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||||
|  | import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component' | ||||||
|  | import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component' | ||||||
|  | import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||||
|  | import { NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap' | ||||||
|  |  | ||||||
|  | enum SettingsNavIDs { | ||||||
|  |   General = 1, | ||||||
|  |   Notifications = 2, | ||||||
|  |   SavedViews = 3, | ||||||
|  |   Mail = 4, | ||||||
|  |   UsersGroups = 5, | ||||||
|  | } | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-settings', |   selector: 'app-settings', | ||||||
| @@ -38,8 +55,14 @@ import { TourService } from 'ngx-ui-tour-ng-bootstrap' | |||||||
| export class SettingsComponent | export class SettingsComponent | ||||||
|   implements OnInit, AfterViewInit, OnDestroy, DirtyComponent |   implements OnInit, AfterViewInit, OnDestroy, DirtyComponent | ||||||
| { | { | ||||||
|  |   SettingsNavIDs = SettingsNavIDs | ||||||
|  |   activeNavID: number | ||||||
|  |  | ||||||
|   savedViewGroup = new FormGroup({}) |   savedViewGroup = new FormGroup({}) | ||||||
|  |  | ||||||
|  |   mailAccountGroup = new FormGroup({}) | ||||||
|  |   mailRuleGroup = new FormGroup({}) | ||||||
|  |  | ||||||
|   settingsForm = new FormGroup({ |   settingsForm = new FormGroup({ | ||||||
|     bulkEditConfirmationDialogs: new FormControl(null), |     bulkEditConfirmationDialogs: new FormControl(null), | ||||||
|     bulkEditApplyOnClose: new FormControl(null), |     bulkEditApplyOnClose: new FormControl(null), | ||||||
| @@ -50,20 +73,28 @@ export class SettingsComponent | |||||||
|     darkModeInvertThumbs: new FormControl(null), |     darkModeInvertThumbs: new FormControl(null), | ||||||
|     themeColor: new FormControl(null), |     themeColor: new FormControl(null), | ||||||
|     useNativePdfViewer: new FormControl(null), |     useNativePdfViewer: new FormControl(null), | ||||||
|     savedViews: this.savedViewGroup, |  | ||||||
|     displayLanguage: new FormControl(null), |     displayLanguage: new FormControl(null), | ||||||
|     dateLocale: new FormControl(null), |     dateLocale: new FormControl(null), | ||||||
|     dateFormat: new FormControl(null), |     dateFormat: new FormControl(null), | ||||||
|  |     commentsEnabled: new FormControl(null), | ||||||
|  |     updateCheckingEnabled: new FormControl(null), | ||||||
|  |  | ||||||
|     notificationsConsumerNewDocument: new FormControl(null), |     notificationsConsumerNewDocument: new FormControl(null), | ||||||
|     notificationsConsumerSuccess: new FormControl(null), |     notificationsConsumerSuccess: new FormControl(null), | ||||||
|     notificationsConsumerFailed: new FormControl(null), |     notificationsConsumerFailed: new FormControl(null), | ||||||
|     notificationsConsumerSuppressOnDashboard: new FormControl(null), |     notificationsConsumerSuppressOnDashboard: new FormControl(null), | ||||||
|     commentsEnabled: new FormControl(null), |  | ||||||
|     updateCheckingEnabled: new FormControl(null), |     savedViews: this.savedViewGroup, | ||||||
|  |  | ||||||
|  |     mailAccounts: this.mailAccountGroup, | ||||||
|  |     mailRules: this.mailRuleGroup, | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   savedViews: PaperlessSavedView[] |   savedViews: PaperlessSavedView[] | ||||||
|  |  | ||||||
|  |   mailAccounts: PaperlessMailAccount[] | ||||||
|  |   mailRules: PaperlessMailRule[] | ||||||
|  |  | ||||||
|   store: BehaviorSubject<any> |   store: BehaviorSubject<any> | ||||||
|   storeSub: Subscription |   storeSub: Subscription | ||||||
|   isDirty$: Observable<boolean> |   isDirty$: Observable<boolean> | ||||||
| @@ -81,19 +112,40 @@ export class SettingsComponent | |||||||
|  |  | ||||||
|   constructor( |   constructor( | ||||||
|     public savedViewService: SavedViewService, |     public savedViewService: SavedViewService, | ||||||
|  |     public mailAccountService: MailAccountService, | ||||||
|  |     public mailRuleService: MailRuleService, | ||||||
|     private documentListViewService: DocumentListViewService, |     private documentListViewService: DocumentListViewService, | ||||||
|     private toastService: ToastService, |     private toastService: ToastService, | ||||||
|     private settings: SettingsService, |     private settings: SettingsService, | ||||||
|     @Inject(LOCALE_ID) public currentLocale: string, |     @Inject(LOCALE_ID) public currentLocale: string, | ||||||
|     private viewportScroller: ViewportScroller, |     private viewportScroller: ViewportScroller, | ||||||
|     private activatedRoute: ActivatedRoute, |     private activatedRoute: ActivatedRoute, | ||||||
|     public readonly tourService: TourService |     private router: Router, | ||||||
|  |     public readonly tourService: TourService, | ||||||
|  |     private modalService: NgbModal | ||||||
|   ) { |   ) { | ||||||
|     this.settings.settingsSaved.subscribe(() => { |     this.settings.settingsSaved.subscribe(() => { | ||||||
|       if (!this.savePending) this.initialize() |       if (!this.savePending) this.initialize() | ||||||
|     }) |     }) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   ngOnInit() { | ||||||
|  |     this.initialize() | ||||||
|  |  | ||||||
|  |     this.activatedRoute.paramMap.subscribe((paramMap) => { | ||||||
|  |       const section = paramMap.get('section') | ||||||
|  |       if (section) { | ||||||
|  |         const navIDKey: string = Object.keys(SettingsNavIDs).find( | ||||||
|  |           (navID) => navID.toLowerCase() == section | ||||||
|  |         ) | ||||||
|  |         if (navIDKey) { | ||||||
|  |           this.activeNavID = SettingsNavIDs[navIDKey] | ||||||
|  |           this.maybeInitializeTab(this.activeNavID) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  |  | ||||||
|   ngAfterViewInit(): void { |   ngAfterViewInit(): void { | ||||||
|     if (this.activatedRoute.snapshot.fragment) { |     if (this.activatedRoute.snapshot.fragment) { | ||||||
|       this.viewportScroller.scrollToAnchor( |       this.viewportScroller.scrollToAnchor( | ||||||
| @@ -123,10 +175,13 @@ export class SettingsComponent | |||||||
|       useNativePdfViewer: this.settings.get( |       useNativePdfViewer: this.settings.get( | ||||||
|         SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER |         SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER | ||||||
|       ), |       ), | ||||||
|       savedViews: {}, |  | ||||||
|       displayLanguage: this.settings.getLanguage(), |       displayLanguage: this.settings.getLanguage(), | ||||||
|       dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE), |       dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE), | ||||||
|       dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT), |       dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT), | ||||||
|  |       commentsEnabled: this.settings.get(SETTINGS_KEYS.COMMENTS_ENABLED), | ||||||
|  |       updateCheckingEnabled: this.settings.get( | ||||||
|  |         SETTINGS_KEYS.UPDATE_CHECKING_ENABLED | ||||||
|  |       ), | ||||||
|       notificationsConsumerNewDocument: this.settings.get( |       notificationsConsumerNewDocument: this.settings.get( | ||||||
|         SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT |         SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT | ||||||
|       ), |       ), | ||||||
| @@ -139,41 +194,147 @@ export class SettingsComponent | |||||||
|       notificationsConsumerSuppressOnDashboard: this.settings.get( |       notificationsConsumerSuppressOnDashboard: this.settings.get( | ||||||
|         SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD |         SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD | ||||||
|       ), |       ), | ||||||
|       commentsEnabled: this.settings.get(SETTINGS_KEYS.COMMENTS_ENABLED), |       savedViews: {}, | ||||||
|       updateCheckingEnabled: this.settings.get( |       mailAccounts: {}, | ||||||
|         SETTINGS_KEYS.UPDATE_CHECKING_ENABLED |       mailRules: {}, | ||||||
|       ), |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   ngOnInit() { |   onNavChange(navChangeEvent: NgbNavChangeEvent) { | ||||||
|     this.savedViewService.listAll().subscribe((r) => { |     this.maybeInitializeTab(navChangeEvent.nextId) | ||||||
|       this.savedViews = r.results |     const [foundNavIDkey, foundNavIDValue] = Object.entries( | ||||||
|       this.initialize() |       SettingsNavIDs | ||||||
|     }) |     ).find(([navIDkey, navIDValue]) => navIDValue == navChangeEvent.nextId) | ||||||
|  |     if (foundNavIDkey) | ||||||
|  |       // if its dirty we need to wait for confirmation | ||||||
|  |       this.router | ||||||
|  |         .navigate(['settings', foundNavIDkey.toLowerCase()]) | ||||||
|  |         .then((navigated) => { | ||||||
|  |           if (!navigated && this.isDirty) { | ||||||
|  |             this.activeNavID = navChangeEvent.activeId | ||||||
|  |           } else if (navigated && this.isDirty) { | ||||||
|  |             this.initialize() | ||||||
|  |           } | ||||||
|  |         }) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   initialize() { |   // Load tab contents 'on demand', either on mouseover or focusin (i.e. before click) or called from nav change event | ||||||
|  |   maybeInitializeTab(navID: number): void { | ||||||
|  |     if (navID == SettingsNavIDs.SavedViews && !this.savedViews) { | ||||||
|  |       this.savedViewService.listAll().subscribe((r) => { | ||||||
|  |         this.savedViews = r.results | ||||||
|  |         this.initialize(false) | ||||||
|  |       }) | ||||||
|  |     } else if ( | ||||||
|  |       navID == SettingsNavIDs.Mail && | ||||||
|  |       (!this.mailAccounts || !this.mailRules) | ||||||
|  |     ) { | ||||||
|  |       this.mailAccountService.listAll().subscribe((r) => { | ||||||
|  |         this.mailAccounts = r.results | ||||||
|  |  | ||||||
|  |         this.mailRuleService.listAll().subscribe((r) => { | ||||||
|  |           this.mailRules = r.results | ||||||
|  |           this.initialize(false) | ||||||
|  |         }) | ||||||
|  |       }) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   initialize(resetSettings: boolean = true) { | ||||||
|     this.unsubscribeNotifier.next(true) |     this.unsubscribeNotifier.next(true) | ||||||
|  |  | ||||||
|  |     const currentFormValue = this.settingsForm.value | ||||||
|  |  | ||||||
|     let storeData = this.getCurrentSettings() |     let storeData = this.getCurrentSettings() | ||||||
|  |  | ||||||
|     for (let view of this.savedViews) { |     if (this.savedViews) { | ||||||
|       storeData.savedViews[view.id.toString()] = { |       for (let view of this.savedViews) { | ||||||
|         id: view.id, |         storeData.savedViews[view.id.toString()] = { | ||||||
|         name: view.name, |           id: view.id, | ||||||
|         show_on_dashboard: view.show_on_dashboard, |           name: view.name, | ||||||
|         show_in_sidebar: view.show_in_sidebar, |           show_on_dashboard: view.show_on_dashboard, | ||||||
|  |           show_in_sidebar: view.show_in_sidebar, | ||||||
|  |         } | ||||||
|  |         this.savedViewGroup.addControl( | ||||||
|  |           view.id.toString(), | ||||||
|  |           new FormGroup({ | ||||||
|  |             id: new FormControl(null), | ||||||
|  |             name: new FormControl(null), | ||||||
|  |             show_on_dashboard: new FormControl(null), | ||||||
|  |             show_in_sidebar: new FormControl(null), | ||||||
|  |           }) | ||||||
|  |         ) | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (this.mailAccounts && this.mailRules) { | ||||||
|  |       for (let account of this.mailAccounts) { | ||||||
|  |         storeData.mailAccounts[account.id.toString()] = { | ||||||
|  |           id: account.id, | ||||||
|  |           name: account.name, | ||||||
|  |           imap_server: account.imap_server, | ||||||
|  |           imap_port: account.imap_port, | ||||||
|  |           imap_security: account.imap_security, | ||||||
|  |           username: account.username, | ||||||
|  |           password: account.password, | ||||||
|  |           character_set: account.character_set, | ||||||
|  |         } | ||||||
|  |         this.mailAccountGroup.addControl( | ||||||
|  |           account.id.toString(), | ||||||
|  |           new FormGroup({ | ||||||
|  |             id: new FormControl(null), | ||||||
|  |             name: new FormControl(null), | ||||||
|  |             imap_server: new FormControl(null), | ||||||
|  |             imap_port: new FormControl(null), | ||||||
|  |             imap_security: new FormControl(null), | ||||||
|  |             username: new FormControl(null), | ||||||
|  |             password: new FormControl(null), | ||||||
|  |             character_set: new FormControl(null), | ||||||
|  |           }) | ||||||
|  |         ) | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       for (let rule of this.mailRules) { | ||||||
|  |         storeData.mailRules[rule.id.toString()] = { | ||||||
|  |           name: rule.name, | ||||||
|  |           account: rule.account, | ||||||
|  |           folder: rule.folder, | ||||||
|  |           filter_from: rule.filter_from, | ||||||
|  |           filter_subject: rule.filter_subject, | ||||||
|  |           filter_body: rule.filter_body, | ||||||
|  |           filter_attachment_filename: rule.filter_attachment_filename, | ||||||
|  |           maximum_age: rule.maximum_age, | ||||||
|  |           attachment_type: rule.attachment_type, | ||||||
|  |           action: rule.action, | ||||||
|  |           action_parameter: rule.action_parameter, | ||||||
|  |           assign_title_from: rule.assign_title_from, | ||||||
|  |           assign_tags: rule.assign_tags, | ||||||
|  |           assign_document_type: rule.assign_document_type, | ||||||
|  |           assign_correspondent_from: rule.assign_correspondent_from, | ||||||
|  |           assign_correspondent: rule.assign_correspondent, | ||||||
|  |         } | ||||||
|  |         this.mailRuleGroup.addControl( | ||||||
|  |           rule.id.toString(), | ||||||
|  |           new FormGroup({ | ||||||
|  |             name: new FormControl(null), | ||||||
|  |             account: new FormControl(null), | ||||||
|  |             folder: new FormControl(null), | ||||||
|  |             filter_from: new FormControl(null), | ||||||
|  |             filter_subject: new FormControl(null), | ||||||
|  |             filter_body: new FormControl(null), | ||||||
|  |             filter_attachment_filename: new FormControl(null), | ||||||
|  |             maximum_age: new FormControl(null), | ||||||
|  |             attachment_type: new FormControl(null), | ||||||
|  |             action: new FormControl(null), | ||||||
|  |             action_parameter: new FormControl(null), | ||||||
|  |             assign_title_from: new FormControl(null), | ||||||
|  |             assign_tags: new FormControl(null), | ||||||
|  |             assign_document_type: new FormControl(null), | ||||||
|  |             assign_correspondent_from: new FormControl(null), | ||||||
|  |             assign_correspondent: new FormControl(null), | ||||||
|  |           }) | ||||||
|  |         ) | ||||||
|       } |       } | ||||||
|       this.savedViewGroup.addControl( |  | ||||||
|         view.id.toString(), |  | ||||||
|         new FormGroup({ |  | ||||||
|           id: new FormControl(null), |  | ||||||
|           name: new FormControl(null), |  | ||||||
|           show_on_dashboard: new FormControl(null), |  | ||||||
|           show_in_sidebar: new FormControl(null), |  | ||||||
|         }) |  | ||||||
|       ) |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     this.store = new BehaviorSubject(storeData) |     this.store = new BehaviorSubject(storeData) | ||||||
| @@ -202,6 +363,11 @@ export class SettingsComponent | |||||||
|           this.settingsForm.get('themeColor').value |           this.settingsForm.get('themeColor').value | ||||||
|         ) |         ) | ||||||
|       }) |       }) | ||||||
|  |  | ||||||
|  |     if (!resetSettings && currentFormValue) { | ||||||
|  |       // prevents loss of unsaved changes | ||||||
|  |       this.settingsForm.patchValue(currentFormValue) | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   ngOnDestroy() { |   ngOnDestroy() { | ||||||
| @@ -372,4 +538,121 @@ export class SettingsComponent | |||||||
|   clearThemeColor() { |   clearThemeColor() { | ||||||
|     this.settingsForm.get('themeColor').patchValue('') |     this.settingsForm.get('themeColor').patchValue('') | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   editMailAccount(account: PaperlessMailAccount) { | ||||||
|  |     const modal = this.modalService.open(MailAccountEditDialogComponent, { | ||||||
|  |       backdrop: 'static', | ||||||
|  |       size: 'xl', | ||||||
|  |     }) | ||||||
|  |     modal.componentInstance.dialogMode = account ? 'edit' : 'create' | ||||||
|  |     modal.componentInstance.object = account | ||||||
|  |     modal.componentInstance.success | ||||||
|  |       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||||
|  |       .subscribe({ | ||||||
|  |         next: (newMailAccount) => { | ||||||
|  |           this.toastService.showInfo( | ||||||
|  |             $localize`Saved account "${newMailAccount.name}".` | ||||||
|  |           ) | ||||||
|  |           this.mailAccountService.clearCache() | ||||||
|  |           this.mailAccountService.listAll().subscribe((r) => { | ||||||
|  |             this.mailAccounts = r.results | ||||||
|  |             this.initialize() | ||||||
|  |           }) | ||||||
|  |         }, | ||||||
|  |         error: (e) => { | ||||||
|  |           this.toastService.showError( | ||||||
|  |             $localize`Error saving account: ${e.toString()}.` | ||||||
|  |           ) | ||||||
|  |         }, | ||||||
|  |       }) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   deleteMailAccount(account: PaperlessMailAccount) { | ||||||
|  |     const modal = this.modalService.open(ConfirmDialogComponent, { | ||||||
|  |       backdrop: 'static', | ||||||
|  |     }) | ||||||
|  |     modal.componentInstance.title = $localize`Confirm delete mail account` | ||||||
|  |     modal.componentInstance.messageBold = $localize`This operation will permanently delete this mail account.` | ||||||
|  |     modal.componentInstance.message = $localize`This operation cannot be undone.` | ||||||
|  |     modal.componentInstance.btnClass = 'btn-danger' | ||||||
|  |     modal.componentInstance.btnCaption = $localize`Proceed` | ||||||
|  |     modal.componentInstance.confirmClicked.subscribe(() => { | ||||||
|  |       modal.componentInstance.buttonsEnabled = false | ||||||
|  |       this.mailAccountService.delete(account).subscribe({ | ||||||
|  |         next: () => { | ||||||
|  |           modal.close() | ||||||
|  |           this.toastService.showInfo($localize`Deleted mail account`) | ||||||
|  |           this.mailAccountService.clearCache() | ||||||
|  |           this.mailAccountService.listAll().subscribe((r) => { | ||||||
|  |             this.mailAccounts = r.results | ||||||
|  |             this.initialize() | ||||||
|  |           }) | ||||||
|  |         }, | ||||||
|  |         error: (e) => { | ||||||
|  |           this.toastService.showError( | ||||||
|  |             $localize`Error deleting mail account: ${e.toString()}.` | ||||||
|  |           ) | ||||||
|  |         }, | ||||||
|  |       }) | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   editMailRule(rule: PaperlessMailRule) { | ||||||
|  |     const modal = this.modalService.open(MailRuleEditDialogComponent, { | ||||||
|  |       backdrop: 'static', | ||||||
|  |       size: 'xl', | ||||||
|  |     }) | ||||||
|  |     modal.componentInstance.dialogMode = rule ? 'edit' : 'create' | ||||||
|  |     modal.componentInstance.object = rule | ||||||
|  |     modal.componentInstance.success | ||||||
|  |       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||||
|  |       .subscribe({ | ||||||
|  |         next: (newMailRule) => { | ||||||
|  |           this.toastService.showInfo( | ||||||
|  |             $localize`Saved rule "${newMailRule.name}".` | ||||||
|  |           ) | ||||||
|  |           this.mailRuleService.clearCache() | ||||||
|  |           this.mailRuleService.listAll().subscribe((r) => { | ||||||
|  |             this.mailRules = r.results | ||||||
|  |  | ||||||
|  |             this.initialize() | ||||||
|  |           }) | ||||||
|  |         }, | ||||||
|  |         error: (e) => { | ||||||
|  |           this.toastService.showError( | ||||||
|  |             $localize`Error saving rule: ${e.toString()}.` | ||||||
|  |           ) | ||||||
|  |         }, | ||||||
|  |       }) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   deleteMailRule(rule: PaperlessMailRule) { | ||||||
|  |     const modal = this.modalService.open(ConfirmDialogComponent, { | ||||||
|  |       backdrop: 'static', | ||||||
|  |     }) | ||||||
|  |     modal.componentInstance.title = $localize`Confirm delete mail rule` | ||||||
|  |     modal.componentInstance.messageBold = $localize`This operation will permanently delete this mail rule.` | ||||||
|  |     modal.componentInstance.message = $localize`This operation cannot be undone.` | ||||||
|  |     modal.componentInstance.btnClass = 'btn-danger' | ||||||
|  |     modal.componentInstance.btnCaption = $localize`Proceed` | ||||||
|  |     modal.componentInstance.confirmClicked.subscribe(() => { | ||||||
|  |       modal.componentInstance.buttonsEnabled = false | ||||||
|  |       this.mailRuleService.delete(rule).subscribe({ | ||||||
|  |         next: () => { | ||||||
|  |           modal.close() | ||||||
|  |           this.toastService.showInfo($localize`Deleted mail rule`) | ||||||
|  |           this.mailRuleService.clearCache() | ||||||
|  |           this.mailRuleService.listAll().subscribe((r) => { | ||||||
|  |             this.mailRules = r.results | ||||||
|  |             this.initialize() | ||||||
|  |           }) | ||||||
|  |         }, | ||||||
|  |         error: (e) => { | ||||||
|  |           this.toastService.showError( | ||||||
|  |             $localize`Error deleting mail rule: ${e.toString()}.` | ||||||
|  |           ) | ||||||
|  |         }, | ||||||
|  |       }) | ||||||
|  |     }) | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,11 +1,11 @@ | |||||||
| <app-page-header title="File Tasks" i18n-title> | <app-page-header title="File Tasks" i18n-title> | ||||||
|   <div class="btn-toolbar col col-md-auto"> |   <div class="btn-toolbar col col-md-auto"> | ||||||
|     <button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size == 0"> |     <button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0"> | ||||||
|       <svg class="sidebaricon" fill="currentColor"> |       <svg class="sidebaricon" fill="currentColor"> | ||||||
|         <use xlink:href="assets/bootstrap-icons.svg#x"/> |         <use xlink:href="assets/bootstrap-icons.svg#x"/> | ||||||
|       </svg> <ng-container i18n>Clear selection</ng-container> |       </svg> <ng-container i18n>Clear selection</ng-container> | ||||||
|     </button> |     </button> | ||||||
|     <button class="btn btn-sm btn-outline-primary me-4" (click)="dismissTasks()" [disabled]="tasksService.total == 0"> |     <button class="btn btn-sm btn-outline-primary me-4" (click)="dismissTasks()" [disabled]="tasksService.total === 0"> | ||||||
|       <svg class="sidebaricon" fill="currentColor"> |       <svg class="sidebaricon" fill="currentColor"> | ||||||
|         <use xlink:href="assets/bootstrap-icons.svg#check2-all"/> |         <use xlink:href="assets/bootstrap-icons.svg#check2-all"/> | ||||||
|       </svg> <ng-container i18n>{{dismissButtonText}}</ng-container> |       </svg> <ng-container i18n>{{dismissButtonText}}</ng-container> | ||||||
| @@ -33,13 +33,13 @@ | |||||||
|       <tr> |       <tr> | ||||||
|         <th scope="col"> |         <th scope="col"> | ||||||
|           <div class="form-check"> |           <div class="form-check"> | ||||||
|             <input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length == 0" (click)="toggleAll($event); $event.stopPropagation();"> |             <input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length === 0" (click)="toggleAll($event); $event.stopPropagation();"> | ||||||
|             <label class="form-check-label" for="all-tasks"></label> |             <label class="form-check-label" for="all-tasks"></label> | ||||||
|           </div> |           </div> | ||||||
|         </th> |         </th> | ||||||
|         <th scope="col" i18n>Name</th> |         <th scope="col" i18n>Name</th> | ||||||
|         <th scope="col" class="d-none d-lg-table-cell" i18n>Created</th> |         <th scope="col" class="d-none d-lg-table-cell" i18n>Created</th> | ||||||
|         <th scope="col" class="d-none d-lg-table-cell" *ngIf="activeTab != 'started' && activeTab != 'queued'" i18n>Results</th> |         <th scope="col" class="d-none d-lg-table-cell" *ngIf="activeTab !== 'started' && activeTab !== 'queued'" i18n>Results</th> | ||||||
|         <th scope="col" class="d-table-cell d-lg-none" i18n>Info</th> |         <th scope="col" class="d-table-cell d-lg-none" i18n>Info</th> | ||||||
|         <th scope="col" i18n>Actions</th> |         <th scope="col" i18n>Actions</th> | ||||||
|       </tr> |       </tr> | ||||||
| @@ -55,7 +55,7 @@ | |||||||
|         </th> |         </th> | ||||||
|         <td class="overflow-auto">{{ task.task_file_name }}</td> |         <td class="overflow-auto">{{ task.task_file_name }}</td> | ||||||
|         <td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td> |         <td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td> | ||||||
|         <td class="d-none d-lg-table-cell" *ngIf="activeTab != 'started' && activeTab != 'queued'"> |         <td class="d-none d-lg-table-cell" *ngIf="activeTab !== 'started' && activeTab !== 'queued'"> | ||||||
|           <div *ngIf="task.result?.length > 50" class="result" (click)="expandTask(task); $event.stopPropagation();" |           <div *ngIf="task.result?.length > 50" class="result" (click)="expandTask(task); $event.stopPropagation();" | ||||||
|             [ngbPopover]="resultPopover" popoverClass="shadow small mobile" triggers="mouseenter:mouseleave" container="body"> |             [ngbPopover]="resultPopover" popoverClass="shadow small mobile" triggers="mouseenter:mouseleave" container="body"> | ||||||
|             <span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result | slice:0:50 }}…</span> |             <span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result | slice:0:50 }}…</span> | ||||||
| @@ -89,7 +89,7 @@ | |||||||
|         </td> |         </td> | ||||||
|       </tr> |       </tr> | ||||||
|       <tr> |       <tr> | ||||||
|         <td class="p-0" [class.border-0]="expandedTask != task.id" colspan="5"> |         <td class="p-0" [class.border-0]="expandedTask !== task.id" colspan="5"> | ||||||
|           <pre #collapse="ngbCollapse" [ngbCollapse]="expandedTask !== task.id" class="small mb-0"><div class="small p-1 p-lg-3 ms-lg-3">{{ task.result }}</div></pre> |           <pre #collapse="ngbCollapse" [ngbCollapse]="expandedTask !== task.id" class="small mb-0"><div class="small p-1 p-lg-3 ms-lg-3">{{ task.result }}</div></pre> | ||||||
|         </td> |         </td> | ||||||
|       </tr> |       </tr> | ||||||
|   | |||||||
| @@ -1,12 +1,10 @@ | |||||||
| import { Component, OnInit } from '@angular/core' | import { Component } from '@angular/core' | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-not-found', |   selector: 'app-not-found', | ||||||
|   templateUrl: './not-found.component.html', |   templateUrl: './not-found.component.html', | ||||||
|   styleUrls: ['./not-found.component.scss'], |   styleUrls: ['./not-found.component.scss'], | ||||||
| }) | }) | ||||||
| export class NotFoundComponent implements OnInit { | export class NotFoundComponent { | ||||||
|   constructor() {} |   constructor() {} | ||||||
|  |  | ||||||
|   ngOnInit(): void {} |  | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										23
									
								
								src-ui/src/app/data/paperless-mail-account.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src-ui/src/app/data/paperless-mail-account.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | import { ObjectWithId } from './object-with-id' | ||||||
|  |  | ||||||
|  | export enum IMAPSecurity { | ||||||
|  |   None = 1, | ||||||
|  |   SSL = 2, | ||||||
|  |   STARTTLS = 3, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface PaperlessMailAccount extends ObjectWithId { | ||||||
|  |   name: string | ||||||
|  |  | ||||||
|  |   imap_server: string | ||||||
|  |  | ||||||
|  |   imap_port: number | ||||||
|  |  | ||||||
|  |   imap_security: IMAPSecurity | ||||||
|  |  | ||||||
|  |   username: string | ||||||
|  |  | ||||||
|  |   password: string | ||||||
|  |  | ||||||
|  |   character_set?: string | ||||||
|  | } | ||||||
							
								
								
									
										60
									
								
								src-ui/src/app/data/paperless-mail-rule.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src-ui/src/app/data/paperless-mail-rule.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | |||||||
|  | import { ObjectWithId } from './object-with-id' | ||||||
|  |  | ||||||
|  | export enum MailFilterAttachmentType { | ||||||
|  |   Attachments = 1, | ||||||
|  |   Everything = 2, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export enum MailAction { | ||||||
|  |   Delete = 1, | ||||||
|  |   Move = 2, | ||||||
|  |   MarkRead = 3, | ||||||
|  |   Flag = 4, | ||||||
|  |   Tag = 5, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export enum MailMetadataTitleOption { | ||||||
|  |   FromSubject = 1, | ||||||
|  |   FromFilename = 2, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export enum MailMetadataCorrespondentOption { | ||||||
|  |   FromNothing = 1, | ||||||
|  |   FromEmail = 2, | ||||||
|  |   FromName = 3, | ||||||
|  |   FromCustom = 4, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface PaperlessMailRule extends ObjectWithId { | ||||||
|  |   name: string | ||||||
|  |  | ||||||
|  |   account: number // PaperlessMailAccount.id | ||||||
|  |  | ||||||
|  |   folder: string | ||||||
|  |  | ||||||
|  |   filter_from: string | ||||||
|  |  | ||||||
|  |   filter_subject: string | ||||||
|  |  | ||||||
|  |   filter_body: string | ||||||
|  |  | ||||||
|  |   filter_attachment_filename: string | ||||||
|  |  | ||||||
|  |   maximum_age: number | ||||||
|  |  | ||||||
|  |   attachment_type: MailFilterAttachmentType | ||||||
|  |  | ||||||
|  |   action: MailAction | ||||||
|  |  | ||||||
|  |   action_parameter?: string | ||||||
|  |  | ||||||
|  |   assign_title_from: MailMetadataTitleOption | ||||||
|  |  | ||||||
|  |   assign_tags?: number[] // PaperlessTag.id | ||||||
|  |  | ||||||
|  |   assign_document_type?: number // PaperlessDocumentType.id | ||||||
|  |  | ||||||
|  |   assign_correspondent_from?: MailMetadataCorrespondentOption | ||||||
|  |  | ||||||
|  |   assign_correspondent?: number // PaperlessCorrespondent.id | ||||||
|  | } | ||||||
| @@ -1,4 +1,11 @@ | |||||||
| import { Directive, EventEmitter, Input, Output } from '@angular/core' | import { | ||||||
|  |   Directive, | ||||||
|  |   EventEmitter, | ||||||
|  |   HostBinding, | ||||||
|  |   HostListener, | ||||||
|  |   Input, | ||||||
|  |   Output, | ||||||
|  | } from '@angular/core' | ||||||
|  |  | ||||||
| export interface SortEvent { | export interface SortEvent { | ||||||
|   column: string |   column: string | ||||||
| @@ -6,18 +13,13 @@ export interface SortEvent { | |||||||
| } | } | ||||||
|  |  | ||||||
| @Directive({ | @Directive({ | ||||||
|   selector: 'th[sortable]', |   selector: 'th[appSortable]', | ||||||
|   host: { |  | ||||||
|     '[class.asc]': 'currentSortField == sortable && !currentSortReverse', |  | ||||||
|     '[class.des]': 'currentSortField == sortable && currentSortReverse', |  | ||||||
|     '(click)': 'rotate()', |  | ||||||
|   }, |  | ||||||
| }) | }) | ||||||
| export class SortableDirective { | export class SortableDirective { | ||||||
|   constructor() {} |   constructor() {} | ||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
|   sortable: string = '' |   appSortable: string = '' | ||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
|   currentSortReverse: boolean = false |   currentSortReverse: boolean = false | ||||||
| @@ -27,11 +29,20 @@ export class SortableDirective { | |||||||
|  |  | ||||||
|   @Output() sort = new EventEmitter<SortEvent>() |   @Output() sort = new EventEmitter<SortEvent>() | ||||||
|  |  | ||||||
|   rotate() { |   @HostBinding('class.asc') get asc() { | ||||||
|     if (this.currentSortField != this.sortable) { |     return ( | ||||||
|       this.sort.emit({ column: this.sortable, reverse: false }) |       this.currentSortField === this.appSortable && !this.currentSortReverse | ||||||
|  |     ) | ||||||
|  |   } | ||||||
|  |   @HostBinding('class.des') get des() { | ||||||
|  |     return this.currentSortField === this.appSortable && this.currentSortReverse | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @HostListener('click') rotate() { | ||||||
|  |     if (this.currentSortField != this.appSortable) { | ||||||
|  |       this.sort.emit({ column: this.appSortable, reverse: false }) | ||||||
|     } else if ( |     } else if ( | ||||||
|       this.currentSortField == this.sortable && |       this.currentSortField == this.appSortable && | ||||||
|       !this.currentSortReverse |       !this.currentSortReverse | ||||||
|     ) { |     ) { | ||||||
|       this.sort.emit({ column: this.currentSortField, reverse: true }) |       this.sort.emit({ column: this.currentSortField, reverse: true }) | ||||||
|   | |||||||
| @@ -1,7 +1,6 @@ | |||||||
| import { Injectable } from '@angular/core' | import { Injectable } from '@angular/core' | ||||||
| import { DirtyCheckGuard } from '@ngneat/dirty-check-forms' | import { DirtyCheckGuard } from '@ngneat/dirty-check-forms' | ||||||
| import { Observable, Subject } from 'rxjs' | import { Observable, Subject } from 'rxjs' | ||||||
| import { map } from 'rxjs/operators' |  | ||||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||||
| import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' | import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' | ||||||
|  |  | ||||||
|   | |||||||
| @@ -213,7 +213,8 @@ export class DocumentListViewService { | |||||||
|         this.currentPageSize, |         this.currentPageSize, | ||||||
|         activeListViewState.sortField, |         activeListViewState.sortField, | ||||||
|         activeListViewState.sortReverse, |         activeListViewState.sortReverse, | ||||||
|         activeListViewState.filterRules |         activeListViewState.filterRules, | ||||||
|  |         { truncate_content: true } | ||||||
|       ) |       ) | ||||||
|       .subscribe({ |       .subscribe({ | ||||||
|         next: (result) => { |         next: (result) => { | ||||||
|   | |||||||
| @@ -6,7 +6,6 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | |||||||
| import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' | import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' | ||||||
| import { Observable, Subject, of } from 'rxjs' | import { Observable, Subject, of } from 'rxjs' | ||||||
| import { first } from 'rxjs/operators' | import { first } from 'rxjs/operators' | ||||||
| import { Router } from '@angular/router' |  | ||||||
|  |  | ||||||
| @Injectable({ | @Injectable({ | ||||||
|   providedIn: 'root', |   providedIn: 'root', | ||||||
| @@ -16,8 +15,7 @@ export class OpenDocumentsService { | |||||||
|  |  | ||||||
|   constructor( |   constructor( | ||||||
|     private documentService: DocumentService, |     private documentService: DocumentService, | ||||||
|     private modalService: NgbModal, |     private modalService: NgbModal | ||||||
|     private router: Router |  | ||||||
|   ) { |   ) { | ||||||
|     if (sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS)) { |     if (sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS)) { | ||||||
|       try { |       try { | ||||||
| @@ -57,39 +55,28 @@ export class OpenDocumentsService { | |||||||
|     return this.openDocuments.find((d) => d.id == id) |     return this.openDocuments.find((d) => d.id == id) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   openDocument( |   openDocument(doc: PaperlessDocument): Observable<boolean> { | ||||||
|     doc: PaperlessDocument, |  | ||||||
|     navigate: boolean = true |  | ||||||
|   ): Observable<boolean> { |  | ||||||
|     if (this.openDocuments.find((d) => d.id == doc.id) == null) { |     if (this.openDocuments.find((d) => d.id == doc.id) == null) { | ||||||
|       if (this.openDocuments.length == this.MAX_OPEN_DOCUMENTS) { |       if (this.openDocuments.length == this.MAX_OPEN_DOCUMENTS) { | ||||||
|         // at max, ensure changes arent lost |         // at max, ensure changes arent lost | ||||||
|         const docToRemove = this.openDocuments[this.MAX_OPEN_DOCUMENTS - 1] |         const docToRemove = this.openDocuments[this.MAX_OPEN_DOCUMENTS - 1] | ||||||
|         const closeObservable = this.closeDocument(docToRemove) |         const closeObservable = this.closeDocument(docToRemove) | ||||||
|         closeObservable.pipe(first()).subscribe((closed) => { |         closeObservable.pipe(first()).subscribe((closed) => { | ||||||
|           if (closed) this.finishOpenDocument(doc, navigate) |           if (closed) this.finishOpenDocument(doc) | ||||||
|         }) |         }) | ||||||
|         return closeObservable |         return closeObservable | ||||||
|       } else { |       } else { | ||||||
|         // not at max |         // not at max | ||||||
|         this.finishOpenDocument(doc, navigate) |         this.finishOpenDocument(doc) | ||||||
|       } |  | ||||||
|     } else { |  | ||||||
|       // doc is open, just maybe navigate |  | ||||||
|       if (navigate) { |  | ||||||
|         this.router.navigate(['documents', doc.id]) |  | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     return of(true) |     return of(true) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private finishOpenDocument(doc: PaperlessDocument, navigate: boolean) { |   private finishOpenDocument(doc: PaperlessDocument) { | ||||||
|     this.openDocuments.unshift(doc) |     this.openDocuments.unshift(doc) | ||||||
|     this.dirtyDocuments.delete(doc.id) |     this.dirtyDocuments.delete(doc.id) | ||||||
|     this.save() |     this.save() | ||||||
|     if (navigate) { |  | ||||||
|       this.router.navigate(['documents', doc.id]) |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   setDirty(doc: PaperlessDocument, dirty: boolean) { |   setDirty(doc: PaperlessDocument, dirty: boolean) { | ||||||
|   | |||||||
| @@ -174,10 +174,18 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument> | |||||||
|     ) |     ) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   bulkDownload(ids: number[], content = 'both') { |   bulkDownload( | ||||||
|  |     ids: number[], | ||||||
|  |     content = 'both', | ||||||
|  |     useFilenameFormatting: boolean = false | ||||||
|  |   ) { | ||||||
|     return this.http.post( |     return this.http.post( | ||||||
|       this.getResourceUrl(null, 'bulk_download'), |       this.getResourceUrl(null, 'bulk_download'), | ||||||
|       { documents: ids, content: content }, |       { | ||||||
|  |         documents: ids, | ||||||
|  |         content: content, | ||||||
|  |         follow_formatting: useFilenameFormatting, | ||||||
|  |       }, | ||||||
|       { responseType: 'blob' } |       { responseType: 'blob' } | ||||||
|     ) |     ) | ||||||
|   } |   } | ||||||
|   | |||||||
							
								
								
									
										51
									
								
								src-ui/src/app/services/rest/mail-account.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src-ui/src/app/services/rest/mail-account.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | |||||||
|  | import { HttpClient } from '@angular/common/http' | ||||||
|  | import { Injectable } from '@angular/core' | ||||||
|  | import { combineLatest, Observable } from 'rxjs' | ||||||
|  | import { tap } from 'rxjs/operators' | ||||||
|  | import { PaperlessMailAccount } from 'src/app/data/paperless-mail-account' | ||||||
|  | import { AbstractPaperlessService } from './abstract-paperless-service' | ||||||
|  |  | ||||||
|  | @Injectable({ | ||||||
|  |   providedIn: 'root', | ||||||
|  | }) | ||||||
|  | export class MailAccountService extends AbstractPaperlessService<PaperlessMailAccount> { | ||||||
|  |   loading: boolean | ||||||
|  |  | ||||||
|  |   constructor(http: HttpClient) { | ||||||
|  |     super(http, 'mail_accounts') | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   private reload() { | ||||||
|  |     this.loading = true | ||||||
|  |     this.listAll().subscribe((r) => { | ||||||
|  |       this.mailAccounts = r.results | ||||||
|  |       this.loading = false | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   private mailAccounts: PaperlessMailAccount[] = [] | ||||||
|  |  | ||||||
|  |   get allAccounts() { | ||||||
|  |     return this.mailAccounts | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   create(o: PaperlessMailAccount) { | ||||||
|  |     return super.create(o).pipe(tap(() => this.reload())) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   update(o: PaperlessMailAccount) { | ||||||
|  |     return super.update(o).pipe(tap(() => this.reload())) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   patchMany( | ||||||
|  |     objects: PaperlessMailAccount[] | ||||||
|  |   ): Observable<PaperlessMailAccount[]> { | ||||||
|  |     return combineLatest(objects.map((o) => super.patch(o))).pipe( | ||||||
|  |       tap(() => this.reload()) | ||||||
|  |     ) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   delete(o: PaperlessMailAccount) { | ||||||
|  |     return super.delete(o).pipe(tap(() => this.reload())) | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										49
									
								
								src-ui/src/app/services/rest/mail-rule.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src-ui/src/app/services/rest/mail-rule.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | |||||||
|  | import { HttpClient } from '@angular/common/http' | ||||||
|  | import { Injectable } from '@angular/core' | ||||||
|  | import { combineLatest, Observable } from 'rxjs' | ||||||
|  | import { tap } from 'rxjs/operators' | ||||||
|  | import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule' | ||||||
|  | import { AbstractPaperlessService } from './abstract-paperless-service' | ||||||
|  |  | ||||||
|  | @Injectable({ | ||||||
|  |   providedIn: 'root', | ||||||
|  | }) | ||||||
|  | export class MailRuleService extends AbstractPaperlessService<PaperlessMailRule> { | ||||||
|  |   loading: boolean | ||||||
|  |  | ||||||
|  |   constructor(http: HttpClient) { | ||||||
|  |     super(http, 'mail_rules') | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   private reload() { | ||||||
|  |     this.loading = true | ||||||
|  |     this.listAll().subscribe((r) => { | ||||||
|  |       this.mailRules = r.results | ||||||
|  |       this.loading = false | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   private mailRules: PaperlessMailRule[] = [] | ||||||
|  |  | ||||||
|  |   get allRules() { | ||||||
|  |     return this.mailRules | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   create(o: PaperlessMailRule) { | ||||||
|  |     return super.create(o).pipe(tap(() => this.reload())) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   update(o: PaperlessMailRule) { | ||||||
|  |     return super.update(o).pipe(tap(() => this.reload())) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   patchMany(objects: PaperlessMailRule[]): Observable<PaperlessMailRule[]> { | ||||||
|  |     return combineLatest(objects.map((o) => super.patch(o))).pipe( | ||||||
|  |       tap(() => this.reload()) | ||||||
|  |     ) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   delete(o: PaperlessMailRule) { | ||||||
|  |     return super.delete(o).pipe(tap(() => this.reload())) | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -5,7 +5,7 @@ export const environment = { | |||||||
|   apiBaseUrl: document.baseURI + 'api/', |   apiBaseUrl: document.baseURI + 'api/', | ||||||
|   apiVersion: '2', |   apiVersion: '2', | ||||||
|   appTitle: 'Paperless-ngx', |   appTitle: 'Paperless-ngx', | ||||||
|   version: '1.10.2', |   version: '1.10.2-dev', | ||||||
|   webSocketHost: window.location.host, |   webSocketHost: window.location.host, | ||||||
|   webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', |   webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', | ||||||
|   webSocketBaseUrl: base_url.pathname + 'ws/', |   webSocketBaseUrl: base_url.pathname + 'ws/', | ||||||
|   | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	 Michael Shamoon
					Michael Shamoon