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": { | ||||
|       "version": "11.1.1" | ||||
|       "version": "11.2.0" | ||||
|     }, | ||||
|   "jbig2enc": { | ||||
|       "version": "0.29", | ||||
|   | ||||
							
								
								
									
										12
									
								
								.github/release-drafter.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.github/release-drafter.yml
									
									
									
									
										vendored
									
									
								
							| @@ -4,6 +4,7 @@ autolabeler: | ||||
|       - '/^fix/' | ||||
|     title: | ||||
|       - "/^fix/i" | ||||
|       - "/^Bugfix/i" | ||||
|   - label: "enhancement" | ||||
|     branch: | ||||
|       - '/^feature/' | ||||
| @@ -13,6 +14,9 @@ categories: | ||||
|   - title: 'Breaking Changes' | ||||
|     labels: | ||||
|       - 'breaking-change' | ||||
|   - title: 'Notable Changes' | ||||
|     labels: | ||||
|       - 'notable' | ||||
|   - title: 'Features' | ||||
|     labels: | ||||
|       - 'enhancement' | ||||
| @@ -20,7 +24,8 @@ categories: | ||||
|     labels: | ||||
|       - 'bug' | ||||
|   - title: 'Documentation' | ||||
|     label: 'documentation' | ||||
|     labels: | ||||
|       - 'documentation' | ||||
|   - title: 'Maintenance' | ||||
|     labels: | ||||
|       - 'chore' | ||||
| @@ -29,7 +34,8 @@ categories: | ||||
|       - 'ci-cd' | ||||
|   - title: 'Dependencies' | ||||
|     collapse-after: 3 | ||||
|     label: 'dependencies' | ||||
|     labels: | ||||
|       - 'dependencies' | ||||
|   - title: 'All App Changes' | ||||
|     labels: | ||||
|       - 'frontend' | ||||
| @@ -46,6 +52,8 @@ include-labels: | ||||
|   - 'frontend' | ||||
|   - 'backend' | ||||
|   - 'ci-cd' | ||||
|   - 'breaking-change' | ||||
|   - 'notable' | ||||
| category-template: '### $TITLE' | ||||
| change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))' | ||||
| change-title-escapes: '\<*_&#@' | ||||
|   | ||||
							
								
								
									
										29
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										29
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -16,7 +16,7 @@ on: | ||||
| jobs: | ||||
|   pre-commit: | ||||
|     name: Linting Checks | ||||
|     runs-on: ubuntu-latest | ||||
|     runs-on: ubuntu-22.04 | ||||
|     steps: | ||||
|       - | ||||
|         name: Checkout repository | ||||
| @@ -34,7 +34,7 @@ jobs: | ||||
|  | ||||
|   documentation: | ||||
|     name: "Build Documentation" | ||||
|     runs-on: ubuntu-20.04 | ||||
|     runs-on: ubuntu-22.04 | ||||
|     needs: | ||||
|       - pre-commit | ||||
|     steps: | ||||
| @@ -44,7 +44,7 @@ jobs: | ||||
|       - | ||||
|         name: Install pipenv | ||||
|         run: | | ||||
|           pipx install pipenv==2022.10.12 | ||||
|           pipx install pipenv==2022.11.30 | ||||
|       - | ||||
|         name: Set up Python | ||||
|         uses: actions/setup-python@v4 | ||||
| @@ -73,7 +73,7 @@ jobs: | ||||
|  | ||||
|   documentation-deploy: | ||||
|     name: "Deploy Documentation" | ||||
|     runs-on: ubuntu-20.04 | ||||
|     runs-on: ubuntu-22.04 | ||||
|     if: github.event_name == 'push' && github.ref == 'refs/heads/main' | ||||
|     needs: | ||||
|       - documentation | ||||
| @@ -92,7 +92,7 @@ jobs: | ||||
|  | ||||
|   tests-backend: | ||||
|     name: "Tests (${{ matrix.python-version }})" | ||||
|     runs-on: ubuntu-20.04 | ||||
|     runs-on: ubuntu-22.04 | ||||
|     needs: | ||||
|       - pre-commit | ||||
|     strategy: | ||||
| @@ -106,6 +106,10 @@ jobs: | ||||
|       PAPERLESS_MAIL_TEST_HOST: ${{ secrets.TEST_MAIL_HOST }} | ||||
|       PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }} | ||||
|       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: | ||||
|       - | ||||
|         name: Checkout | ||||
| @@ -120,7 +124,7 @@ jobs: | ||||
|       - | ||||
|         name: Install pipenv | ||||
|         run: | | ||||
|           pipx install pipenv==2022.10.12 | ||||
|           pipx install pipenv==2022.11.30 | ||||
|       - | ||||
|         name: Set up Python | ||||
|         uses: actions/setup-python@v4 | ||||
| @@ -177,7 +181,7 @@ jobs: | ||||
|  | ||||
|   tests-frontend: | ||||
|     name: "Tests Frontend" | ||||
|     runs-on: ubuntu-20.04 | ||||
|     runs-on: ubuntu-22.04 | ||||
|     needs: | ||||
|       - pre-commit | ||||
|     strategy: | ||||
| @@ -191,13 +195,14 @@ jobs: | ||||
|         with: | ||||
|           node-version: ${{ matrix.node-version }} | ||||
|       - 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 e2e:ci | ||||
|  | ||||
|   prepare-docker-build: | ||||
|     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')) | ||||
|     runs-on: ubuntu-20.04 | ||||
|     runs-on: ubuntu-22.04 | ||||
|     # If the push triggered the installer library workflow, wait for it to | ||||
|     # 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 | ||||
| @@ -274,7 +279,7 @@ jobs: | ||||
|  | ||||
|   # build and push image to docker hub. | ||||
|   build-docker-image: | ||||
|     runs-on: ubuntu-20.04 | ||||
|     runs-on: ubuntu-22.04 | ||||
|     concurrency: | ||||
|       group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }} | ||||
|       cancel-in-progress: true | ||||
| @@ -379,7 +384,7 @@ jobs: | ||||
|   build-release: | ||||
|     needs: | ||||
|       - build-docker-image | ||||
|     runs-on: ubuntu-20.04 | ||||
|     runs-on: ubuntu-22.04 | ||||
|     steps: | ||||
|       - | ||||
|         name: Checkout | ||||
| @@ -458,7 +463,7 @@ jobs: | ||||
|           path: dist/paperless-ngx.tar.xz | ||||
|  | ||||
|   publish-release: | ||||
|     runs-on: ubuntu-20.04 | ||||
|     runs-on: ubuntu-22.04 | ||||
|     outputs: | ||||
|       prerelease: ${{ steps.get_version.outputs.prerelease }} | ||||
|       changelog: ${{ steps.create-release.outputs.body }} | ||||
| @@ -507,7 +512,7 @@ jobs: | ||||
|           asset_content_type: application/x-xz | ||||
|  | ||||
|   append-changelog: | ||||
|     runs-on: ubuntu-20.04 | ||||
|     runs-on: ubuntu-22.04 | ||||
|     needs: | ||||
|       - publish-release | ||||
|     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 | ||||
| # delete container images from the GHCR which no longer have an associated | ||||
| # 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 | ||||
|  | ||||
| on: | ||||
|   schedule: | ||||
|     - cron: '0 0 * * SAT' | ||||
|   delete: | ||||
|   pull_request: | ||||
|     types: | ||||
|       - closed | ||||
|   push: | ||||
|     paths: | ||||
|       - ".github/workflows/cleanup-tags.yml" | ||||
| @@ -26,7 +23,8 @@ concurrency: | ||||
| jobs: | ||||
|   cleanup-images: | ||||
|     name: Cleanup Image Tags for ${{ matrix.primary-name }} | ||||
|     runs-on: ubuntu-latest | ||||
|     if: github.repository_owner == 'paperless-ngx' | ||||
|     runs-on: ubuntu-22.04 | ||||
|     strategy: | ||||
|       matrix: | ||||
|         include: | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							| @@ -23,7 +23,7 @@ on: | ||||
| jobs: | ||||
|   analyze: | ||||
|     name: Analyze | ||||
|     runs-on: ubuntu-latest | ||||
|     runs-on: ubuntu-22.04 | ||||
|     permissions: | ||||
|       actions: read | ||||
|       contents: read | ||||
|   | ||||
							
								
								
									
										3
									
								
								.github/workflows/installer-library.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/workflows/installer-library.yml
									
									
									
									
										vendored
									
									
								
							| @@ -34,7 +34,7 @@ concurrency: | ||||
| jobs: | ||||
|   prepare-docker-build: | ||||
|     name: Prepare Docker Image Version Data | ||||
|     runs-on: ubuntu-20.04 | ||||
|     runs-on: ubuntu-22.04 | ||||
|     steps: | ||||
|       - | ||||
|         name: Set ghcr repository name | ||||
| @@ -127,6 +127,7 @@ jobs: | ||||
|     uses: ./.github/workflows/reusable-workflow-builder.yml | ||||
|     with: | ||||
|       dockerfile: ./docker-builders/Dockerfile.qpdf | ||||
|       build-platforms: linux/amd64 | ||||
|       build-json: ${{ needs.prepare-docker-build.outputs.qpdf-json }} | ||||
|       build-args: | | ||||
|         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: | ||||
|   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') | ||||
|     steps: | ||||
|       - name: Add issue to project and set status to ${{ env.todo }} | ||||
| @@ -37,7 +37,7 @@ jobs: | ||||
|           status_value: ${{ env.todo }} # Target status | ||||
|   pr_opened_or_reopened: | ||||
|     name: pr_opened_or_reopened | ||||
|     runs-on: ubuntu-latest | ||||
|     runs-on: ubuntu-22.04 | ||||
|     permissions: | ||||
|       # write permission is required for autolabeler | ||||
|       pull-requests: write | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/release-chart.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/release-chart.yml
									
									
									
									
										vendored
									
									
								
							| @@ -9,7 +9,7 @@ on: | ||||
| jobs: | ||||
|   release_chart: | ||||
|     name: "Release Chart" | ||||
|     runs-on: ubuntu-latest | ||||
|     runs-on: ubuntu-22.04 | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v3 | ||||
|   | ||||
| @@ -13,6 +13,10 @@ on: | ||||
|         required: false | ||||
|         default: "" | ||||
|         type: string | ||||
|       build-platforms: | ||||
|         required: false | ||||
|         default: linux/amd64,linux/arm64,linux/arm/v7 | ||||
|         type: string | ||||
|  | ||||
| concurrency: | ||||
|   group: ${{ github.workflow }}-${{ fromJSON(inputs.build-json).name }}-${{ fromJSON(inputs.build-json).version }} | ||||
| @@ -21,7 +25,7 @@ concurrency: | ||||
| jobs: | ||||
|   build-image: | ||||
|     name: Build ${{ fromJSON(inputs.build-json).name }} @ ${{ fromJSON(inputs.build-json).version }} | ||||
|     runs-on: ubuntu-latest | ||||
|     runs-on: ubuntu-22.04 | ||||
|     steps: | ||||
|       - | ||||
|         name: Checkout | ||||
| @@ -46,7 +50,7 @@ jobs: | ||||
|           context: . | ||||
|           file: ${{ inputs.dockerfile }} | ||||
|           tags: ${{ fromJSON(inputs.build-json).image_tag }} | ||||
|           platforms: linux/amd64,linux/arm64,linux/arm/v7 | ||||
|           platforms: ${{ inputs.build-platforms }} | ||||
|           build-args: ${{ inputs.build-args }} | ||||
|           push: true | ||||
|           cache-from: type=registry,ref=${{ fromJSON(inputs.build-json).cache_tag }} | ||||
|   | ||||
| @@ -5,7 +5,7 @@ | ||||
| repos: | ||||
|   # General hooks | ||||
|   - repo: https://github.com/pre-commit/pre-commit-hooks | ||||
|     rev: v4.3.0 | ||||
|     rev: v4.4.0 | ||||
|     hooks: | ||||
|       - id: check-docstring-first | ||||
|       - id: check-json | ||||
| @@ -48,23 +48,23 @@ repos: | ||||
|       - id: yesqa | ||||
|         exclude: "(migrations)" | ||||
|   - repo: https://github.com/asottile/add-trailing-comma | ||||
|     rev: "v2.3.0" | ||||
|     rev: "v2.4.0" | ||||
|     hooks: | ||||
|       - id: add-trailing-comma | ||||
|         exclude: "(migrations)" | ||||
|   - repo: https://github.com/PyCQA/flake8 | ||||
|     rev: 5.0.4 | ||||
|     rev: 6.0.0 | ||||
|     hooks: | ||||
|       - id: flake8 | ||||
|         files: ^src/ | ||||
|         args: | ||||
|           - "--config=./src/setup.cfg" | ||||
|   - repo: https://github.com/psf/black | ||||
|     rev: 22.10.0 | ||||
|     rev: 22.12.0 | ||||
|     hooks: | ||||
|       - id: black | ||||
|   - repo: https://github.com/asottile/pyupgrade | ||||
|     rev: v3.2.2 | ||||
|     rev: v3.3.1 | ||||
|     hooks: | ||||
|       - id: pyupgrade | ||||
|         exclude: "(migrations)" | ||||
| @@ -83,6 +83,6 @@ repos: | ||||
|         args: | ||||
|           - "--tab" | ||||
|   - repo: https://github.com/shellcheck-py/shellcheck-py | ||||
|     rev: "v0.8.0.4" | ||||
|     rev: "v0.9.0.2" | ||||
|     hooks: | ||||
|       - id: shellcheck | ||||
|   | ||||
							
								
								
									
										22
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										22
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -45,7 +45,7 @@ COPY Pipfile* ./ | ||||
|  | ||||
| RUN set -eux \ | ||||
|   && 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" \ | ||||
|     && 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" | ||||
|  | ||||
| ARG DEBIAN_FRONTEND=noninteractive | ||||
| # Buildx provided | ||||
| ARG TARGETARCH | ||||
| ARG TARGETVARIANT | ||||
|  | ||||
| # Workflow provided | ||||
| ARG QPDF_VERSION | ||||
|  | ||||
| # | ||||
| # 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 \ | ||||
|   set -eux \ | ||||
|   && 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_*.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_VERSION}/${TARGETARCH}${TARGETVARIANT}/qpdf_*.deb \ | ||||
|   && 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/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 install --no-cache-dir /pikepdf/usr/src/wheels/*.whl \ | ||||
|     && python3 -m pip list \ | ||||
|   && echo "Installing psycopg2" \ | ||||
|     && 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 \ | ||||
|   && echo "Installing Python requirements" \ | ||||
|     && 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" \ | ||||
|     && apt-get -y purge ${BUILD_PACKAGES} \ | ||||
|     && apt-get -y autoremove --purge \ | ||||
|   | ||||
							
								
								
									
										21
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								Pipfile
									
									
									
									
									
								
							| @@ -30,8 +30,6 @@ psycopg2 = "*" | ||||
| rapidfuzz = "*" | ||||
| redis = {extras = ["hiredis"], version = "*"} | ||||
| scikit-learn = "~=1.1" | ||||
| # Pin this until piwheels is building 1.9 (see https://www.piwheels.org/project/scipy/) | ||||
| scipy = "==1.8.1" | ||||
| numpy = "*" | ||||
| whitenoise = "~=6.2" | ||||
| watchdog = "~=2.1" | ||||
| @@ -43,9 +41,6 @@ tika = "*" | ||||
| # TODO: This will sadly also install daphne+dependencies, | ||||
| #  which an ASGI server we don't need. Adds about 15MB image size. | ||||
| 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 = "*"} | ||||
| concurrent-log-handler = "*" | ||||
| "pdfminer.six" = "*" | ||||
| @@ -60,6 +55,21 @@ setproctitle = "*" | ||||
| nltk = "*" | ||||
| pdf2image = "*" | ||||
| 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] | ||||
| coveralls = "*" | ||||
| @@ -76,4 +86,5 @@ black = "*" | ||||
| pre-commit = "*" | ||||
| sphinx-autobuild = "*" | ||||
| myst-parser = "*" | ||||
| imagehash = "*" | ||||
| 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: | ||||
| #	./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" | ||||
| 	exit 1 | ||||
| elif [ ! -f "$1" ]; then | ||||
| @@ -20,28 +20,62 @@ elif [ ! -f "$1" ]; then | ||||
| 	exit 1 | ||||
| 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) | ||||
| branch_name=$(git rev-parse --abbrev-ref HEAD) | ||||
|  | ||||
| # https://docs.docker.com/develop/develop-images/build_enhancements/ | ||||
| # Required to use cache-from | ||||
| export DOCKER_BUILDKIT=1 | ||||
| # Parse eithe Pipfile.lock or the .build-config.json | ||||
| jbig2enc_version=$(jq ".jbig2enc.version" .build-config.json | sed 's/"//g') | ||||
| 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 \ | ||||
| 	--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 \ | ||||
| 	--build-arg JBIG2ENC_VERSION="${jbig2enc_version}" \ | ||||
| 	--build-arg QPDF_VERSION="${qpdf_version}" \ | ||||
| 	--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}" . | ||||
| 	--output=type=docker \ | ||||
| 	"${cache_from_arr[@]}" \ | ||||
| 	"${build_args_arr[@]}" \ | ||||
| 	"${@: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" | ||||
|  | ||||
| # Buildx provided | ||||
| ARG TARGETARCH | ||||
| ARG TARGETVARIANT | ||||
|  | ||||
| ARG DEBIAN_FRONTEND=noninteractive | ||||
| # Workflow provided | ||||
| ARG QPDF_VERSION | ||||
| ARG PIKEPDF_VERSION | ||||
| # These are not used, but will still bust the cache if one changes | ||||
| # Otherwise, the main image will try to build thing (and fail) | ||||
| @@ -54,7 +60,7 @@ ARG BUILD_PACKAGES="\ | ||||
|  | ||||
| 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 | ||||
| # the added size of the install is basically irrelevant | ||||
| @@ -77,6 +83,8 @@ RUN set -eux \ | ||||
|     && python3 -m pip wheel \ | ||||
|       # Build the package at the required 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 | ||||
|       --wheel-dir wheels \ | ||||
|       # Do not use a binary packge for the package being built | ||||
| @@ -86,6 +94,8 @@ RUN set -eux \ | ||||
|       # Don't cache build files | ||||
|       --no-cache-dir \ | ||||
|     && ls -ahl wheels \ | ||||
|   && echo "Gathering package data" \ | ||||
|     && dpkg-query -f '${Package;-40}${Version}\n' -W > ./wheels/pkg-list.txt \ | ||||
|   && echo "Cleaning up image" \ | ||||
|     && apt-get -y purge ${BUILD_PACKAGES} \ | ||||
|     && apt-get -y autoremove --purge \ | ||||
|   | ||||
| @@ -42,6 +42,8 @@ RUN set -eux \ | ||||
|       # Don't cache build files | ||||
|       --no-cache-dir \ | ||||
|     && ls -ahl wheels/ \ | ||||
|   && echo "Gathering package data" \ | ||||
|     && dpkg-query -f '${Package;-40}${Version}\n' -W > ./wheels/pkg-list.txt \ | ||||
|   && echo "Cleaning up image" \ | ||||
|     && apt-get -y purge ${BUILD_PACKAGES} \ | ||||
|     && apt-get -y autoremove --purge \ | ||||
|   | ||||
| @@ -1,48 +1,156 @@ | ||||
| # This Dockerfile compiles the jbig2enc library | ||||
| # Inputs: | ||||
| #    - QPDF_VERSION - the version of qpdf to build a .deb. | ||||
| #                     Must be present as a deb-src in bookworm | ||||
| # | ||||
| # Stage: pre-build | ||||
| # Purpose: | ||||
| #  - 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 BUILD_PACKAGES="\ | ||||
|   build-essential \ | ||||
| ARG COMMON_BUILD_PACKAGES="\ | ||||
|   cmake \ | ||||
|   debhelper\ | ||||
|   debian-keyring \ | ||||
|   devscripts \ | ||||
|   dpkg-dev \ | ||||
|   equivs \ | ||||
|   libtool \ | ||||
|   # https://qpdf.readthedocs.io/en/stable/installation.html#system-requirements | ||||
|   libjpeg62-turbo-dev \ | ||||
|   libgnutls28-dev \ | ||||
|   packaging-dev \ | ||||
|   cmake \ | ||||
|   zlib1g-dev" | ||||
|   libtool" | ||||
|  | ||||
| ENV DEB_BUILD_OPTIONS="terse nocheck nodoc parallel=2" | ||||
|  | ||||
| WORKDIR /usr/src | ||||
|  | ||||
| RUN set -eux \ | ||||
|   && echo "Installing build tools" \ | ||||
|   && echo "Installing common packages" \ | ||||
|     && apt-get update --quiet \ | ||||
|     && apt-get install --yes --quiet --no-install-recommends $BUILD_PACKAGES \ | ||||
|   && echo "Getting qpdf src" \ | ||||
|     && apt-get install --yes --quiet --no-install-recommends ${COMMON_BUILD_PACKAGES} \ | ||||
|   && echo "Getting qpdf source" \ | ||||
|     && echo "deb-src http://deb.debian.org/debian/ bookworm main" > /etc/apt/sources.list.d/bookworm-src.list \ | ||||
|     && apt-get update \ | ||||
|     && mkdir qpdf \ | ||||
|     && cd qpdf \ | ||||
|     && apt-get source --yes --quiet qpdf=${QPDF_VERSION}-1/bookworm \ | ||||
|   && echo "Building qpdf" \ | ||||
|     && cd qpdf-$QPDF_VERSION \ | ||||
|     && export DEB_BUILD_OPTIONS="terse nocheck nodoc parallel=2" \ | ||||
|     && apt-get update --quiet \ | ||||
|     && apt-get source --yes --quiet qpdf=${QPDF_VERSION}-1/bookworm | ||||
|  | ||||
| # | ||||
| # Stage: amd64-builder | ||||
| # Purpose: Builds qpdf for x86_64 (native build) | ||||
| # | ||||
| FROM pre-build as amd64-builder | ||||
|  | ||||
| ARG AMD64_BUILD_PACKAGES="\ | ||||
|   build-essential \ | ||||
|   libjpeg62-turbo-dev:amd64 \ | ||||
|   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 \ | ||||
|     && ls -ahl ../*.deb \ | ||||
|   && echo "Cleaning up image" \ | ||||
|     && apt-get -y purge ${BUILD_PACKAGES} \ | ||||
|     && apt-get -y autoremove --purge \ | ||||
|     && rm -rf /var/lib/apt/lists/* | ||||
|     && 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 | ||||
|     network_mode: host | ||||
|     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: | ||||
|       - "gotenberg" | ||||
|       - "--chromium-disable-routes=true" | ||||
|       - "--chromium-disable-javascript=true" | ||||
|       - "--chromium-allow-list=file:///tmp/.*" | ||||
|   tika: | ||||
|     image: ghcr.io/paperless-ngx/tika:latest | ||||
|     hostname: tika | ||||
|   | ||||
| @@ -85,9 +85,12 @@ services: | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:7.6 | ||||
|     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: | ||||
|       - "gotenberg" | ||||
|       - "--chromium-disable-routes=true" | ||||
|       - "--chromium-disable-javascript=true" | ||||
|       - "--chromium-allow-list=file:///tmp/.*" | ||||
|  | ||||
|   tika: | ||||
|     image: ghcr.io/paperless-ngx/tika:latest | ||||
|   | ||||
| @@ -79,9 +79,13 @@ services: | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:7.6 | ||||
|     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: | ||||
|       - "gotenberg" | ||||
|       - "--chromium-disable-routes=true" | ||||
|       - "--chromium-disable-javascript=true" | ||||
|       - "--chromium-allow-list=file:///tmp/.*" | ||||
|  | ||||
|   tika: | ||||
|     image: ghcr.io/paperless-ngx/tika:latest | ||||
|   | ||||
| @@ -67,9 +67,13 @@ services: | ||||
|   gotenberg: | ||||
|     image: docker.io/gotenberg/gotenberg:7.6 | ||||
|     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: | ||||
|       - "gotenberg" | ||||
|       - "--chromium-disable-routes=true" | ||||
|       - "--chromium-disable-javascript=true" | ||||
|       - "--chromium-allow-list=file:///tmp/.*" | ||||
|  | ||||
|   tika: | ||||
|     image: ghcr.io/paperless-ngx/tika:latest | ||||
|   | ||||
| @@ -53,30 +53,6 @@ map_folders() { | ||||
| 	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() { | ||||
| 	# Mostly borrowed from the LinuxServer.io base image | ||||
| 	# https://github.com/linuxserver/docker-baseimage-ubuntu/tree/bionic/root/etc/cont-init.d | ||||
| @@ -157,8 +133,6 @@ initialize() { | ||||
| 	echo "Creating directory ${tmp_dir}" | ||||
| 	mkdir -p "${tmp_dir}" | ||||
|  | ||||
| 	nltk_data | ||||
|  | ||||
| 	set +e | ||||
| 	echo "Adjusting permissions of paperless files. This may take a while." | ||||
| 	chown -R paperless:paperless ${tmp_dir} | ||||
| @@ -191,10 +165,6 @@ install_languages() { | ||||
|  | ||||
| 	for lang in "${langs[@]}"; do | ||||
| 		pkg="tesseract-ocr-$lang" | ||||
| 		# English is installed by default | ||||
| 		#if [[ "$lang" ==  "eng" ]]; then | ||||
| 		#    continue | ||||
| 		#fi | ||||
|  | ||||
| 		if dpkg -s "$pkg" &>/dev/null; then | ||||
| 			echo "Package $pkg already installed!" | ||||
|   | ||||
| @@ -20,7 +20,6 @@ wait_for_postgres() { | ||||
| 			exit 1 | ||||
| 		else | ||||
| 			echo "Attempt $attempt_num failed! Trying again in 5 seconds..." | ||||
|  | ||||
| 		fi | ||||
|  | ||||
| 		attempt_num=$(("$attempt_num" + 1)) | ||||
| @@ -37,6 +36,8 @@ wait_for_mariadb() { | ||||
| 	local attempt_num=1 | ||||
| 	local -r max_attempts=5 | ||||
|  | ||||
| 	# Disable warning, host and port can't have spaces | ||||
| 	# shellcheck disable=SC2086 | ||||
| 	while ! true > /dev/tcp/$host/$port; do | ||||
|  | ||||
| 		if [ $attempt_num -eq $max_attempts ]; then | ||||
| @@ -67,10 +68,16 @@ migrations() { | ||||
| 		# of the current container starts. | ||||
| 		flock 200 | ||||
| 		echo "Apply database migrations..." | ||||
| 		python3 manage.py migrate | ||||
| 		python3 manage.py migrate --skip-checks --no-input | ||||
| 	) 200>"${DATA_DIR}/migration_lock" | ||||
| } | ||||
|  | ||||
| django_checks() { | ||||
| 	# Explicitly run the Django system checks | ||||
| 	echo "Running Django checks" | ||||
| 	python3 manage.py check | ||||
| } | ||||
|  | ||||
| search_index() { | ||||
|  | ||||
| 	local -r index_version=1 | ||||
| @@ -100,6 +107,8 @@ do_work() { | ||||
|  | ||||
| 	migrations | ||||
|  | ||||
| 	django_checks | ||||
|  | ||||
| 	search_index | ||||
|  | ||||
| 	superuser | ||||
|   | ||||
| @@ -233,6 +233,7 @@ optional arguments: | ||||
| -c, --compare-checksums | ||||
| -f, --use-filename-format | ||||
| -d, --delete | ||||
| -z  --zip | ||||
| ``` | ||||
|  | ||||
| `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 | ||||
| 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 | ||||
| `[date created] [correspondent] [title].[extension]`. If you want | ||||
| 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 | ||||
|   for the configuration file in these locations and use the first one | ||||
|   it finds: | ||||
|  | ||||
|   ``` | ||||
|   /path/to/paperless/paperless.conf | ||||
|   /etc/paperless.conf | ||||
|   /usr/local/etc/paperless.conf | ||||
|   ``` | ||||
|   - The environment variable `PAPERLESS_CONFIGURATION_PATH` | ||||
|   - `/path/to/paperless/paperless.conf` | ||||
|   - `/etc/paperless.conf` | ||||
|   - `/usr/local/etc/paperless.conf` | ||||
|  | ||||
| ## Required services | ||||
|  | ||||
| @@ -170,6 +168,19 @@ details. | ||||
|  | ||||
|     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 | ||||
|  | ||||
| `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 | ||||
| [Gotenberg](https://gotenberg.dev/) for parsing and converting | ||||
| "Office" documents (such as ".doc", ".xlsx" and ".odt"). If you | ||||
| wish to use this, you must provide a Tika server and a Gotenberg server, | ||||
| "Office" documents (such as ".doc", ".xlsx" and ".odt"). | ||||
| 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. | ||||
|  | ||||
| `PAPERLESS_TIKA_ENABLED=<bool>` | ||||
| @@ -609,9 +622,12 @@ services: | ||||
|     gotenberg: | ||||
|       image: gotenberg/gotenberg:7.6 | ||||
|       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: | ||||
|         - 'gotenberg' | ||||
|       - '--chromium-disable-routes=true' | ||||
|         - '--chromium-disable-javascript=true' | ||||
|         - '--chromium-allow-list=file:///tmp/.*' | ||||
|  | ||||
|   tika: | ||||
|     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: | ||||
|  | ||||
|     | CPU core count | Workers | Threads | | ||||
|     |----------------|---------|---------| | ||||
|     | -------------- | ------- | ------- | | ||||
|     | > 1            | > 1     | > 1     | | ||||
|     | > 2            | > 2     | > 1     | | ||||
|     | > 4            | > 2     | > 2     | | ||||
| @@ -691,6 +707,16 @@ for details on how to set it. | ||||
|  | ||||
|     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} | ||||
|  | ||||
| `PAPERLESS_CONSUMER_POLLING=<num>` | ||||
|   | ||||
| @@ -125,12 +125,12 @@ using docker-compose, this is achieved by the following configuration | ||||
| change in the `docker-compose.yml` file: | ||||
|  | ||||
| ```yaml | ||||
| gotenberg: | ||||
|   image: gotenberg/gotenberg:7.6 | ||||
|   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: | ||||
|   - 'gotenberg' | ||||
|     - '--chromium-disable-routes=true' | ||||
|   - '--chromium-disable-javascript=true' | ||||
|   - '--chromium-allow-list=file:///tmp/.*' | ||||
|   - '--api-timeout=60' | ||||
| ``` | ||||
|  | ||||
|   | ||||
| @@ -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 -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 | ||||
|   | ||||
							
								
								
									
										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": {} | ||||
|     } | ||||
|   ] | ||||
| } | ||||
| @@ -53,7 +53,8 @@ | ||||
|               "src/favicon.ico", | ||||
|               "src/apple-touch-icon.png", | ||||
|               "src/assets", | ||||
| 							"src/manifest.webmanifest", { | ||||
|               "src/manifest.webmanifest", | ||||
|               { | ||||
|                 "glob": "pdf.worker.min.js", | ||||
|                 "input": "node_modules/pdfjs-dist/build/", | ||||
|                 "output": "/assets/js/" | ||||
| @@ -103,7 +104,9 @@ | ||||
|               ] | ||||
|             }, | ||||
|             "en-US": { | ||||
| 							"localize": ["en-US"] | ||||
|               "localize": [ | ||||
|                 "en-US" | ||||
|               ] | ||||
|             } | ||||
|           }, | ||||
|           "defaultConfiguration": "" | ||||
| @@ -171,9 +174,23 @@ | ||||
|             "watch": true, | ||||
|             "headless": false | ||||
|           } | ||||
|         }, | ||||
|         "lint": { | ||||
|           "builder": "@angular-eslint/builder:lint", | ||||
|           "options": { | ||||
|             "lintFilePatterns": [ | ||||
|               "src/**/*.ts", | ||||
|               "src/**/*.html" | ||||
|             ] | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
| 	"defaultProject": "paperless-ui" | ||||
|   "defaultProject": "paperless-ui", | ||||
|   "cli": { | ||||
|     "schematicCollections": [ | ||||
|       "@angular-eslint/schematics" | ||||
|     ] | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -35,6 +35,16 @@ describe('settings', () => { | ||||
|             req.reply(response) | ||||
|           } | ||||
|         ).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) => { | ||||
| @@ -48,7 +58,6 @@ describe('settings', () => { | ||||
|  | ||||
|     cy.viewport(1024, 1600) | ||||
|     cy.visit('/settings') | ||||
|     cy.wait('@savedViews') | ||||
|   }) | ||||
|  | ||||
|   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('You have unsaved changes') | ||||
|     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('You have unsaved changes').should('not.exist') | ||||
|   }) | ||||
| @@ -77,16 +86,16 @@ describe('settings', () => { | ||||
|   }) | ||||
|  | ||||
|   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.contains('button', 'Save').click().wait('@savedViews') | ||||
|     cy.contains('button', 'Save').click().wait('@savedViews').wait(2000) | ||||
|     cy.contains('li', 'Inbox').should('not.exist') | ||||
|   }) | ||||
|  | ||||
|   it('should remove saved view from dashboard when unset', () => { | ||||
|     cy.contains('a', 'Saved views').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.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": { | ||||
|     "@angular-builders/jest": "14.1.0", | ||||
|     "@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/compiler-cli": "~14.2.8", | ||||
|     "@types/jest": "28.1.6", | ||||
|     "@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", | ||||
|     "eslint": "^8.28.0", | ||||
|     "jest": "28.1.3", | ||||
|     "jest-environment-jsdom": "^29.2.2", | ||||
|     "jest-preset-angular": "^12.2.3", | ||||
|     "ts-node": "~10.9.1", | ||||
|     "tslint": "~6.1.3", | ||||
|     "typescript": "~4.8.4", | ||||
|     "wait-on": "~6.0.1" | ||||
|   }, | ||||
|   | ||||
| @@ -47,6 +47,11 @@ const routes: Routes = [ | ||||
|         component: SettingsComponent, | ||||
|         canDeactivate: [DirtyFormGuard], | ||||
|       }, | ||||
|       { | ||||
|         path: 'settings/:section', | ||||
|         component: SettingsComponent, | ||||
|         canDeactivate: [DirtyFormGuard], | ||||
|       }, | ||||
|       { path: 'tasks', component: TasksComponent }, | ||||
|     ], | ||||
|   }, | ||||
|   | ||||
| @@ -191,21 +191,13 @@ export class AppComponent implements OnInit, OnDestroy { | ||||
|       }, | ||||
|       { | ||||
|         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', | ||||
|         enableBackdrop: true, | ||||
|         prevBtnTitle, | ||||
|         nextBtnTitle, | ||||
|         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', | ||||
|         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 { DocumentTypeEditDialogComponent } from './components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.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 { AppFrameComponent } from './components/app-frame/app-frame.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 { SelectComponent } from './components/common/input/select/select.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 { TagsComponent } from './components/common/input/tags/tags.component' | ||||
| import { SortableDirective } from './directives/sortable.directive' | ||||
| @@ -76,6 +77,8 @@ import { StoragePathEditDialogComponent } from './components/common/edit-dialog/ | ||||
| import { SettingsService } from './services/settings.service' | ||||
| import { TasksComponent } from './components/manage/tasks/tasks.component' | ||||
| 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 localeCs from '@angular/common/locales/cs' | ||||
| @@ -143,7 +146,7 @@ function initializeApp(settings: SettingsService) { | ||||
|     DocumentTypeEditDialogComponent, | ||||
|     StoragePathEditDialogComponent, | ||||
|     TagComponent, | ||||
|     ClearableBadge, | ||||
|     ClearableBadgeComponent, | ||||
|     PageHeaderComponent, | ||||
|     AppFrameComponent, | ||||
|     ToastsComponent, | ||||
| @@ -157,6 +160,7 @@ function initializeApp(settings: SettingsService) { | ||||
|     TextComponent, | ||||
|     SelectComponent, | ||||
|     CheckComponent, | ||||
|     PasswordComponent, | ||||
|     SaveViewConfigDialogComponent, | ||||
|     TagsComponent, | ||||
|     SortableDirective, | ||||
| @@ -180,6 +184,8 @@ function initializeApp(settings: SettingsService) { | ||||
|     DocumentAsnComponent, | ||||
|     DocumentCommentsComponent, | ||||
|     TasksComponent, | ||||
|     MailAccountEditDialogComponent, | ||||
|     MailRuleEditDialogComponent, | ||||
|   ], | ||||
|   imports: [ | ||||
|     BrowserModule, | ||||
|   | ||||
| @@ -174,13 +174,6 @@ | ||||
|               </svg><span> <ng-container i18n>Settings</ng-container></span> | ||||
|             </a> | ||||
|           </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> | ||||
|  | ||||
|         <h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted"> | ||||
|   | ||||
| @@ -220,6 +220,12 @@ main { | ||||
|   font-size: 1rem; | ||||
| } | ||||
|  | ||||
| @media screen and (min-width: 768px) { | ||||
|   .navbar-brand.slim { | ||||
|     max-width: 50px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .dropdown.show .dropdown-toggle, | ||||
| .dropdown-toggle:hover { | ||||
|   opacity: 0.7; | ||||
|   | ||||
| @@ -5,7 +5,7 @@ import { Component, Input, Output, EventEmitter } from '@angular/core' | ||||
|   templateUrl: './clearable-badge.component.html', | ||||
|   styleUrls: ['./clearable-badge.component.scss'], | ||||
| }) | ||||
| export class ClearableBadge { | ||||
| export class ClearableBadgeComponent { | ||||
|   constructor() {} | ||||
|  | ||||
|   @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 { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' | ||||
| import { CorrespondentService } from 'src/app/services/rest/correspondent.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-correspondent-edit-dialog', | ||||
| @@ -13,12 +12,8 @@ import { ToastService } from 'src/app/services/toast.service' | ||||
|   styleUrls: ['./correspondent-edit-dialog.component.scss'], | ||||
| }) | ||||
| export class CorrespondentEditDialogComponent extends EditDialogComponent<PaperlessCorrespondent> { | ||||
|   constructor( | ||||
|     service: CorrespondentService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     toastService: ToastService | ||||
|   ) { | ||||
|     super(service, activeModal, toastService) | ||||
|   constructor(service: CorrespondentService, activeModal: NgbActiveModal) { | ||||
|     super(service, activeModal) | ||||
|   } | ||||
|  | ||||
|   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 { PaperlessDocumentType } from 'src/app/data/paperless-document-type' | ||||
| import { DocumentTypeService } from 'src/app/services/rest/document-type.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
|  | ||||
| @Component({ | ||||
|   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'], | ||||
| }) | ||||
| export class DocumentTypeEditDialogComponent extends EditDialogComponent<PaperlessDocumentType> { | ||||
|   constructor( | ||||
|     service: DocumentTypeService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     toastService: ToastService | ||||
|   ) { | ||||
|     super(service, activeModal, toastService) | ||||
|   constructor(service: DocumentTypeService, activeModal: NgbActiveModal) { | ||||
|     super(service, activeModal) | ||||
|   } | ||||
|  | ||||
|   getCreateTitle() { | ||||
|   | ||||
| @@ -2,11 +2,9 @@ import { Directive, EventEmitter, Input, OnInit, Output } from '@angular/core' | ||||
| import { FormGroup } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { Observable } from 'rxjs' | ||||
| import { map } from 'rxjs/operators' | ||||
| import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model' | ||||
| import { ObjectWithId } from 'src/app/data/object-with-id' | ||||
| import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
|  | ||||
| @Directive() | ||||
| export abstract class EditDialogComponent<T extends ObjectWithId> | ||||
| @@ -14,8 +12,7 @@ export abstract class EditDialogComponent<T extends ObjectWithId> | ||||
| { | ||||
|   constructor( | ||||
|     private service: AbstractPaperlessService<T>, | ||||
|     private activeModal: NgbActiveModal, | ||||
|     private toastService: ToastService | ||||
|     private activeModal: NgbActiveModal | ||||
|   ) {} | ||||
|  | ||||
|   @Input() | ||||
| @@ -25,7 +22,7 @@ export abstract class EditDialogComponent<T extends ObjectWithId> | ||||
|   object: T | ||||
|  | ||||
|   @Output() | ||||
|   success = new EventEmitter() | ||||
|   succeeded = new EventEmitter() | ||||
|  | ||||
|   networkActive = false | ||||
|  | ||||
| @@ -95,16 +92,16 @@ export abstract class EditDialogComponent<T extends ObjectWithId> | ||||
|         break | ||||
|     } | ||||
|     this.networkActive = true | ||||
|     serverResponse.subscribe( | ||||
|       (result) => { | ||||
|     serverResponse.subscribe({ | ||||
|       next: (result) => { | ||||
|         this.activeModal.close() | ||||
|         this.success.emit(result) | ||||
|         this.succeeded.emit(result) | ||||
|       }, | ||||
|       (error) => { | ||||
|       error: (error) => { | ||||
|         this.error = error.error | ||||
|         this.networkActive = false | ||||
|       } | ||||
|     ) | ||||
|       }, | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   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 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> | ||||
|     </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 { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' | ||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
|  | ||||
| @Component({ | ||||
|   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'], | ||||
| }) | ||||
| export class StoragePathEditDialogComponent extends EditDialogComponent<PaperlessStoragePath> { | ||||
|   constructor( | ||||
|     service: StoragePathService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     toastService: ToastService | ||||
|   ) { | ||||
|     super(service, activeModal, toastService) | ||||
|   constructor(service: StoragePathService, activeModal: NgbActiveModal) { | ||||
|     super(service, activeModal) | ||||
|   } | ||||
|  | ||||
|   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 { PaperlessTag } from 'src/app/data/paperless-tag' | ||||
| 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 { 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'], | ||||
| }) | ||||
| export class TagEditDialogComponent extends EditDialogComponent<PaperlessTag> { | ||||
|   constructor( | ||||
|     service: TagService, | ||||
|     activeModal: NgbActiveModal, | ||||
|     toastService: ToastService | ||||
|   ) { | ||||
|     super(service, activeModal, toastService) | ||||
|   constructor(service: TagService, activeModal: NgbActiveModal) { | ||||
|     super(service, activeModal) | ||||
|   } | ||||
|  | ||||
|   getCreateTitle() { | ||||
|   | ||||
| @@ -4,7 +4,7 @@ | ||||
|       <use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" /> | ||||
|     </svg> | ||||
|     <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> | ||||
|     </ng-container> | ||||
|   </button> | ||||
|   | ||||
| @@ -321,7 +321,7 @@ export class FilterableDropdownComponent { | ||||
|   apply = new EventEmitter<ChangedItems>() | ||||
|  | ||||
|   @Output() | ||||
|   open = new EventEmitter() | ||||
|   opened = new EventEmitter() | ||||
|  | ||||
|   get operatorToggleEnabled(): boolean { | ||||
|     return ( | ||||
| @@ -356,7 +356,7 @@ export class FilterableDropdownComponent { | ||||
|       if (this.editing) { | ||||
|         this.selectionModel.reset() | ||||
|       } | ||||
|       this.open.next(this) | ||||
|       this.opened.next(this) | ||||
|     } else { | ||||
|       this.filterText = '' | ||||
|       if (this.applyOnClose && this.selectionModel.isDirty()) { | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|   <label class="form-label" [for]="inputId">{{title}}</label> | ||||
|   <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"> | ||||
|     <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 class="invalid-feedback"> | ||||
|     {{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 { FILTER_ASN_ISNULL } from 'src/app/data/filter-rule-type' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| @@ -17,6 +17,9 @@ import { AbstractInputComponent } from '../abstract-input' | ||||
|   styleUrls: ['./number.component.scss'], | ||||
| }) | ||||
| export class NumberComponent extends AbstractInputComponent<number> { | ||||
|   @Input() | ||||
|   showAdd: boolean = true | ||||
|  | ||||
|   constructor(private documentService: DocumentService) { | ||||
|     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" | ||||
|       [clearSearchOnAdd]="true" | ||||
|       [hideSelected]="true" | ||||
|       [addTag]="createTagRef" | ||||
|       [addTag]="allowCreate ? createTagRef : false" | ||||
|       addTagText="Add tag" | ||||
|       i18n-addTagText | ||||
|       (change)="onChange(value)" | ||||
| @@ -31,7 +31,7 @@ | ||||
|       </ng-template> | ||||
|     </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"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#plus" /> | ||||
|       </svg> | ||||
|   | ||||
| @@ -54,6 +54,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor { | ||||
|   @Input() | ||||
|   suggestions: number[] | ||||
|  | ||||
|   @Input() | ||||
|   allowCreate: boolean = true | ||||
|  | ||||
|   value: number[] | ||||
|  | ||||
|   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 { 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', | ||||
|   styleUrls: ['./select-dialog.component.scss'], | ||||
| }) | ||||
| export class SelectDialogComponent implements OnInit { | ||||
| export class SelectDialogComponent { | ||||
|   constructor(public activeModal: NgbActiveModal) {} | ||||
|  | ||||
|   @Output() | ||||
| @@ -24,8 +24,6 @@ export class SelectDialogComponent implements OnInit { | ||||
|  | ||||
|   selected: number | ||||
|  | ||||
|   ngOnInit(): void {} | ||||
|  | ||||
|   cancelClicked() { | ||||
|     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' | ||||
|  | ||||
| @Component({ | ||||
| @@ -6,7 +6,7 @@ import { PaperlessTag } from 'src/app/data/paperless-tag' | ||||
|   templateUrl: './tag.component.html', | ||||
|   styleUrls: ['./tag.component.scss'], | ||||
| }) | ||||
| export class TagComponent implements OnInit { | ||||
| export class TagComponent { | ||||
|   constructor() {} | ||||
|  | ||||
|   @Input() | ||||
| @@ -17,6 +17,4 @@ export class TagComponent implements OnInit { | ||||
|  | ||||
|   @Input() | ||||
|   clickable: boolean = false | ||||
|  | ||||
|   ngOnInit(): void {} | ||||
| } | ||||
|   | ||||
| @@ -11,9 +11,9 @@ | ||||
|       </tr> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|       <tr *ngFor="let doc of documents" (click)="openDocumentsService.openDocument(doc)"> | ||||
|         <td>{{doc.created_date | customDate}}</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> | ||||
|       <tr *ngFor="let doc of documents"> | ||||
|         <td><a routerLink="/documents/{{doc.id}}" class="d-block text-dark text-decoration-none">{{doc.created_date | customDate}}</a></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> | ||||
|     </tbody> | ||||
|   </table> | ||||
|   | ||||
| @@ -7,6 +7,6 @@ th:first-child { | ||||
|   width: 25%; | ||||
| } | ||||
|  | ||||
| tbody tr { | ||||
| tbody app-tag { | ||||
|   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([ | ||||
|       { rule_type: FILTER_HAS_TAGS_ALL, value: tag.id.toString() }, | ||||
|     ]) | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <app-widget-frame title="Statistics" [loading]="loading" i18n-title> | ||||
|   <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> | ||||
|   </ng-container> | ||||
| </app-widget-frame> | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| import { HttpEventType } from '@angular/common/http' | ||||
| import { Component, OnInit } from '@angular/core' | ||||
| import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop' | ||||
| import { Component } from '@angular/core' | ||||
| import { NgxFileDropEntry } from 'ngx-file-drop' | ||||
| import { | ||||
|   ConsumerStatusService, | ||||
|   FileStatus, | ||||
| @@ -15,7 +14,7 @@ const MAX_ALERTS = 5 | ||||
|   templateUrl: './upload-file-widget.component.html', | ||||
|   styleUrls: ['./upload-file-widget.component.scss'], | ||||
| }) | ||||
| export class UploadFileWidgetComponent implements OnInit { | ||||
| export class UploadFileWidgetComponent { | ||||
|   alertsExpanded = false | ||||
|  | ||||
|   constructor( | ||||
| @@ -109,8 +108,6 @@ export class UploadFileWidgetComponent implements OnInit { | ||||
|     this.consumerStatusService.dismissCompleted() | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void {} | ||||
|  | ||||
|   public fileOver(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' | ||||
|  | ||||
| @Component({ | ||||
| @@ -6,8 +6,6 @@ import { TourService } from 'ngx-ui-tour-ng-bootstrap' | ||||
|   templateUrl: './welcome-widget.component.html', | ||||
|   styleUrls: ['./welcome-widget.component.scss'], | ||||
| }) | ||||
| export class WelcomeWidgetComponent implements OnInit { | ||||
| export class WelcomeWidgetComponent { | ||||
|   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({ | ||||
|   selector: 'app-widget-frame', | ||||
|   templateUrl: './widget-frame.component.html', | ||||
|   styleUrls: ['./widget-frame.component.scss'], | ||||
| }) | ||||
| export class WidgetFrameComponent implements OnInit { | ||||
| export class WidgetFrameComponent { | ||||
|   constructor() {} | ||||
|  | ||||
|   @Input() | ||||
| @@ -13,6 +13,4 @@ export class WidgetFrameComponent implements OnInit { | ||||
|  | ||||
|   @Input() | ||||
|   loading: boolean = false | ||||
|  | ||||
|   ngOnInit(): void {} | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <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> | ||||
|       <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> | ||||
| @@ -149,9 +149,9 @@ | ||||
|  | ||||
|                 <li [ngbNavItem]="4" class="d-md-none"> | ||||
|                     <a ngbNavLink>Preview</a> | ||||
|                     <ng-template ngbNavContent *ngIf="pdfPreview.offsetParent == undefined"> | ||||
|                     <ng-template ngbNavContent *ngIf="pdfPreview.offsetParent === undefined"> | ||||
|                         <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"> | ||||
|                                     <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> | ||||
| @@ -159,7 +159,7 @@ | ||||
|                                     <object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object> | ||||
|                                 </ng-template> | ||||
|                             </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> | ||||
|                             </ng-container> | ||||
|                             <div *ngIf="requiresPassword" class="password-prompt"> | ||||
| @@ -180,14 +180,14 @@ | ||||
|  | ||||
|             <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-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n [disabled]="networkActive || !(isDirty$ | async) || error">Save & next</button>  | ||||
|             <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive || !(isDirty$ | async) || error">Save</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) === false || error">Save & next</button>  | ||||
|             <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive || (isDirty$ | async) === false || error">Save</button>  | ||||
|         </form> | ||||
|     </div> | ||||
|  | ||||
|     <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"> | ||||
|                 <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> | ||||
| @@ -195,7 +195,7 @@ | ||||
|                 <object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object> | ||||
|             </ng-template> | ||||
|         </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> | ||||
|         </ng-container> | ||||
|         <div *ngIf="requiresPassword" class="password-prompt"> | ||||
|   | ||||
| @@ -184,7 +184,7 @@ export class DocumentDetailComponent | ||||
|               this.openDocumentService.getOpenDocument(this.documentId) | ||||
|             ) | ||||
|           } else { | ||||
|             this.openDocumentService.openDocument(doc, false) | ||||
|             this.openDocumentService.openDocument(doc) | ||||
|             this.updateComponent(doc) | ||||
|           } | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| import { Component, Input, OnInit } from '@angular/core' | ||||
| import { Component, Input } from '@angular/core' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-metadata-collapse', | ||||
|   templateUrl: './metadata-collapse.component.html', | ||||
|   styleUrls: ['./metadata-collapse.component.scss'], | ||||
| }) | ||||
| export class MetadataCollapseComponent implements OnInit { | ||||
| export class MetadataCollapseComponent { | ||||
|   constructor() {} | ||||
|  | ||||
|   expand = false | ||||
| @@ -15,6 +15,4 @@ export class MetadataCollapseComponent implements OnInit { | ||||
|  | ||||
|   @Input() | ||||
|   title = $localize`Metadata` | ||||
|  | ||||
|   ngOnInit(): void {} | ||||
| } | ||||
|   | ||||
| @@ -66,7 +66,6 @@ | ||||
|   </div> | ||||
|   <div class="col-auto ms-auto mb-2 mb-xl-0 d-flex"> | ||||
|     <div class="btn-group btn-group-sm me-2"> | ||||
|  | ||||
|       <div ngbDropdown class="me-2 d-flex"> | ||||
|         <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle> | ||||
|           <svg class="toolbaricon" fill="currentColor"> | ||||
| @@ -75,22 +74,52 @@ | ||||
|           <div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div> | ||||
|         </button> | ||||
|         <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> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="btn-group btn-group-sm me-2"> | ||||
|       <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()"> | ||||
|         <svg *ngIf="!awaitingDownload" class="toolbaricon" fill="currentColor"> | ||||
|           <use xlink:href="assets/bootstrap-icons.svg#arrow-down" /> | ||||
|         </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" /> | ||||
| @@ -98,3 +127,4 @@ | ||||
|       </button> | ||||
|     </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 { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' | ||||
| 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 { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' | ||||
| import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||
| import { FormControl, FormGroup } from '@angular/forms' | ||||
| import { first, Subject, takeUntil } from 'rxjs' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-bulk-editor', | ||||
|   templateUrl: './bulk-editor.component.html', | ||||
|   styleUrls: ['./bulk-editor.component.scss'], | ||||
| }) | ||||
| export class BulkEditorComponent { | ||||
| export class BulkEditorComponent implements OnInit, OnDestroy { | ||||
|   tags: PaperlessTag[] | ||||
|   correspondents: PaperlessCorrespondent[] | ||||
|   documentTypes: PaperlessDocumentType[] | ||||
| @@ -43,6 +45,14 @@ export class BulkEditorComponent { | ||||
|   storagePathsSelectionModel = new FilterableDropdownSelectionModel() | ||||
|   awaitingDownload: boolean | ||||
|  | ||||
|   unsubscribeNotifier: Subject<any> = new Subject() | ||||
|  | ||||
|   downloadForm = new FormGroup({ | ||||
|     downloadFileTypeArchive: new FormControl(true), | ||||
|     downloadFileTypeOriginals: new FormControl(false), | ||||
|     downloadUseFormatting: new FormControl(false), | ||||
|   }) | ||||
|  | ||||
|   constructor( | ||||
|     private documentTypeService: DocumentTypeService, | ||||
|     private tagService: TagService, | ||||
| @@ -66,16 +76,46 @@ export class BulkEditorComponent { | ||||
|   ngOnInit() { | ||||
|     this.tagService | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => (this.tags = result.results)) | ||||
|     this.correspondentService | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => (this.correspondents = result.results)) | ||||
|     this.documentTypeService | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .subscribe((result) => (this.documentTypes = result.results)) | ||||
|     this.storagePathService | ||||
|       .listAll() | ||||
|       .pipe(first()) | ||||
|       .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) { | ||||
| @@ -84,8 +124,9 @@ export class BulkEditorComponent { | ||||
|     } | ||||
|     this.documentService | ||||
|       .bulkEdit(Array.from(this.list.selected), method, args) | ||||
|       .subscribe( | ||||
|         (response) => { | ||||
|       .pipe(first()) | ||||
|       .subscribe({ | ||||
|         next: () => { | ||||
|           this.list.reload() | ||||
|           this.list.reduceSelectionToFilter() | ||||
|           this.list.selected.forEach((id) => { | ||||
| @@ -95,7 +136,7 @@ export class BulkEditorComponent { | ||||
|             modal.close() | ||||
|           } | ||||
|         }, | ||||
|         (error) => { | ||||
|         error: (error) => { | ||||
|           if (modal) { | ||||
|             modal.componentInstance.buttonsEnabled = true | ||||
|           } | ||||
| @@ -104,8 +145,8 @@ export class BulkEditorComponent { | ||||
|               error.error | ||||
|             )}` | ||||
|           ) | ||||
|         } | ||||
|       ) | ||||
|         }, | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   private applySelectionData( | ||||
| @@ -126,6 +167,7 @@ export class BulkEditorComponent { | ||||
|   openTagsDropdown() { | ||||
|     this.documentService | ||||
|       .getSelectionData(Array.from(this.list.selected)) | ||||
|       .pipe(first()) | ||||
|       .subscribe((s) => { | ||||
|         this.applySelectionData(s.selected_tags, this.tagSelectionModel) | ||||
|       }) | ||||
| @@ -134,6 +176,7 @@ export class BulkEditorComponent { | ||||
|   openDocumentTypeDropdown() { | ||||
|     this.documentService | ||||
|       .getSelectionData(Array.from(this.list.selected)) | ||||
|       .pipe(first()) | ||||
|       .subscribe((s) => { | ||||
|         this.applySelectionData( | ||||
|           s.selected_document_types, | ||||
| @@ -145,6 +188,7 @@ export class BulkEditorComponent { | ||||
|   openCorrespondentDropdown() { | ||||
|     this.documentService | ||||
|       .getSelectionData(Array.from(this.list.selected)) | ||||
|       .pipe(first()) | ||||
|       .subscribe((s) => { | ||||
|         this.applySelectionData( | ||||
|           s.selected_correspondents, | ||||
| @@ -156,6 +200,7 @@ export class BulkEditorComponent { | ||||
|   openStoragePathDropdown() { | ||||
|     this.documentService | ||||
|       .getSelectionData(Array.from(this.list.selected)) | ||||
|       .pipe(first()) | ||||
|       .subscribe((s) => { | ||||
|         this.applySelectionData( | ||||
|           s.selected_storage_paths, | ||||
| @@ -232,7 +277,9 @@ export class BulkEditorComponent { | ||||
|  | ||||
|       modal.componentInstance.btnClass = 'btn-warning' | ||||
|       modal.componentInstance.btnCaption = $localize`Confirm` | ||||
|       modal.componentInstance.confirmClicked.subscribe(() => { | ||||
|       modal.componentInstance.confirmClicked | ||||
|         .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|         .subscribe(() => { | ||||
|           this.executeBulkOperation(modal, 'modify_tags', { | ||||
|             add_tags: changedTags.itemsToAdd.map((t) => t.id), | ||||
|             remove_tags: changedTags.itemsToRemove.map((t) => t.id), | ||||
| @@ -270,7 +317,9 @@ export class BulkEditorComponent { | ||||
|       } | ||||
|       modal.componentInstance.btnClass = 'btn-warning' | ||||
|       modal.componentInstance.btnCaption = $localize`Confirm` | ||||
|       modal.componentInstance.confirmClicked.subscribe(() => { | ||||
|       modal.componentInstance.confirmClicked | ||||
|         .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|         .subscribe(() => { | ||||
|           this.executeBulkOperation(modal, 'set_correspondent', { | ||||
|             correspondent: correspondent ? correspondent.id : null, | ||||
|           }) | ||||
| @@ -306,7 +355,9 @@ export class BulkEditorComponent { | ||||
|       } | ||||
|       modal.componentInstance.btnClass = 'btn-warning' | ||||
|       modal.componentInstance.btnCaption = $localize`Confirm` | ||||
|       modal.componentInstance.confirmClicked.subscribe(() => { | ||||
|       modal.componentInstance.confirmClicked | ||||
|         .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|         .subscribe(() => { | ||||
|           this.executeBulkOperation(modal, 'set_document_type', { | ||||
|             document_type: documentType ? documentType.id : null, | ||||
|           }) | ||||
| @@ -342,7 +393,9 @@ export class BulkEditorComponent { | ||||
|       } | ||||
|       modal.componentInstance.btnClass = 'btn-warning' | ||||
|       modal.componentInstance.btnCaption = $localize`Confirm` | ||||
|       modal.componentInstance.confirmClicked.subscribe(() => { | ||||
|       modal.componentInstance.confirmClicked | ||||
|         .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|         .subscribe(() => { | ||||
|           this.executeBulkOperation(modal, 'set_storage_path', { | ||||
|             storage_path: storagePath ? storagePath.id : null, | ||||
|           }) | ||||
| @@ -364,16 +417,30 @@ export class BulkEditorComponent { | ||||
|     modal.componentInstance.message = $localize`This operation cannot be undone.` | ||||
|     modal.componentInstance.btnClass = 'btn-danger' | ||||
|     modal.componentInstance.btnCaption = $localize`Delete document(s)` | ||||
|     modal.componentInstance.confirmClicked.subscribe(() => { | ||||
|     modal.componentInstance.confirmClicked | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe(() => { | ||||
|         modal.componentInstance.buttonsEnabled = false | ||||
|         this.executeBulkOperation(modal, 'delete', {}) | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   downloadSelected(content = 'archive') { | ||||
|   downloadSelected() { | ||||
|     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 | ||||
|       .bulkDownload(Array.from(this.list.selected), content) | ||||
|       .bulkDownload( | ||||
|         Array.from(this.list.selected), | ||||
|         downloadFileType, | ||||
|         this.downloadForm.get('downloadUseFormatting').value | ||||
|       ) | ||||
|       .pipe(first()) | ||||
|       .subscribe((result: any) => { | ||||
|         saveAs(result, 'documents.zip') | ||||
|         this.awaitingDownload = false | ||||
| @@ -389,7 +456,9 @@ export class BulkEditorComponent { | ||||
|     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.confirmClicked | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe(() => { | ||||
|         modal.componentInstance.buttonsEnabled = false | ||||
|         this.executeBulkOperation(modal, 'redo_ocr', {}) | ||||
|       }) | ||||
|   | ||||
| @@ -37,7 +37,7 @@ | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#diagram-3"/> | ||||
|               </svg> <span class="d-none d-md-inline" i18n>More like this</span> | ||||
|             </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"> | ||||
|                 <use xlink:href="assets/bootstrap-icons.svg#pencil"/> | ||||
|               </svg> <span class="d-none d-md-inline" i18n>Edit</span> | ||||
|   | ||||
| @@ -2,7 +2,6 @@ import { | ||||
|   Component, | ||||
|   EventEmitter, | ||||
|   Input, | ||||
|   OnInit, | ||||
|   Output, | ||||
|   ViewChild, | ||||
| } 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 { SettingsService } from 'src/app/services/settings.service' | ||||
| 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' | ||||
|  | ||||
| @Component({ | ||||
| @@ -23,11 +19,10 @@ import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||
|     '../popover-preview/popover-preview.scss', | ||||
|   ], | ||||
| }) | ||||
| export class DocumentCardLargeComponent implements OnInit { | ||||
| export class DocumentCardLargeComponent { | ||||
|   constructor( | ||||
|     private documentService: DocumentService, | ||||
|     private settingsService: SettingsService, | ||||
|     public openDocumentsService: OpenDocumentsService | ||||
|     private settingsService: SettingsService | ||||
|   ) {} | ||||
|  | ||||
|   @Input() | ||||
| @@ -75,8 +70,6 @@ export class DocumentCardLargeComponent implements OnInit { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void {} | ||||
|  | ||||
|   getIsThumbInverted() { | ||||
|     return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED) | ||||
|   } | ||||
| @@ -119,6 +112,9 @@ export class DocumentCardLargeComponent implements OnInit { | ||||
|   } | ||||
|  | ||||
|   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 class="d-flex justify-content-between align-items-center"> | ||||
|         <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"> | ||||
|               <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> | ||||
|   | ||||
| @@ -2,7 +2,6 @@ import { | ||||
|   Component, | ||||
|   EventEmitter, | ||||
|   Input, | ||||
|   OnInit, | ||||
|   Output, | ||||
|   ViewChild, | ||||
| } 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 { SettingsService } from 'src/app/services/settings.service' | ||||
| 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' | ||||
|  | ||||
| @Component({ | ||||
| @@ -22,11 +20,10 @@ import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||
|     '../popover-preview/popover-preview.scss', | ||||
|   ], | ||||
| }) | ||||
| export class DocumentCardSmallComponent implements OnInit { | ||||
| export class DocumentCardSmallComponent { | ||||
|   constructor( | ||||
|     private documentService: DocumentService, | ||||
|     private settingsService: SettingsService, | ||||
|     public openDocumentsService: OpenDocumentsService | ||||
|     private settingsService: SettingsService | ||||
|   ) {} | ||||
|  | ||||
|   @Input() | ||||
| @@ -57,8 +54,6 @@ export class DocumentCardSmallComponent implements OnInit { | ||||
|   mouseOnPreview = false | ||||
|   popoverHidden = true | ||||
|  | ||||
|   ngOnInit(): void {} | ||||
|  | ||||
|   getIsThumbInverted() { | ||||
|     return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED) | ||||
|   } | ||||
|   | ||||
| @@ -53,7 +53,7 @@ | ||||
|       </div> | ||||
|       <div> | ||||
|         <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> | ||||
|       </div> | ||||
|     </div> | ||||
| @@ -94,7 +94,7 @@ | ||||
|       </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> | ||||
|       <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> | ||||
|     </p> | ||||
|     <ngb-pagination *ngIf="list.collectionSize" [pageSize]="list.currentPageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5" | ||||
| @@ -111,52 +111,52 @@ | ||||
| </ng-container> | ||||
|  | ||||
| <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> | ||||
|   </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> | ||||
|       <th></th> | ||||
|       <th class="d-none d-lg-table-cell" | ||||
|         sortable="archive_serial_number" | ||||
|         appSortable="archive_serial_number" | ||||
|         [currentSortField]="list.sortField" | ||||
|         [currentSortReverse]="list.sortReverse" | ||||
|         (sort)="onSort($event)" | ||||
|         i18n>ASN</th> | ||||
|       <th class="d-none d-md-table-cell" | ||||
|         sortable="correspondent__name" | ||||
|         appSortable="correspondent__name" | ||||
|         [currentSortField]="list.sortField" | ||||
|         [currentSortReverse]="list.sortReverse" | ||||
|         (sort)="onSort($event)" | ||||
|         i18n>Correspondent</th> | ||||
|       <th | ||||
|         sortable="title" | ||||
|         appSortable="title" | ||||
|         [currentSortField]="list.sortField" | ||||
|         [currentSortReverse]="list.sortReverse" | ||||
|         (sort)="onSort($event)" | ||||
|         i18n>Title</th> | ||||
|       <th class="d-none d-xl-table-cell" | ||||
|         sortable="document_type__name" | ||||
|         appSortable="document_type__name" | ||||
|         [currentSortField]="list.sortField" | ||||
|         [currentSortReverse]="list.sortReverse" | ||||
|         (sort)="onSort($event)" | ||||
|         i18n>Document type</th> | ||||
|       <th class="d-none d-xl-table-cell" | ||||
|         sortable="storage_path__name" | ||||
|         appSortable="storage_path__name" | ||||
|         [currentSortField]="list.sortField" | ||||
|         [currentSortReverse]="list.sortReverse" | ||||
|         (sort)="onSort($event)" | ||||
|         i18n>Storage path</th> | ||||
|       <th | ||||
|         sortable="created" | ||||
|         appSortable="created" | ||||
|         [currentSortField]="list.sortField" | ||||
|         [currentSortReverse]="list.sortReverse" | ||||
|         (sort)="onSort($event)" | ||||
|         i18n>Created</th> | ||||
|       <th class="d-none d-xl-table-cell" | ||||
|         sortable="added" | ||||
|         appSortable="added" | ||||
|         [currentSortField]="list.sortField" | ||||
|         [currentSortReverse]="list.sortReverse" | ||||
|         (sort)="onSort($event)" | ||||
| @@ -179,7 +179,7 @@ | ||||
|           </ng-container> | ||||
|         </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> | ||||
|         </td> | ||||
|         <td class="d-none d-xl-table-cell"> | ||||
| @@ -202,7 +202,7 @@ | ||||
|     </tbody> | ||||
|   </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> | ||||
|   </div> | ||||
|   <div *ngIf="list.documents?.length > 15" class="mt-3"> | ||||
|   | ||||
| @@ -5,10 +5,10 @@ | ||||
|            <div ngbDropdown> | ||||
|             <button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{textFilterTargetName}}</button> | ||||
|             <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> | ||||
|           <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> | ||||
|           </select> | ||||
|           <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"/> | ||||
|             </svg> | ||||
|           </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> | ||||
|   | ||||
| @@ -16,10 +16,10 @@ | ||||
| <table class="table table-striped align-middle border shadow-sm"> | ||||
|   <thead> | ||||
|     <tr> | ||||
|       <th scope="col" sortable="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" sortable="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" appSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</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" appSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</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> | ||||
|     </tr> | ||||
|   </thead> | ||||
|   | ||||
| @@ -120,8 +120,20 @@ export abstract class ManagementListComponent<T extends ObjectWithId> | ||||
|       backdrop: 'static', | ||||
|     }) | ||||
|     activeModal.componentInstance.dialogMode = 'create' | ||||
|     activeModal.componentInstance.success.subscribe((o) => { | ||||
|     activeModal.componentInstance.success.subscribe({ | ||||
|       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.dialogMode = 'edit' | ||||
|     activeModal.componentInstance.success.subscribe((o) => { | ||||
|     activeModal.componentInstance.success.subscribe({ | ||||
|       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> | ||||
|   <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> | ||||
|  | ||||
| <!-- <p>items per page, documents per view type</p> --> | ||||
| <form [formGroup]="settingsForm" (ngSubmit)="saveSettings()"> | ||||
|  | ||||
|   <ul ngbNav #nav="ngbNav" class="nav-tabs"> | ||||
|     <li [ngbNavItem]="1"> | ||||
|   <ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-tabs"> | ||||
|     <li [ngbNavItem]="SettingsNavIDs.General"> | ||||
|       <a ngbNavLink i18n>General</a> | ||||
|       <ng-template ngbNavContent> | ||||
|  | ||||
| @@ -19,7 +24,7 @@ | ||||
|           <div class="col"> | ||||
|  | ||||
|             <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> | ||||
|  | ||||
|             <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> | ||||
|     </li> | ||||
|  | ||||
|     <li [ngbNavItem]="2"> | ||||
|     <li [ngbNavItem]="SettingsNavIDs.Notifications"> | ||||
|       <a ngbNavLink i18n>Notifications</a> | ||||
|       <ng-template ngbNavContent> | ||||
|  | ||||
| @@ -180,7 +185,7 @@ | ||||
|       </ng-template> | ||||
|     </li> | ||||
|  | ||||
|     <li [ngbNavItem]="3"> | ||||
|     <li [ngbNavItem]="SettingsNavIDs.SavedViews" (mouseover)="maybeInitializeTab(SettingsNavIDs.SavedViews)" (focusin)="maybeInitializeTab(SettingsNavIDs.SavedViews)"> | ||||
|       <a ngbNavLink i18n>Saved views</a> | ||||
|       <ng-template ngbNavContent> | ||||
|  | ||||
| @@ -210,8 +215,97 @@ | ||||
|               </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> | ||||
|  | ||||
|       </ng-template> | ||||
| @@ -220,5 +314,5 @@ | ||||
|  | ||||
|   <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> | ||||
|   | ||||
| @@ -26,9 +26,26 @@ import { | ||||
|   Subject, | ||||
| } from 'rxjs' | ||||
| 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 { 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({ | ||||
|   selector: 'app-settings', | ||||
| @@ -38,8 +55,14 @@ import { TourService } from 'ngx-ui-tour-ng-bootstrap' | ||||
| export class SettingsComponent | ||||
|   implements OnInit, AfterViewInit, OnDestroy, DirtyComponent | ||||
| { | ||||
|   SettingsNavIDs = SettingsNavIDs | ||||
|   activeNavID: number | ||||
|  | ||||
|   savedViewGroup = new FormGroup({}) | ||||
|  | ||||
|   mailAccountGroup = new FormGroup({}) | ||||
|   mailRuleGroup = new FormGroup({}) | ||||
|  | ||||
|   settingsForm = new FormGroup({ | ||||
|     bulkEditConfirmationDialogs: new FormControl(null), | ||||
|     bulkEditApplyOnClose: new FormControl(null), | ||||
| @@ -50,20 +73,28 @@ export class SettingsComponent | ||||
|     darkModeInvertThumbs: new FormControl(null), | ||||
|     themeColor: new FormControl(null), | ||||
|     useNativePdfViewer: new FormControl(null), | ||||
|     savedViews: this.savedViewGroup, | ||||
|     displayLanguage: new FormControl(null), | ||||
|     dateLocale: new FormControl(null), | ||||
|     dateFormat: new FormControl(null), | ||||
|     commentsEnabled: new FormControl(null), | ||||
|     updateCheckingEnabled: new FormControl(null), | ||||
|  | ||||
|     notificationsConsumerNewDocument: new FormControl(null), | ||||
|     notificationsConsumerSuccess: new FormControl(null), | ||||
|     notificationsConsumerFailed: 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[] | ||||
|  | ||||
|   mailAccounts: PaperlessMailAccount[] | ||||
|   mailRules: PaperlessMailRule[] | ||||
|  | ||||
|   store: BehaviorSubject<any> | ||||
|   storeSub: Subscription | ||||
|   isDirty$: Observable<boolean> | ||||
| @@ -81,19 +112,40 @@ export class SettingsComponent | ||||
|  | ||||
|   constructor( | ||||
|     public savedViewService: SavedViewService, | ||||
|     public mailAccountService: MailAccountService, | ||||
|     public mailRuleService: MailRuleService, | ||||
|     private documentListViewService: DocumentListViewService, | ||||
|     private toastService: ToastService, | ||||
|     private settings: SettingsService, | ||||
|     @Inject(LOCALE_ID) public currentLocale: string, | ||||
|     private viewportScroller: ViewportScroller, | ||||
|     private activatedRoute: ActivatedRoute, | ||||
|     public readonly tourService: TourService | ||||
|     private router: Router, | ||||
|     public readonly tourService: TourService, | ||||
|     private modalService: NgbModal | ||||
|   ) { | ||||
|     this.settings.settingsSaved.subscribe(() => { | ||||
|       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 { | ||||
|     if (this.activatedRoute.snapshot.fragment) { | ||||
|       this.viewportScroller.scrollToAnchor( | ||||
| @@ -123,10 +175,13 @@ export class SettingsComponent | ||||
|       useNativePdfViewer: this.settings.get( | ||||
|         SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER | ||||
|       ), | ||||
|       savedViews: {}, | ||||
|       displayLanguage: this.settings.getLanguage(), | ||||
|       dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE), | ||||
|       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( | ||||
|         SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT | ||||
|       ), | ||||
| @@ -139,25 +194,60 @@ export class SettingsComponent | ||||
|       notificationsConsumerSuppressOnDashboard: this.settings.get( | ||||
|         SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD | ||||
|       ), | ||||
|       commentsEnabled: this.settings.get(SETTINGS_KEYS.COMMENTS_ENABLED), | ||||
|       updateCheckingEnabled: this.settings.get( | ||||
|         SETTINGS_KEYS.UPDATE_CHECKING_ENABLED | ||||
|       ), | ||||
|       savedViews: {}, | ||||
|       mailAccounts: {}, | ||||
|       mailRules: {}, | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   ngOnInit() { | ||||
|     this.savedViewService.listAll().subscribe((r) => { | ||||
|       this.savedViews = r.results | ||||
|   onNavChange(navChangeEvent: NgbNavChangeEvent) { | ||||
|     this.maybeInitializeTab(navChangeEvent.nextId) | ||||
|     const [foundNavIDkey, foundNavIDValue] = Object.entries( | ||||
|       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) | ||||
|  | ||||
|     const currentFormValue = this.settingsForm.value | ||||
|  | ||||
|     let storeData = this.getCurrentSettings() | ||||
|  | ||||
|     if (this.savedViews) { | ||||
|       for (let view of this.savedViews) { | ||||
|         storeData.savedViews[view.id.toString()] = { | ||||
|           id: view.id, | ||||
| @@ -175,6 +265,77 @@ export class SettingsComponent | ||||
|           }) | ||||
|         ) | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     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.store = new BehaviorSubject(storeData) | ||||
|  | ||||
| @@ -202,6 +363,11 @@ export class SettingsComponent | ||||
|           this.settingsForm.get('themeColor').value | ||||
|         ) | ||||
|       }) | ||||
|  | ||||
|     if (!resetSettings && currentFormValue) { | ||||
|       // prevents loss of unsaved changes | ||||
|       this.settingsForm.patchValue(currentFormValue) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy() { | ||||
| @@ -372,4 +538,121 @@ export class SettingsComponent | ||||
|   clearThemeColor() { | ||||
|     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> | ||||
|   <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"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#x"/> | ||||
|       </svg> <ng-container i18n>Clear selection</ng-container> | ||||
|     </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"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#check2-all"/> | ||||
|       </svg> <ng-container i18n>{{dismissButtonText}}</ng-container> | ||||
| @@ -33,13 +33,13 @@ | ||||
|       <tr> | ||||
|         <th scope="col"> | ||||
|           <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> | ||||
|           </div> | ||||
|         </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" *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" i18n>Actions</th> | ||||
|       </tr> | ||||
| @@ -55,7 +55,7 @@ | ||||
|         </th> | ||||
|         <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" *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();" | ||||
|             [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> | ||||
| @@ -89,7 +89,7 @@ | ||||
|         </td> | ||||
|       </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> | ||||
|         </td> | ||||
|       </tr> | ||||
|   | ||||
| @@ -1,12 +1,10 @@ | ||||
| import { Component, OnInit } from '@angular/core' | ||||
| import { Component } from '@angular/core' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-not-found', | ||||
|   templateUrl: './not-found.component.html', | ||||
|   styleUrls: ['./not-found.component.scss'], | ||||
| }) | ||||
| export class NotFoundComponent implements OnInit { | ||||
| export class NotFoundComponent { | ||||
|   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 { | ||||
|   column: string | ||||
| @@ -6,18 +13,13 @@ export interface SortEvent { | ||||
| } | ||||
|  | ||||
| @Directive({ | ||||
|   selector: 'th[sortable]', | ||||
|   host: { | ||||
|     '[class.asc]': 'currentSortField == sortable && !currentSortReverse', | ||||
|     '[class.des]': 'currentSortField == sortable && currentSortReverse', | ||||
|     '(click)': 'rotate()', | ||||
|   }, | ||||
|   selector: 'th[appSortable]', | ||||
| }) | ||||
| export class SortableDirective { | ||||
|   constructor() {} | ||||
|  | ||||
|   @Input() | ||||
|   sortable: string = '' | ||||
|   appSortable: string = '' | ||||
|  | ||||
|   @Input() | ||||
|   currentSortReverse: boolean = false | ||||
| @@ -27,11 +29,20 @@ export class SortableDirective { | ||||
|  | ||||
|   @Output() sort = new EventEmitter<SortEvent>() | ||||
|  | ||||
|   rotate() { | ||||
|     if (this.currentSortField != this.sortable) { | ||||
|       this.sort.emit({ column: this.sortable, reverse: false }) | ||||
|   @HostBinding('class.asc') get asc() { | ||||
|     return ( | ||||
|       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 ( | ||||
|       this.currentSortField == this.sortable && | ||||
|       this.currentSortField == this.appSortable && | ||||
|       !this.currentSortReverse | ||||
|     ) { | ||||
|       this.sort.emit({ column: this.currentSortField, reverse: true }) | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import { Injectable } from '@angular/core' | ||||
| import { DirtyCheckGuard } from '@ngneat/dirty-check-forms' | ||||
| import { Observable, Subject } from 'rxjs' | ||||
| import { map } from 'rxjs/operators' | ||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' | ||||
|  | ||||
|   | ||||
| @@ -213,7 +213,8 @@ export class DocumentListViewService { | ||||
|         this.currentPageSize, | ||||
|         activeListViewState.sortField, | ||||
|         activeListViewState.sortReverse, | ||||
|         activeListViewState.filterRules | ||||
|         activeListViewState.filterRules, | ||||
|         { truncate_content: true } | ||||
|       ) | ||||
|       .subscribe({ | ||||
|         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 { Observable, Subject, of } from 'rxjs' | ||||
| import { first } from 'rxjs/operators' | ||||
| import { Router } from '@angular/router' | ||||
|  | ||||
| @Injectable({ | ||||
|   providedIn: 'root', | ||||
| @@ -16,8 +15,7 @@ export class OpenDocumentsService { | ||||
|  | ||||
|   constructor( | ||||
|     private documentService: DocumentService, | ||||
|     private modalService: NgbModal, | ||||
|     private router: Router | ||||
|     private modalService: NgbModal | ||||
|   ) { | ||||
|     if (sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS)) { | ||||
|       try { | ||||
| @@ -57,39 +55,28 @@ export class OpenDocumentsService { | ||||
|     return this.openDocuments.find((d) => d.id == id) | ||||
|   } | ||||
|  | ||||
|   openDocument( | ||||
|     doc: PaperlessDocument, | ||||
|     navigate: boolean = true | ||||
|   ): Observable<boolean> { | ||||
|   openDocument(doc: PaperlessDocument): Observable<boolean> { | ||||
|     if (this.openDocuments.find((d) => d.id == doc.id) == null) { | ||||
|       if (this.openDocuments.length == this.MAX_OPEN_DOCUMENTS) { | ||||
|         // at max, ensure changes arent lost | ||||
|         const docToRemove = this.openDocuments[this.MAX_OPEN_DOCUMENTS - 1] | ||||
|         const closeObservable = this.closeDocument(docToRemove) | ||||
|         closeObservable.pipe(first()).subscribe((closed) => { | ||||
|           if (closed) this.finishOpenDocument(doc, navigate) | ||||
|           if (closed) this.finishOpenDocument(doc) | ||||
|         }) | ||||
|         return closeObservable | ||||
|       } else { | ||||
|         // not at max | ||||
|         this.finishOpenDocument(doc, navigate) | ||||
|       } | ||||
|     } else { | ||||
|       // doc is open, just maybe navigate | ||||
|       if (navigate) { | ||||
|         this.router.navigate(['documents', doc.id]) | ||||
|         this.finishOpenDocument(doc) | ||||
|       } | ||||
|     } | ||||
|     return of(true) | ||||
|   } | ||||
|  | ||||
|   private finishOpenDocument(doc: PaperlessDocument, navigate: boolean) { | ||||
|   private finishOpenDocument(doc: PaperlessDocument) { | ||||
|     this.openDocuments.unshift(doc) | ||||
|     this.dirtyDocuments.delete(doc.id) | ||||
|     this.save() | ||||
|     if (navigate) { | ||||
|       this.router.navigate(['documents', doc.id]) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   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( | ||||
|       this.getResourceUrl(null, 'bulk_download'), | ||||
|       { documents: ids, content: content }, | ||||
|       { | ||||
|         documents: ids, | ||||
|         content: content, | ||||
|         follow_formatting: useFilenameFormatting, | ||||
|       }, | ||||
|       { 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/', | ||||
|   apiVersion: '2', | ||||
|   appTitle: 'Paperless-ngx', | ||||
|   version: '1.10.2', | ||||
|   version: '1.10.2-dev', | ||||
|   webSocketHost: window.location.host, | ||||
|   webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : '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