Merge branch 'dev'

This commit is contained in:
Michael Shamoon 2022-12-29 19:39:38 -08:00
commit 8c9a74ee0c
181 changed files with 44867 additions and 8345 deletions

View File

@ -1,6 +1,6 @@
{ {
"qpdf": { "qpdf": {
"version": "11.1.1" "version": "11.2.0"
}, },
"jbig2enc": { "jbig2enc": {
"version": "0.29", "version": "0.29",

View File

@ -4,6 +4,7 @@ autolabeler:
- '/^fix/' - '/^fix/'
title: title:
- "/^fix/i" - "/^fix/i"
- "/^Bugfix/i"
- label: "enhancement" - label: "enhancement"
branch: branch:
- '/^feature/' - '/^feature/'
@ -13,6 +14,9 @@ categories:
- title: 'Breaking Changes' - title: 'Breaking Changes'
labels: labels:
- 'breaking-change' - 'breaking-change'
- title: 'Notable Changes'
labels:
- 'notable'
- title: 'Features' - title: 'Features'
labels: labels:
- 'enhancement' - 'enhancement'
@ -20,7 +24,8 @@ categories:
labels: labels:
- 'bug' - 'bug'
- title: 'Documentation' - title: 'Documentation'
label: 'documentation' labels:
- 'documentation'
- title: 'Maintenance' - title: 'Maintenance'
labels: labels:
- 'chore' - 'chore'
@ -29,7 +34,8 @@ categories:
- 'ci-cd' - 'ci-cd'
- title: 'Dependencies' - title: 'Dependencies'
collapse-after: 3 collapse-after: 3
label: 'dependencies' labels:
- 'dependencies'
- title: 'All App Changes' - title: 'All App Changes'
labels: labels:
- 'frontend' - 'frontend'
@ -46,6 +52,8 @@ include-labels:
- 'frontend' - 'frontend'
- 'backend' - 'backend'
- 'ci-cd' - 'ci-cd'
- 'breaking-change'
- 'notable'
category-template: '### $TITLE' category-template: '### $TITLE'
change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))' change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))'
change-title-escapes: '\<*_&#@' change-title-escapes: '\<*_&#@'

View File

@ -16,7 +16,7 @@ on:
jobs: jobs:
pre-commit: pre-commit:
name: Linting Checks name: Linting Checks
runs-on: ubuntu-latest runs-on: ubuntu-22.04
steps: steps:
- -
name: Checkout repository name: Checkout repository
@ -34,7 +34,7 @@ jobs:
documentation: documentation:
name: "Build Documentation" name: "Build Documentation"
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
needs: needs:
- pre-commit - pre-commit
steps: steps:
@ -44,7 +44,7 @@ jobs:
- -
name: Install pipenv name: Install pipenv
run: | run: |
pipx install pipenv==2022.10.12 pipx install pipenv==2022.11.30
- -
name: Set up Python name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
@ -73,7 +73,7 @@ jobs:
documentation-deploy: documentation-deploy:
name: "Deploy Documentation" name: "Deploy Documentation"
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: needs:
- documentation - documentation
@ -92,7 +92,7 @@ jobs:
tests-backend: tests-backend:
name: "Tests (${{ matrix.python-version }})" name: "Tests (${{ matrix.python-version }})"
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
needs: needs:
- pre-commit - pre-commit
strategy: strategy:
@ -106,6 +106,10 @@ jobs:
PAPERLESS_MAIL_TEST_HOST: ${{ secrets.TEST_MAIL_HOST }} PAPERLESS_MAIL_TEST_HOST: ${{ secrets.TEST_MAIL_HOST }}
PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }} PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }}
PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }} PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }}
# Skip Tests which require convert
PAPERLESS_TEST_SKIP_CONVERT: 1
# Enable Gotenberg end to end testing
GOTENBERG_LIVE: 1
steps: steps:
- -
name: Checkout name: Checkout
@ -120,7 +124,7 @@ jobs:
- -
name: Install pipenv name: Install pipenv
run: | run: |
pipx install pipenv==2022.10.12 pipx install pipenv==2022.11.30
- -
name: Set up Python name: Set up Python
uses: actions/setup-python@v4 uses: actions/setup-python@v4
@ -177,7 +181,7 @@ jobs:
tests-frontend: tests-frontend:
name: "Tests Frontend" name: "Tests Frontend"
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
needs: needs:
- pre-commit - pre-commit
strategy: strategy:
@ -191,13 +195,14 @@ jobs:
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
- run: cd src-ui && npm ci - run: cd src-ui && npm ci
- run: cd src-ui && npm run lint
- run: cd src-ui && npm run test - run: cd src-ui && npm run test
- run: cd src-ui && npm run e2e:ci - run: cd src-ui && npm run e2e:ci
prepare-docker-build: prepare-docker-build:
name: Prepare Docker Pipeline Data name: Prepare Docker Pipeline Data
if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v')) if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v'))
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
# If the push triggered the installer library workflow, wait for it to # If the push triggered the installer library workflow, wait for it to
# complete here. This ensures the required versions for the final # complete here. This ensures the required versions for the final
# image have been built, while not waiting at all if the versions haven't changed # image have been built, while not waiting at all if the versions haven't changed
@ -274,7 +279,7 @@ jobs:
# build and push image to docker hub. # build and push image to docker hub.
build-docker-image: build-docker-image:
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
concurrency: concurrency:
group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }} group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }}
cancel-in-progress: true cancel-in-progress: true
@ -379,7 +384,7 @@ jobs:
build-release: build-release:
needs: needs:
- build-docker-image - build-docker-image
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
steps: steps:
- -
name: Checkout name: Checkout
@ -458,7 +463,7 @@ jobs:
path: dist/paperless-ngx.tar.xz path: dist/paperless-ngx.tar.xz
publish-release: publish-release:
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
outputs: outputs:
prerelease: ${{ steps.get_version.outputs.prerelease }} prerelease: ${{ steps.get_version.outputs.prerelease }}
changelog: ${{ steps.create-release.outputs.body }} changelog: ${{ steps.create-release.outputs.body }}
@ -507,7 +512,7 @@ jobs:
asset_content_type: application/x-xz asset_content_type: application/x-xz
append-changelog: append-changelog:
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
needs: needs:
- publish-release - publish-release
if: needs.publish-release.outputs.prerelease == 'false' if: needs.publish-release.outputs.prerelease == 'false'

View File

@ -1,17 +1,14 @@
# This workflow runs on certain conditions to check for and potentially # This workflow runs on certain conditions to check for and potentially
# delete container images from the GHCR which no longer have an associated # delete container images from the GHCR which no longer have an associated
# code branch. # code branch.
# Requires a PAT with the correct scope set in the secrets # Requires a PAT with the correct scope set in the secrets.
#
# This workflow will not trigger runs on forked repos.
name: Cleanup Image Tags name: Cleanup Image Tags
on: on:
schedule:
- cron: '0 0 * * SAT'
delete: delete:
pull_request:
types:
- closed
push: push:
paths: paths:
- ".github/workflows/cleanup-tags.yml" - ".github/workflows/cleanup-tags.yml"
@ -26,7 +23,8 @@ concurrency:
jobs: jobs:
cleanup-images: cleanup-images:
name: Cleanup Image Tags for ${{ matrix.primary-name }} name: Cleanup Image Tags for ${{ matrix.primary-name }}
runs-on: ubuntu-latest if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-22.04
strategy: strategy:
matrix: matrix:
include: include:

View File

@ -23,7 +23,7 @@ on:
jobs: jobs:
analyze: analyze:
name: Analyze name: Analyze
runs-on: ubuntu-latest runs-on: ubuntu-22.04
permissions: permissions:
actions: read actions: read
contents: read contents: read

View File

@ -34,7 +34,7 @@ concurrency:
jobs: jobs:
prepare-docker-build: prepare-docker-build:
name: Prepare Docker Image Version Data name: Prepare Docker Image Version Data
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
steps: steps:
- -
name: Set ghcr repository name name: Set ghcr repository name
@ -127,6 +127,7 @@ jobs:
uses: ./.github/workflows/reusable-workflow-builder.yml uses: ./.github/workflows/reusable-workflow-builder.yml
with: with:
dockerfile: ./docker-builders/Dockerfile.qpdf dockerfile: ./docker-builders/Dockerfile.qpdf
build-platforms: linux/amd64
build-json: ${{ needs.prepare-docker-build.outputs.qpdf-json }} build-json: ${{ needs.prepare-docker-build.outputs.qpdf-json }}
build-args: | build-args: |
QPDF_VERSION=${{ fromJSON(needs.prepare-docker-build.outputs.qpdf-json).version }} QPDF_VERSION=${{ fromJSON(needs.prepare-docker-build.outputs.qpdf-json).version }}

View File

@ -24,7 +24,7 @@ env:
jobs: jobs:
issue_opened_or_reopened: issue_opened_or_reopened:
name: issue_opened_or_reopened name: issue_opened_or_reopened
runs-on: ubuntu-latest runs-on: ubuntu-22.04
if: github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'reopened') if: github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'reopened')
steps: steps:
- name: Add issue to project and set status to ${{ env.todo }} - name: Add issue to project and set status to ${{ env.todo }}
@ -37,7 +37,7 @@ jobs:
status_value: ${{ env.todo }} # Target status status_value: ${{ env.todo }} # Target status
pr_opened_or_reopened: pr_opened_or_reopened:
name: pr_opened_or_reopened name: pr_opened_or_reopened
runs-on: ubuntu-latest runs-on: ubuntu-22.04
permissions: permissions:
# write permission is required for autolabeler # write permission is required for autolabeler
pull-requests: write pull-requests: write

View File

@ -9,7 +9,7 @@ on:
jobs: jobs:
release_chart: release_chart:
name: "Release Chart" name: "Release Chart"
runs-on: ubuntu-latest runs-on: ubuntu-22.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3

View File

@ -13,6 +13,10 @@ on:
required: false required: false
default: "" default: ""
type: string type: string
build-platforms:
required: false
default: linux/amd64,linux/arm64,linux/arm/v7
type: string
concurrency: concurrency:
group: ${{ github.workflow }}-${{ fromJSON(inputs.build-json).name }}-${{ fromJSON(inputs.build-json).version }} group: ${{ github.workflow }}-${{ fromJSON(inputs.build-json).name }}-${{ fromJSON(inputs.build-json).version }}
@ -21,7 +25,7 @@ concurrency:
jobs: jobs:
build-image: build-image:
name: Build ${{ fromJSON(inputs.build-json).name }} @ ${{ fromJSON(inputs.build-json).version }} name: Build ${{ fromJSON(inputs.build-json).name }} @ ${{ fromJSON(inputs.build-json).version }}
runs-on: ubuntu-latest runs-on: ubuntu-22.04
steps: steps:
- -
name: Checkout name: Checkout
@ -46,7 +50,7 @@ jobs:
context: . context: .
file: ${{ inputs.dockerfile }} file: ${{ inputs.dockerfile }}
tags: ${{ fromJSON(inputs.build-json).image_tag }} tags: ${{ fromJSON(inputs.build-json).image_tag }}
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: ${{ inputs.build-platforms }}
build-args: ${{ inputs.build-args }} build-args: ${{ inputs.build-args }}
push: true push: true
cache-from: type=registry,ref=${{ fromJSON(inputs.build-json).cache_tag }} cache-from: type=registry,ref=${{ fromJSON(inputs.build-json).cache_tag }}

View File

@ -5,7 +5,7 @@
repos: repos:
# General hooks # General hooks
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0 rev: v4.4.0
hooks: hooks:
- id: check-docstring-first - id: check-docstring-first
- id: check-json - id: check-json
@ -48,23 +48,23 @@ repos:
- id: yesqa - id: yesqa
exclude: "(migrations)" exclude: "(migrations)"
- repo: https://github.com/asottile/add-trailing-comma - repo: https://github.com/asottile/add-trailing-comma
rev: "v2.3.0" rev: "v2.4.0"
hooks: hooks:
- id: add-trailing-comma - id: add-trailing-comma
exclude: "(migrations)" exclude: "(migrations)"
- repo: https://github.com/PyCQA/flake8 - repo: https://github.com/PyCQA/flake8
rev: 5.0.4 rev: 6.0.0
hooks: hooks:
- id: flake8 - id: flake8
files: ^src/ files: ^src/
args: args:
- "--config=./src/setup.cfg" - "--config=./src/setup.cfg"
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 22.10.0 rev: 22.12.0
hooks: hooks:
- id: black - id: black
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v3.2.2 rev: v3.3.1
hooks: hooks:
- id: pyupgrade - id: pyupgrade
exclude: "(migrations)" exclude: "(migrations)"
@ -83,6 +83,6 @@ repos:
args: args:
- "--tab" - "--tab"
- repo: https://github.com/shellcheck-py/shellcheck-py - repo: https://github.com/shellcheck-py/shellcheck-py
rev: "v0.8.0.4" rev: "v0.9.0.2"
hooks: hooks:
- id: shellcheck - id: shellcheck

View File

@ -45,7 +45,7 @@ COPY Pipfile* ./
RUN set -eux \ RUN set -eux \
&& echo "Installing pipenv" \ && echo "Installing pipenv" \
&& python3 -m pip install --no-cache-dir --upgrade pipenv \ && python3 -m pip install --no-cache-dir --upgrade pipenv==2022.11.30 \
&& echo "Generating requirement.txt" \ && echo "Generating requirement.txt" \
&& pipenv requirements > requirements.txt && pipenv requirements > requirements.txt
@ -58,6 +58,12 @@ LABEL org.opencontainers.image.url="https://github.com/paperless-ngx/paperless-n
LABEL org.opencontainers.image.licenses="GPL-3.0-only" LABEL org.opencontainers.image.licenses="GPL-3.0-only"
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
# Buildx provided
ARG TARGETARCH
ARG TARGETVARIANT
# Workflow provided
ARG QPDF_VERSION
# #
# Begin installation and configuration # Begin installation and configuration
@ -194,14 +200,10 @@ RUN --mount=type=bind,from=qpdf-builder,target=/qpdf \
--mount=type=bind,from=pikepdf-builder,target=/pikepdf \ --mount=type=bind,from=pikepdf-builder,target=/pikepdf \
set -eux \ set -eux \
&& echo "Installing qpdf" \ && echo "Installing qpdf" \
&& apt-get install --yes --no-install-recommends /qpdf/usr/src/qpdf/libqpdf29_*.deb \ && apt-get install --yes --no-install-recommends /qpdf/usr/src/qpdf/${QPDF_VERSION}/${TARGETARCH}${TARGETVARIANT}/libqpdf29_*.deb \
&& apt-get install --yes --no-install-recommends /qpdf/usr/src/qpdf/qpdf_*.deb \ && apt-get install --yes --no-install-recommends /qpdf/usr/src/qpdf/${QPDF_VERSION}/${TARGETARCH}${TARGETVARIANT}/qpdf_*.deb \
&& echo "Installing pikepdf and dependencies" \ && echo "Installing pikepdf and dependencies" \
&& python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/pyparsing*.whl \ && python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/*.whl \
&& python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/packaging*.whl \
&& python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/lxml*.whl \
&& python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/Pillow*.whl \
&& python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/pikepdf*.whl \
&& python3 -m pip list \ && python3 -m pip list \
&& echo "Installing psycopg2" \ && echo "Installing psycopg2" \
&& python3 -m pip install --no-cache-dir /psycopg2/usr/src/wheels/psycopg2*.whl \ && python3 -m pip install --no-cache-dir /psycopg2/usr/src/wheels/psycopg2*.whl \
@ -228,6 +230,10 @@ RUN set -eux \
&& python3 -m pip install --no-cache-dir --upgrade wheel \ && python3 -m pip install --no-cache-dir --upgrade wheel \
&& echo "Installing Python requirements" \ && echo "Installing Python requirements" \
&& python3 -m pip install --default-timeout=1000 --no-cache-dir --requirement requirements.txt \ && python3 -m pip install --default-timeout=1000 --no-cache-dir --requirement requirements.txt \
&& echo "Installing NLTK data" \
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/local/share/nltk_data" snowball_data \
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/local/share/nltk_data" stopwords \
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/local/share/nltk_data" punkt \
&& echo "Cleaning up image" \ && echo "Cleaning up image" \
&& apt-get -y purge ${BUILD_PACKAGES} \ && apt-get -y purge ${BUILD_PACKAGES} \
&& apt-get -y autoremove --purge \ && apt-get -y autoremove --purge \

21
Pipfile
View File

@ -30,8 +30,6 @@ psycopg2 = "*"
rapidfuzz = "*" rapidfuzz = "*"
redis = {extras = ["hiredis"], version = "*"} redis = {extras = ["hiredis"], version = "*"}
scikit-learn = "~=1.1" scikit-learn = "~=1.1"
# Pin this until piwheels is building 1.9 (see https://www.piwheels.org/project/scipy/)
scipy = "==1.8.1"
numpy = "*" numpy = "*"
whitenoise = "~=6.2" whitenoise = "~=6.2"
watchdog = "~=2.1" watchdog = "~=2.1"
@ -43,9 +41,6 @@ tika = "*"
# TODO: This will sadly also install daphne+dependencies, # TODO: This will sadly also install daphne+dependencies,
# which an ASGI server we don't need. Adds about 15MB image size. # which an ASGI server we don't need. Adds about 15MB image size.
channels = "~=3.0" channels = "~=3.0"
# Locked version until https://github.com/django/channels_redis/issues/332
# is resolved
channels-redis = "==3.4.1"
uvicorn = {extras = ["standard"], version = "*"} uvicorn = {extras = ["standard"], version = "*"}
concurrent-log-handler = "*" concurrent-log-handler = "*"
"pdfminer.six" = "*" "pdfminer.six" = "*"
@ -60,6 +55,21 @@ setproctitle = "*"
nltk = "*" nltk = "*"
pdf2image = "*" pdf2image = "*"
flower = "*" flower = "*"
bleach = "*"
#
# Packages locked due to issues (try to check if these are fixed in a release every so often)
#
# Pin this until piwheels is building 1.9 (see https://www.piwheels.org/project/scipy/)
scipy = "==1.8.1"
# Newer versions aren't builting yet (see https://www.piwheels.org/project/cryptography/)
cryptography = "==38.0.1"
# Locked version until https://github.com/django/channels_redis/issues/332
# is resolved
channels-redis = "==3.4.1"
[dev-packages] [dev-packages]
coveralls = "*" coveralls = "*"
@ -76,4 +86,5 @@ black = "*"
pre-commit = "*" pre-commit = "*"
sphinx-autobuild = "*" sphinx-autobuild = "*"
myst-parser = "*" myst-parser = "*"
imagehash = "*"
mkdocs-material = "*" mkdocs-material = "*"

1508
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,9 +10,9 @@
# Example Usage: # Example Usage:
# ./build-docker-image.sh Dockerfile -t paperless-ngx:my-awesome-feature # ./build-docker-image.sh Dockerfile -t paperless-ngx:my-awesome-feature
set -eux set -eu
if ! command -v jq; then if ! command -v jq &> /dev/null ; then
echo "jq required" echo "jq required"
exit 1 exit 1
elif [ ! -f "$1" ]; then elif [ ! -f "$1" ]; then
@ -20,28 +20,62 @@ elif [ ! -f "$1" ]; then
exit 1 exit 1
fi fi
# Parse what we can from Pipfile.lock
pikepdf_version=$(jq ".default.pikepdf.version" Pipfile.lock | sed 's/=//g' | sed 's/"//g')
psycopg2_version=$(jq ".default.psycopg2.version" Pipfile.lock | sed 's/=//g' | sed 's/"//g')
pillow_version=$(jq ".default.pillow.version" Pipfile.lock | sed 's/=//g' | sed 's/"//g')
lxml_version=$(jq ".default.lxml.version" Pipfile.lock | sed 's/=//g' | sed 's/"//g')
# Read this from the other config file
qpdf_version=$(jq ".qpdf.version" .build-config.json | sed 's/"//g')
jbig2enc_version=$(jq ".jbig2enc.version" .build-config.json | sed 's/"//g')
# Get the branch name (used for caching) # Get the branch name (used for caching)
branch_name=$(git rev-parse --abbrev-ref HEAD) branch_name=$(git rev-parse --abbrev-ref HEAD)
# https://docs.docker.com/develop/develop-images/build_enhancements/ # Parse eithe Pipfile.lock or the .build-config.json
# Required to use cache-from jbig2enc_version=$(jq ".jbig2enc.version" .build-config.json | sed 's/"//g')
export DOCKER_BUILDKIT=1 qpdf_version=$(jq ".qpdf.version" .build-config.json | sed 's/"//g')
psycopg2_version=$(jq ".default.psycopg2.version" Pipfile.lock | sed 's/=//g' | sed 's/"//g')
pikepdf_version=$(jq ".default.pikepdf.version" Pipfile.lock | sed 's/=//g' | sed 's/"//g')
pillow_version=$(jq ".default.pillow.version" Pipfile.lock | sed 's/=//g' | sed 's/"//g')
lxml_version=$(jq ".default.lxml.version" Pipfile.lock | sed 's/=//g' | sed 's/"//g')
docker build --file "$1" \ base_filename="$(basename -- "${1}")"
build_args_str=""
cache_from_str=""
case "${base_filename}" in
*.jbig2enc)
build_args_str="--build-arg JBIG2ENC_VERSION=${jbig2enc_version}"
cache_from_str="--cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/jbig2enc:${jbig2enc_version}"
;;
*.psycopg2)
build_args_str="--build-arg PSYCOPG2_VERSION=${psycopg2_version}"
cache_from_str="--cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/psycopg2:${psycopg2_version}"
;;
*.qpdf)
build_args_str="--build-arg QPDF_VERSION=${qpdf_version}"
cache_from_str="--cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/qpdf:${qpdf_version}"
;;
*.pikepdf)
build_args_str="--build-arg QPDF_VERSION=${qpdf_version} --build-arg PIKEPDF_VERSION=${pikepdf_version} --build-arg PILLOW_VERSION=${pillow_version} --build-arg LXML_VERSION=${lxml_version}"
cache_from_str="--cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/pikepdf:${pikepdf_version}"
;;
Dockerfile)
build_args_str="--build-arg QPDF_VERSION=${qpdf_version} --build-arg PIKEPDF_VERSION=${pikepdf_version} --build-arg PSYCOPG2_VERSION=${psycopg2_version} --build-arg JBIG2ENC_VERSION=${jbig2enc_version}"
cache_from_str="--cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/app:${branch_name} --cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/app:dev"
;;
*)
echo "Unable to match ${base_filename}"
exit 1
;;
esac
read -r -a build_args_arr <<< "${build_args_str}"
read -r -a cache_from_arr <<< "${cache_from_str}"
set -eux
docker buildx build --file "${1}" \
--progress=plain \ --progress=plain \
--cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/app:"${branch_name}" \ --output=type=docker \
--cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/app:dev \ "${cache_from_arr[@]}" \
--build-arg JBIG2ENC_VERSION="${jbig2enc_version}" \ "${build_args_arr[@]}" \
--build-arg QPDF_VERSION="${qpdf_version}" \ "${@:2}" .
--build-arg PIKEPDF_VERSION="${pikepdf_version}" \
--build-arg PILLOW_VERSION="${pillow_version}" \
--build-arg LXML_VERSION="${lxml_version}" \
--build-arg PSYCOPG2_VERSION="${psycopg2_version}" "${@:2}" .

View File

@ -16,7 +16,13 @@ FROM python:3.9-slim-bullseye as main
LABEL org.opencontainers.image.description="A intermediate image with pikepdf wheel built" LABEL org.opencontainers.image.description="A intermediate image with pikepdf wheel built"
# Buildx provided
ARG TARGETARCH
ARG TARGETVARIANT
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
# Workflow provided
ARG QPDF_VERSION
ARG PIKEPDF_VERSION ARG PIKEPDF_VERSION
# These are not used, but will still bust the cache if one changes # These are not used, but will still bust the cache if one changes
# Otherwise, the main image will try to build thing (and fail) # Otherwise, the main image will try to build thing (and fail)
@ -54,7 +60,7 @@ ARG BUILD_PACKAGES="\
WORKDIR /usr/src WORKDIR /usr/src
COPY --from=qpdf-builder /usr/src/qpdf/*.deb ./ COPY --from=qpdf-builder /usr/src/qpdf/${QPDF_VERSION}/${TARGETARCH}${TARGETVARIANT}/*.deb ./
# As this is an base image for a multi-stage final image # As this is an base image for a multi-stage final image
# the added size of the install is basically irrelevant # the added size of the install is basically irrelevant
@ -77,6 +83,8 @@ RUN set -eux \
&& python3 -m pip wheel \ && python3 -m pip wheel \
# Build the package at the required version # Build the package at the required version
pikepdf==${PIKEPDF_VERSION} \ pikepdf==${PIKEPDF_VERSION} \
# Look to piwheels for additional pre-built wheels
--extra-index-url https://www.piwheels.org/simple \
# Output the *.whl into this directory # Output the *.whl into this directory
--wheel-dir wheels \ --wheel-dir wheels \
# Do not use a binary packge for the package being built # Do not use a binary packge for the package being built
@ -86,6 +94,8 @@ RUN set -eux \
# Don't cache build files # Don't cache build files
--no-cache-dir \ --no-cache-dir \
&& ls -ahl wheels \ && ls -ahl wheels \
&& echo "Gathering package data" \
&& dpkg-query -f '${Package;-40}${Version}\n' -W > ./wheels/pkg-list.txt \
&& echo "Cleaning up image" \ && echo "Cleaning up image" \
&& apt-get -y purge ${BUILD_PACKAGES} \ && apt-get -y purge ${BUILD_PACKAGES} \
&& apt-get -y autoremove --purge \ && apt-get -y autoremove --purge \

View File

@ -42,6 +42,8 @@ RUN set -eux \
# Don't cache build files # Don't cache build files
--no-cache-dir \ --no-cache-dir \
&& ls -ahl wheels/ \ && ls -ahl wheels/ \
&& echo "Gathering package data" \
&& dpkg-query -f '${Package;-40}${Version}\n' -W > ./wheels/pkg-list.txt \
&& echo "Cleaning up image" \ && echo "Cleaning up image" \
&& apt-get -y purge ${BUILD_PACKAGES} \ && apt-get -y purge ${BUILD_PACKAGES} \
&& apt-get -y autoremove --purge \ && apt-get -y autoremove --purge \

View File

@ -1,48 +1,156 @@
# This Dockerfile compiles the jbig2enc library #
# Inputs: # Stage: pre-build
# - QPDF_VERSION - the version of qpdf to build a .deb. # Purpose:
# Must be present as a deb-src in bookworm # - Installs common packages
# - Sets common environment variables related to dpkg
# - Aquires the qpdf source from bookwork
# Useful Links:
# - https://qpdf.readthedocs.io/en/stable/installation.html#system-requirements
# - https://wiki.debian.org/Multiarch/HOWTO
# - https://wiki.debian.org/CrossCompiling
#
FROM debian:bullseye-slim as main FROM debian:bullseye-slim as pre-build
LABEL org.opencontainers.image.description="A intermediate image with qpdf built"
ARG DEBIAN_FRONTEND=noninteractive
# This must match to pikepdf's minimum at least
ARG QPDF_VERSION ARG QPDF_VERSION
ARG BUILD_PACKAGES="\ ARG COMMON_BUILD_PACKAGES="\
build-essential \ cmake \
debhelper \ debhelper\
debian-keyring \ debian-keyring \
devscripts \ devscripts \
equivs \ dpkg-dev \
libtool \ equivs \
# https://qpdf.readthedocs.io/en/stable/installation.html#system-requirements
libjpeg62-turbo-dev \
libgnutls28-dev \
packaging-dev \ packaging-dev \
cmake \ libtool"
zlib1g-dev"
ENV DEB_BUILD_OPTIONS="terse nocheck nodoc parallel=2"
WORKDIR /usr/src WORKDIR /usr/src
RUN set -eux \ RUN set -eux \
&& echo "Installing build tools" \ && echo "Installing common packages" \
&& apt-get update --quiet \ && apt-get update --quiet \
&& apt-get install --yes --quiet --no-install-recommends $BUILD_PACKAGES \ && apt-get install --yes --quiet --no-install-recommends ${COMMON_BUILD_PACKAGES} \
&& echo "Getting qpdf src" \ && echo "Getting qpdf source" \
&& echo "deb-src http://deb.debian.org/debian/ bookworm main" > /etc/apt/sources.list.d/bookworm-src.list \ && echo "deb-src http://deb.debian.org/debian/ bookworm main" > /etc/apt/sources.list.d/bookworm-src.list \
&& apt-get update \ && apt-get update --quiet \
&& mkdir qpdf \ && apt-get source --yes --quiet qpdf=${QPDF_VERSION}-1/bookworm
&& cd qpdf \
&& apt-get source --yes --quiet qpdf=${QPDF_VERSION}-1/bookworm \ #
&& echo "Building qpdf" \ # Stage: amd64-builder
&& cd qpdf-$QPDF_VERSION \ # Purpose: Builds qpdf for x86_64 (native build)
&& export DEB_BUILD_OPTIONS="terse nocheck nodoc parallel=2" \ #
&& dpkg-buildpackage --build=binary --unsigned-source --unsigned-changes --post-clean \ FROM pre-build as amd64-builder
&& ls -ahl ../*.deb \
&& echo "Cleaning up image" \ ARG AMD64_BUILD_PACKAGES="\
&& apt-get -y purge ${BUILD_PACKAGES} \ build-essential \
&& apt-get -y autoremove --purge \ libjpeg62-turbo-dev:amd64 \
&& rm -rf /var/lib/apt/lists/* libgnutls28-dev:amd64 \
zlib1g-dev:amd64"
WORKDIR /usr/src/qpdf-${QPDF_VERSION}
RUN set -eux \
&& echo "Beginning amd64" \
&& echo "Install amd64 packages" \
&& apt-get update --quiet \
&& apt-get install --yes --quiet --no-install-recommends ${AMD64_BUILD_PACKAGES} \
&& echo "Building amd64" \
&& dpkg-buildpackage --build=binary --unsigned-source --unsigned-changes --post-clean \
&& echo "Removing debug files" \
&& rm -f ../libqpdf29-dbgsym* \
&& rm -f ../qpdf-dbgsym* \
&& echo "Gathering package data" \
&& dpkg-query -f '${Package;-40}${Version}\n' -W > ../pkg-list.txt
#
# Stage: armhf-builder
# Purpose:
# - Sets armhf specific environment
# - Builds qpdf for armhf (cross compile)
#
FROM pre-build as armhf-builder
ARG ARMHF_PACKAGES="\
crossbuild-essential-armhf \
libjpeg62-turbo-dev:armhf \
libgnutls28-dev:armhf \
zlib1g-dev:armhf"
WORKDIR /usr/src/qpdf-${QPDF_VERSION}
ENV CXX="/usr/bin/arm-linux-gnueabihf-g++" \
CC="/usr/bin/arm-linux-gnueabihf-gcc"
RUN set -eux \
&& echo "Beginning armhf" \
&& echo "Install armhf packages" \
&& dpkg --add-architecture armhf \
&& apt-get update --quiet \
&& apt-get install --yes --quiet --no-install-recommends ${ARMHF_PACKAGES} \
&& echo "Building armhf" \
&& dpkg-buildpackage --build=binary --unsigned-source --unsigned-changes --post-clean --host-arch armhf \
&& echo "Removing debug files" \
&& rm -f ../libqpdf29-dbgsym* \
&& rm -f ../qpdf-dbgsym* \
&& echo "Gathering package data" \
&& dpkg-query -f '${Package;-40}${Version}\n' -W > ../pkg-list.txt
#
# Stage: aarch64-builder
# Purpose:
# - Sets aarch64 specific environment
# - Builds qpdf for aarch64 (cross compile)
#
FROM pre-build as aarch64-builder
ARG ARM64_PACKAGES="\
crossbuild-essential-arm64 \
libjpeg62-turbo-dev:arm64 \
libgnutls28-dev:arm64 \
zlib1g-dev:arm64"
ENV CXX="/usr/bin/aarch64-linux-gnu-g++" \
CC="/usr/bin/aarch64-linux-gnu-gcc"
WORKDIR /usr/src/qpdf-${QPDF_VERSION}
RUN set -eux \
&& echo "Beginning arm64" \
&& echo "Install arm64 packages" \
&& dpkg --add-architecture arm64 \
&& apt-get update --quiet \
&& apt-get install --yes --quiet --no-install-recommends ${ARM64_PACKAGES} \
&& echo "Building arm64" \
&& dpkg-buildpackage --build=binary --unsigned-source --unsigned-changes --post-clean --host-arch arm64 \
&& echo "Removing debug files" \
&& rm -f ../libqpdf29-dbgsym* \
&& rm -f ../qpdf-dbgsym* \
&& echo "Gathering package data" \
&& dpkg-query -f '${Package;-40}${Version}\n' -W > ../pkg-list.txt
#
# Stage: package
# Purpose: Holds the compiled .deb files in arch/variant specific folders
#
FROM alpine:3.17 as package
LABEL org.opencontainers.image.description="A image with qpdf installers stored in architecture & version specific folders"
ARG QPDF_VERSION
WORKDIR /usr/src/qpdf/${QPDF_VERSION}/amd64
COPY --from=amd64-builder /usr/src/*.deb ./
COPY --from=amd64-builder /usr/src/pkg-list.txt ./
# Note this is ${TARGETARCH}${TARGETVARIANT} for armv7
WORKDIR /usr/src/qpdf/${QPDF_VERSION}/armv7
COPY --from=armhf-builder /usr/src/*.deb ./
COPY --from=armhf-builder /usr/src/pkg-list.txt ./
WORKDIR /usr/src/qpdf/${QPDF_VERSION}/arm64
COPY --from=aarch64-builder /usr/src/*.deb ./
COPY --from=aarch64-builder /usr/src/pkg-list.txt ./

View File

@ -11,9 +11,12 @@ services:
container_name: gotenberg container_name: gotenberg
network_mode: host network_mode: host
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.
command: command:
- "gotenberg" - "gotenberg"
- "--chromium-disable-routes=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*"
tika: tika:
image: ghcr.io/paperless-ngx/tika:latest image: ghcr.io/paperless-ngx/tika:latest
hostname: tika hostname: tika

View File

@ -85,9 +85,12 @@ services:
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:7.6 image: docker.io/gotenberg/gotenberg:7.6
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.
command: command:
- "gotenberg" - "gotenberg"
- "--chromium-disable-routes=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*"
tika: tika:
image: ghcr.io/paperless-ngx/tika:latest image: ghcr.io/paperless-ngx/tika:latest

View File

@ -79,9 +79,13 @@ services:
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:7.6 image: docker.io/gotenberg/gotenberg:7.6
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.
command: command:
- "gotenberg" - "gotenberg"
- "--chromium-disable-routes=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*"
tika: tika:
image: ghcr.io/paperless-ngx/tika:latest image: ghcr.io/paperless-ngx/tika:latest

View File

@ -67,9 +67,13 @@ services:
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:7.6 image: docker.io/gotenberg/gotenberg:7.6
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.
command: command:
- "gotenberg" - "gotenberg"
- "--chromium-disable-routes=true" - "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*"
tika: tika:
image: ghcr.io/paperless-ngx/tika:latest image: ghcr.io/paperless-ngx/tika:latest

View File

@ -53,30 +53,6 @@ map_folders() {
export CONSUME_DIR="${PAPERLESS_CONSUMPTION_DIR:-/usr/src/paperless/consume}" export CONSUME_DIR="${PAPERLESS_CONSUMPTION_DIR:-/usr/src/paperless/consume}"
} }
nltk_data () {
# Store the NLTK data outside the Docker container
local -r nltk_data_dir="${DATA_DIR}/nltk"
local -r truthy_things=("yes y 1 t true")
# If not set, or it looks truthy
if [[ -z "${PAPERLESS_ENABLE_NLTK}" ]] || [[ "${truthy_things[*]}" =~ ${PAPERLESS_ENABLE_NLTK,} ]]; then
# Download or update the snowball stemmer data
python3 -W ignore::RuntimeWarning -m nltk.downloader -d "${nltk_data_dir}" snowball_data
# Download or update the stopwords corpus
python3 -W ignore::RuntimeWarning -m nltk.downloader -d "${nltk_data_dir}" stopwords
# Download or update the punkt tokenizer data
python3 -W ignore::RuntimeWarning -m nltk.downloader -d "${nltk_data_dir}" punkt
else
echo "Skipping NLTK data download"
fi
}
custom_container_init() { custom_container_init() {
# Mostly borrowed from the LinuxServer.io base image # Mostly borrowed from the LinuxServer.io base image
# https://github.com/linuxserver/docker-baseimage-ubuntu/tree/bionic/root/etc/cont-init.d # https://github.com/linuxserver/docker-baseimage-ubuntu/tree/bionic/root/etc/cont-init.d
@ -157,8 +133,6 @@ initialize() {
echo "Creating directory ${tmp_dir}" echo "Creating directory ${tmp_dir}"
mkdir -p "${tmp_dir}" mkdir -p "${tmp_dir}"
nltk_data
set +e set +e
echo "Adjusting permissions of paperless files. This may take a while." echo "Adjusting permissions of paperless files. This may take a while."
chown -R paperless:paperless ${tmp_dir} chown -R paperless:paperless ${tmp_dir}
@ -191,10 +165,6 @@ install_languages() {
for lang in "${langs[@]}"; do for lang in "${langs[@]}"; do
pkg="tesseract-ocr-$lang" pkg="tesseract-ocr-$lang"
# English is installed by default
#if [[ "$lang" == "eng" ]]; then
# continue
#fi
if dpkg -s "$pkg" &>/dev/null; then if dpkg -s "$pkg" &>/dev/null; then
echo "Package $pkg already installed!" echo "Package $pkg already installed!"

View File

@ -20,7 +20,6 @@ wait_for_postgres() {
exit 1 exit 1
else else
echo "Attempt $attempt_num failed! Trying again in 5 seconds..." echo "Attempt $attempt_num failed! Trying again in 5 seconds..."
fi fi
attempt_num=$(("$attempt_num" + 1)) attempt_num=$(("$attempt_num" + 1))
@ -37,6 +36,8 @@ wait_for_mariadb() {
local attempt_num=1 local attempt_num=1
local -r max_attempts=5 local -r max_attempts=5
# Disable warning, host and port can't have spaces
# shellcheck disable=SC2086
while ! true > /dev/tcp/$host/$port; do while ! true > /dev/tcp/$host/$port; do
if [ $attempt_num -eq $max_attempts ]; then if [ $attempt_num -eq $max_attempts ]; then
@ -67,10 +68,16 @@ migrations() {
# of the current container starts. # of the current container starts.
flock 200 flock 200
echo "Apply database migrations..." echo "Apply database migrations..."
python3 manage.py migrate python3 manage.py migrate --skip-checks --no-input
) 200>"${DATA_DIR}/migration_lock" ) 200>"${DATA_DIR}/migration_lock"
} }
django_checks() {
# Explicitly run the Django system checks
echo "Running Django checks"
python3 manage.py check
}
search_index() { search_index() {
local -r index_version=1 local -r index_version=1
@ -100,6 +107,8 @@ do_work() {
migrations migrations
django_checks
search_index search_index
superuser superuser

View File

@ -233,6 +233,7 @@ optional arguments:
-c, --compare-checksums -c, --compare-checksums
-f, --use-filename-format -f, --use-filename-format
-d, --delete -d, --delete
-z --zip
``` ```
`target` is a folder to which the data gets written. This includes `target` is a folder to which the data gets written. This includes
@ -258,6 +259,9 @@ current export such as files from deleted documents, specify `--delete`.
Be careful when pointing paperless to a directory that already contains Be careful when pointing paperless to a directory that already contains
other files. other files.
If `-z` or `--zip` is provided, the export will be a zipfile
in the target directory, named according to the current date.
The filenames generated by this command follow the format The filenames generated by this command follow the format
`[date created] [correspondent] [title].[extension]`. If you want `[date created] [correspondent] [title].[extension]`. If you want
paperless to use `PAPERLESS_FILENAME_FORMAT` for exported filenames paperless to use `PAPERLESS_FILENAME_FORMAT` for exported filenames

View File

@ -10,12 +10,10 @@ run paperless, these settings have to be defined in different places.
- If you are running paperless on anything else, paperless will search - If you are running paperless on anything else, paperless will search
for the configuration file in these locations and use the first one for the configuration file in these locations and use the first one
it finds: it finds:
- The environment variable `PAPERLESS_CONFIGURATION_PATH`
``` - `/path/to/paperless/paperless.conf`
/path/to/paperless/paperless.conf - `/etc/paperless.conf`
/etc/paperless.conf - `/usr/local/etc/paperless.conf`
/usr/local/etc/paperless.conf
```
## Required services ## Required services
@ -170,6 +168,19 @@ details.
Defaults to `PAPERLESS_DATA_DIR/log/`. Defaults to `PAPERLESS_DATA_DIR/log/`.
`PAPERLESS_NLTK_DIR=<path>`
: This is where paperless will search for the data required for NLTK
processing, if you are using it. If you are using the Docker image,
this should not be changed, as the data is included in the image
already.
Previously, the location defaulted to `PAPERLESS_DATA_DIR/nltk`.
Unless you are using this in a bare metal install or other setup,
this folder is no longer needed and can be removed manually.
Defaults to `/usr/local/share/nltk_data`
## Logging ## Logging
`PAPERLESS_LOGROTATE_MAX_SIZE=<num>` `PAPERLESS_LOGROTATE_MAX_SIZE=<num>`
@ -564,8 +575,10 @@ they use underscores instead of dashes.
Paperless can make use of [Tika](https://tika.apache.org/) and Paperless can make use of [Tika](https://tika.apache.org/) and
[Gotenberg](https://gotenberg.dev/) for parsing and converting [Gotenberg](https://gotenberg.dev/) for parsing and converting
"Office" documents (such as ".doc", ".xlsx" and ".odt"). If you "Office" documents (such as ".doc", ".xlsx" and ".odt").
wish to use this, you must provide a Tika server and a Gotenberg server, Tika and Gotenberg are also needed to allow parsing of E-Mails (.eml).
If you wish to use this, you must provide a Tika server and a Gotenberg server,
configure their endpoints, and enable the feature. configure their endpoints, and enable the feature.
`PAPERLESS_TIKA_ENABLED=<bool>` `PAPERLESS_TIKA_ENABLED=<bool>`
@ -604,14 +617,17 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
# ... # ...
gotenberg: gotenberg:
image: gotenberg/gotenberg:7.6 image: gotenberg/gotenberg:7.6
restart: unless-stopped restart: unless-stopped
command: # The gotenberg chromium route is used to convert .eml files. We do not
- 'gotenberg' # want to allow external content like tracking pixels or even javascript.
- '--chromium-disable-routes=true' command:
- 'gotenberg'
- '--chromium-disable-javascript=true'
- '--chromium-allow-list=file:///tmp/.*'
tika: tika:
image: ghcr.io/paperless-ngx/tika:latest image: ghcr.io/paperless-ngx/tika:latest
@ -658,7 +674,7 @@ paperless will process in parallel on a single document.
count, with a slight favor towards threads per worker: count, with a slight favor towards threads per worker:
| CPU core count | Workers | Threads | | CPU core count | Workers | Threads |
|----------------|---------|---------| | -------------- | ------- | ------- |
| > 1 | > 1 | > 1 | | > 1 | > 1 | > 1 |
| > 2 | > 2 | > 1 | | > 2 | > 2 | > 1 |
| > 4 | > 2 | > 2 | | > 4 | > 2 | > 2 |
@ -691,6 +707,16 @@ for details on how to set it.
Defaults to UTC. Defaults to UTC.
`PAPERLESS_ENABLE_NLTK=<bool>`
: Enables or disables the advanced natural language processing
used during automatic classification. If disabled, paperless will
still preform some basic text pre-processing before matching.
See also `PAPERLESS_NLTK_DIR`.
Defaults to 1.
## Polling {#polling} ## Polling {#polling}
`PAPERLESS_CONSUMER_POLLING=<num>` `PAPERLESS_CONSUMER_POLLING=<num>`

View File

@ -125,13 +125,13 @@ using docker-compose, this is achieved by the following configuration
change in the `docker-compose.yml` file: change in the `docker-compose.yml` file:
```yaml ```yaml
gotenberg: # The gotenberg chromium route is used to convert .eml files. We do not
image: gotenberg/gotenberg:7.6 # want to allow external content like tracking pixels or even javascript.
restart: unless-stopped command:
command: - 'gotenberg'
- 'gotenberg' - '--chromium-disable-javascript=true'
- '--chromium-disable-routes=true' - '--chromium-allow-list=file:///tmp/.*'
- '--api-timeout=60' - '--api-timeout=60'
``` ```
## Permission denied errors in the consumption directory ## Permission denied errors in the consumption directory

View File

@ -2,5 +2,5 @@
docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:13 docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:13
docker run -d -p 6379:6379 redis:latest docker run -d -p 6379:6379 redis:latest
docker run -p 3000:3000 -d gotenberg/gotenberg:7.6 docker run -p 3000:3000 -d gotenberg/gotenberg:7.6 gotenberg --chromium-disable-javascript=true --chromium-allow-list="file:///tmp/.*"
docker run -p 9998:9998 -d ghcr.io/paperless-ngx/tika:latest docker run -p 9998:9998 -d ghcr.io/paperless-ngx/tika:latest

51
src-ui/.eslintrc.json Normal file
View 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": {}
}
]
}

View File

@ -1,179 +1,196 @@
{ {
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1, "version": 1,
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
"paperless-ui": { "paperless-ui": {
"projectType": "application", "projectType": "application",
"schematics": { "schematics": {
"@schematics/angular:component": { "@schematics/angular:component": {
"style": "scss" "style": "scss"
} }
}, },
"root": "", "root": "",
"sourceRoot": "src", "sourceRoot": "src",
"prefix": "app", "prefix": "app",
"i18n": { "i18n": {
"sourceLocale": "en-US", "sourceLocale": "en-US",
"locales": { "locales": {
"be-BY": "src/locale/messages.be_BY.xlf", "be-BY": "src/locale/messages.be_BY.xlf",
"cs-CZ": "src/locale/messages.cs_CZ.xlf", "cs-CZ": "src/locale/messages.cs_CZ.xlf",
"da-DK": "src/locale/messages.da_DK.xlf", "da-DK": "src/locale/messages.da_DK.xlf",
"de-DE": "src/locale/messages.de_DE.xlf", "de-DE": "src/locale/messages.de_DE.xlf",
"en-GB": "src/locale/messages.en_GB.xlf", "en-GB": "src/locale/messages.en_GB.xlf",
"es-ES": "src/locale/messages.es_ES.xlf", "es-ES": "src/locale/messages.es_ES.xlf",
"fr-FR": "src/locale/messages.fr_FR.xlf", "fr-FR": "src/locale/messages.fr_FR.xlf",
"it-IT": "src/locale/messages.it_IT.xlf", "it-IT": "src/locale/messages.it_IT.xlf",
"lb-LU": "src/locale/messages.lb_LU.xlf", "lb-LU": "src/locale/messages.lb_LU.xlf",
"nl-NL": "src/locale/messages.nl_NL.xlf", "nl-NL": "src/locale/messages.nl_NL.xlf",
"pl-PL": "src/locale/messages.pl_PL.xlf", "pl-PL": "src/locale/messages.pl_PL.xlf",
"pt-BR": "src/locale/messages.pt_BR.xlf", "pt-BR": "src/locale/messages.pt_BR.xlf",
"pt-PT": "src/locale/messages.pt_PT.xlf", "pt-PT": "src/locale/messages.pt_PT.xlf",
"ro-RO": "src/locale/messages.ro_RO.xlf", "ro-RO": "src/locale/messages.ro_RO.xlf",
"ru-RU": "src/locale/messages.ru_RU.xlf", "ru-RU": "src/locale/messages.ru_RU.xlf",
"sl-SI": "src/locale/messages.sl_SI.xlf", "sl-SI": "src/locale/messages.sl_SI.xlf",
"sr-CS": "src/locale/messages.sr_CS.xlf", "sr-CS": "src/locale/messages.sr_CS.xlf",
"sv-SE": "src/locale/messages.sv_SE.xlf", "sv-SE": "src/locale/messages.sv_SE.xlf",
"tr-TR": "src/locale/messages.tr_TR.xlf", "tr-TR": "src/locale/messages.tr_TR.xlf",
"zh-CN": "src/locale/messages.zh_CN.xlf" "zh-CN": "src/locale/messages.zh_CN.xlf"
} }
}, },
"architect": { "architect": {
"build": { "build": {
"builder": "@angular-devkit/build-angular:browser", "builder": "@angular-devkit/build-angular:browser",
"options": { "options": {
"outputPath": "dist/paperless-ui", "outputPath": "dist/paperless-ui",
"outputHashing": "none", "outputHashing": "none",
"index": "src/index.html", "index": "src/index.html",
"main": "src/main.ts", "main": "src/main.ts",
"polyfills": "src/polyfills.ts", "polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"localize": true, "localize": true,
"assets": [ "assets": [
"src/favicon.ico", "src/favicon.ico",
"src/apple-touch-icon.png", "src/apple-touch-icon.png",
"src/assets", "src/assets",
"src/manifest.webmanifest", { "src/manifest.webmanifest",
"glob": "pdf.worker.min.js", {
"input": "node_modules/pdfjs-dist/build/", "glob": "pdf.worker.min.js",
"output": "/assets/js/" "input": "node_modules/pdfjs-dist/build/",
} "output": "/assets/js/"
], }
"styles": [ ],
"src/styles.scss" "styles": [
], "src/styles.scss"
"scripts": [], ],
"allowedCommonJsDependencies": [ "scripts": [],
"ng2-pdf-viewer" "allowedCommonJsDependencies": [
], "ng2-pdf-viewer"
"vendorChunk": true, ],
"extractLicenses": false, "vendorChunk": true,
"buildOptimizer": false, "extractLicenses": false,
"sourceMap": true, "buildOptimizer": false,
"optimization": false, "sourceMap": true,
"namedChunks": true "optimization": false,
}, "namedChunks": true
"configurations": { },
"production": { "configurations": {
"fileReplacements": [ "production": {
{ "fileReplacements": [
"replace": "src/environments/environment.ts", {
"with": "src/environments/environment.prod.ts" "replace": "src/environments/environment.ts",
} "with": "src/environments/environment.prod.ts"
], }
"outputPath": "../src/documents/static/frontend/", ],
"optimization": true, "outputPath": "../src/documents/static/frontend/",
"outputHashing": "none", "optimization": true,
"sourceMap": false, "outputHashing": "none",
"namedChunks": false, "sourceMap": false,
"extractLicenses": true, "namedChunks": false,
"vendorChunk": false, "extractLicenses": true,
"buildOptimizer": true, "vendorChunk": false,
"budgets": [ "buildOptimizer": true,
{ "budgets": [
"type": "initial", {
"maximumWarning": "2mb", "type": "initial",
"maximumError": "5mb" "maximumWarning": "2mb",
}, "maximumError": "5mb"
{ },
"type": "anyComponentStyle", {
"maximumWarning": "6kb", "type": "anyComponentStyle",
"maximumError": "10kb" "maximumWarning": "6kb",
} "maximumError": "10kb"
] }
}, ]
"en-US": { },
"localize": ["en-US"] "en-US": {
} "localize": [
}, "en-US"
"defaultConfiguration": "" ]
}, }
"serve": { },
"builder": "@angular-devkit/build-angular:dev-server", "defaultConfiguration": ""
"options": { },
"browserTarget": "paperless-ui:build:en-US" "serve": {
}, "builder": "@angular-devkit/build-angular:dev-server",
"configurations": { "options": {
"production": { "browserTarget": "paperless-ui:build:en-US"
"browserTarget": "paperless-ui:build:production" },
} "configurations": {
} "production": {
}, "browserTarget": "paperless-ui:build:production"
"extract-i18n": { }
"builder": "@angular-devkit/build-angular:extract-i18n", }
"options": { },
"browserTarget": "paperless-ui:build" "extract-i18n": {
} "builder": "@angular-devkit/build-angular:extract-i18n",
}, "options": {
"test": { "browserTarget": "paperless-ui:build"
"builder": "@angular-builders/jest:run", }
"options": { },
"tsConfig": "tsconfig.spec.json", "test": {
"assets": [ "builder": "@angular-builders/jest:run",
"src/favicon.ico", "options": {
"src/apple-touch-icon.png", "tsConfig": "tsconfig.spec.json",
"src/assets", "assets": [
"src/manifest.webmanifest" "src/favicon.ico",
], "src/apple-touch-icon.png",
"styles": [ "src/assets",
"src/styles.scss" "src/manifest.webmanifest"
], ],
"scripts": [] "styles": [
} "src/styles.scss"
}, ],
"e2e": { "scripts": []
"builder": "@cypress/schematic:cypress", }
"options": { },
"devServerTarget": "paperless-ui:serve", "e2e": {
"watch": true, "builder": "@cypress/schematic:cypress",
"headless": false "options": {
}, "devServerTarget": "paperless-ui:serve",
"configurations": { "watch": true,
"production": { "headless": false
"devServerTarget": "paperless-ui:serve:production" },
} "configurations": {
} "production": {
}, "devServerTarget": "paperless-ui:serve:production"
"cypress-run": { }
"builder": "@cypress/schematic:cypress", }
"options": { },
"devServerTarget": "paperless-ui:serve" "cypress-run": {
}, "builder": "@cypress/schematic:cypress",
"configurations": { "options": {
"production": { "devServerTarget": "paperless-ui:serve"
"devServerTarget": "paperless-ui:serve:production" },
} "configurations": {
} "production": {
}, "devServerTarget": "paperless-ui:serve:production"
"cypress-open": { }
"builder": "@cypress/schematic:cypress", }
"options": { },
"watch": true, "cypress-open": {
"headless": false "builder": "@cypress/schematic:cypress",
} "options": {
} "watch": true,
} "headless": false
} }
}, },
"defaultProject": "paperless-ui" "lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
},
"defaultProject": "paperless-ui",
"cli": {
"schematicCollections": [
"@angular-eslint/schematics"
]
}
} }

View File

@ -35,6 +35,16 @@ describe('settings', () => {
req.reply(response) req.reply(response)
} }
).as('savedViews') ).as('savedViews')
cy.intercept('http://localhost:8000/api/mail_accounts/*', {
fixture: 'mail_accounts/mail_accounts.json',
})
cy.intercept('http://localhost:8000/api/mail_rules/*', {
fixture: 'mail_rules/mail_rules.json',
}).as('mailRules')
cy.intercept('http://localhost:8000/api/tasks/', {
fixture: 'tasks/tasks.json',
})
}) })
cy.fixture('documents/documents.json').then((documentsJson) => { cy.fixture('documents/documents.json').then((documentsJson) => {
@ -48,7 +58,6 @@ describe('settings', () => {
cy.viewport(1024, 1600) cy.viewport(1024, 1600)
cy.visit('/settings') cy.visit('/settings')
cy.wait('@savedViews')
}) })
it('should activate / deactivate save button when settings change and are saved', () => { it('should activate / deactivate save button when settings change and are saved', () => {
@ -64,7 +73,7 @@ describe('settings', () => {
cy.contains('a', 'Dashboard').click() cy.contains('a', 'Dashboard').click()
cy.contains('You have unsaved changes') cy.contains('You have unsaved changes')
cy.contains('button', 'Cancel').click() cy.contains('button', 'Cancel').click()
cy.contains('button', 'Save').click().wait('@savedViews') cy.contains('button', 'Save').click().wait('@savedViews').wait(2000)
cy.contains('a', 'Dashboard').click() cy.contains('a', 'Dashboard').click()
cy.contains('You have unsaved changes').should('not.exist') cy.contains('You have unsaved changes').should('not.exist')
}) })
@ -77,16 +86,16 @@ describe('settings', () => {
}) })
it('should remove saved view from sidebar when unset', () => { it('should remove saved view from sidebar when unset', () => {
cy.contains('a', 'Saved views').click() cy.contains('a', 'Saved views').click().wait(2000)
cy.get('#show_in_sidebar_1').click() cy.get('#show_in_sidebar_1').click()
cy.contains('button', 'Save').click().wait('@savedViews') cy.contains('button', 'Save').click().wait('@savedViews').wait(2000)
cy.contains('li', 'Inbox').should('not.exist') cy.contains('li', 'Inbox').should('not.exist')
}) })
it('should remove saved view from dashboard when unset', () => { it('should remove saved view from dashboard when unset', () => {
cy.contains('a', 'Saved views').click() cy.contains('a', 'Saved views').click()
cy.get('#show_on_dashboard_1').click() cy.get('#show_on_dashboard_1').click()
cy.contains('button', 'Save').click().wait('@savedViews') cy.contains('button', 'Save').click().wait('@savedViews').wait(2000)
cy.visit('/dashboard') cy.visit('/dashboard')
cy.get('app-saved-view-widget').contains('Inbox').should('not.exist') cy.get('app-saved-view-widget').contains('Inbox').should('not.exist')
}) })

View 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"
}
]
}

View 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
}
]
}

View File

@ -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"
}
]
}
]
}

File diff suppressed because it is too large Load Diff

3239
src-ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -40,17 +40,23 @@
"devDependencies": { "devDependencies": {
"@angular-builders/jest": "14.1.0", "@angular-builders/jest": "14.1.0",
"@angular-devkit/build-angular": "~14.2.7", "@angular-devkit/build-angular": "~14.2.7",
"@angular-eslint/builder": "14.4.0",
"@angular-eslint/eslint-plugin": "14.4.0",
"@angular-eslint/eslint-plugin-template": "14.4.0",
"@angular-eslint/schematics": "14.4.0",
"@angular-eslint/template-parser": "14.4.0",
"@angular/cli": "~14.2.7", "@angular/cli": "~14.2.7",
"@angular/compiler-cli": "~14.2.8", "@angular/compiler-cli": "~14.2.8",
"@types/jest": "28.1.6", "@types/jest": "28.1.6",
"@types/node": "^18.7.23", "@types/node": "^18.7.23",
"codelyzer": "^6.0.2", "@typescript-eslint/eslint-plugin": "5.43.0",
"@typescript-eslint/parser": "5.43.0",
"concurrently": "7.4.0", "concurrently": "7.4.0",
"eslint": "^8.28.0",
"jest": "28.1.3", "jest": "28.1.3",
"jest-environment-jsdom": "^29.2.2", "jest-environment-jsdom": "^29.2.2",
"jest-preset-angular": "^12.2.3", "jest-preset-angular": "^12.2.3",
"ts-node": "~10.9.1", "ts-node": "~10.9.1",
"tslint": "~6.1.3",
"typescript": "~4.8.4", "typescript": "~4.8.4",
"wait-on": "~6.0.1" "wait-on": "~6.0.1"
}, },

View File

@ -47,6 +47,11 @@ const routes: Routes = [
component: SettingsComponent, component: SettingsComponent,
canDeactivate: [DirtyFormGuard], canDeactivate: [DirtyFormGuard],
}, },
{
path: 'settings/:section',
component: SettingsComponent,
canDeactivate: [DirtyFormGuard],
},
{ path: 'tasks', component: TasksComponent }, { path: 'tasks', component: TasksComponent },
], ],
}, },

View File

@ -191,21 +191,13 @@ export class AppComponent implements OnInit, OnDestroy {
}, },
{ {
anchorId: 'tour.settings', anchorId: 'tour.settings',
content: $localize`Check out the settings for various tweaks to the web app or to toggle settings for saved views.`, content: $localize`Check out the settings for various tweaks to the web app, toggle settings for saved views or setup e-mail checking.`,
route: '/settings', route: '/settings',
enableBackdrop: true, enableBackdrop: true,
prevBtnTitle, prevBtnTitle,
nextBtnTitle, nextBtnTitle,
endBtnTitle, endBtnTitle,
}, },
{
anchorId: 'tour.admin',
content: $localize`The Admin area contains more advanced controls as well as the settings for automatic e-mail fetching.`,
enableBackdrop: true,
prevBtnTitle,
nextBtnTitle,
endBtnTitle,
},
{ {
anchorId: 'tour.outro', anchorId: 'tour.outro',
title: $localize`Thank you! 🙏`, title: $localize`Thank you! 🙏`,

View File

@ -24,7 +24,7 @@ import { CorrespondentEditDialogComponent } from './components/common/edit-dialo
import { TagEditDialogComponent } from './components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { TagEditDialogComponent } from './components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from './components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' import { DocumentTypeEditDialogComponent } from './components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { TagComponent } from './components/common/tag/tag.component' import { TagComponent } from './components/common/tag/tag.component'
import { ClearableBadge } from './components/common/clearable-badge/clearable-badge.component' import { ClearableBadgeComponent } from './components/common/clearable-badge/clearable-badge.component'
import { PageHeaderComponent } from './components/common/page-header/page-header.component' import { PageHeaderComponent } from './components/common/page-header/page-header.component'
import { AppFrameComponent } from './components/app-frame/app-frame.component' import { AppFrameComponent } from './components/app-frame/app-frame.component'
import { ToastsComponent } from './components/common/toasts/toasts.component' import { ToastsComponent } from './components/common/toasts/toasts.component'
@ -39,6 +39,7 @@ import { NgxFileDropModule } from 'ngx-file-drop'
import { TextComponent } from './components/common/input/text/text.component' import { TextComponent } from './components/common/input/text/text.component'
import { SelectComponent } from './components/common/input/select/select.component' import { SelectComponent } from './components/common/input/select/select.component'
import { CheckComponent } from './components/common/input/check/check.component' import { CheckComponent } from './components/common/input/check/check.component'
import { PasswordComponent } from './components/common/input/password/password.component'
import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component' import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component'
import { TagsComponent } from './components/common/input/tags/tags.component' import { TagsComponent } from './components/common/input/tags/tags.component'
import { SortableDirective } from './directives/sortable.directive' import { SortableDirective } from './directives/sortable.directive'
@ -76,6 +77,8 @@ import { StoragePathEditDialogComponent } from './components/common/edit-dialog/
import { SettingsService } from './services/settings.service' import { SettingsService } from './services/settings.service'
import { TasksComponent } from './components/manage/tasks/tasks.component' import { TasksComponent } from './components/manage/tasks/tasks.component'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap' import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { MailAccountEditDialogComponent } from './components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
import { MailRuleEditDialogComponent } from './components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
import localeBe from '@angular/common/locales/be' import localeBe from '@angular/common/locales/be'
import localeCs from '@angular/common/locales/cs' import localeCs from '@angular/common/locales/cs'
@ -143,7 +146,7 @@ function initializeApp(settings: SettingsService) {
DocumentTypeEditDialogComponent, DocumentTypeEditDialogComponent,
StoragePathEditDialogComponent, StoragePathEditDialogComponent,
TagComponent, TagComponent,
ClearableBadge, ClearableBadgeComponent,
PageHeaderComponent, PageHeaderComponent,
AppFrameComponent, AppFrameComponent,
ToastsComponent, ToastsComponent,
@ -157,6 +160,7 @@ function initializeApp(settings: SettingsService) {
TextComponent, TextComponent,
SelectComponent, SelectComponent,
CheckComponent, CheckComponent,
PasswordComponent,
SaveViewConfigDialogComponent, SaveViewConfigDialogComponent,
TagsComponent, TagsComponent,
SortableDirective, SortableDirective,
@ -180,6 +184,8 @@ function initializeApp(settings: SettingsService) {
DocumentAsnComponent, DocumentAsnComponent,
DocumentCommentsComponent, DocumentCommentsComponent,
TasksComponent, TasksComponent,
MailAccountEditDialogComponent,
MailRuleEditDialogComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

View File

@ -174,13 +174,6 @@
</svg><span>&nbsp;<ng-container i18n>Settings</ng-container></span> </svg><span>&nbsp;<ng-container i18n>Settings</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" tourAnchor="tour.admin">
<a class="nav-link" href="admin/" ngbPopover="Admin" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#toggles"/>
</svg><span>&nbsp;<ng-container i18n>Admin</ng-container></span>
</a>
</li>
</ul> </ul>
<h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted"> <h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted">

View File

@ -220,6 +220,12 @@ main {
font-size: 1rem; font-size: 1rem;
} }
@media screen and (min-width: 768px) {
.navbar-brand.slim {
max-width: 50px;
}
}
.dropdown.show .dropdown-toggle, .dropdown.show .dropdown-toggle,
.dropdown-toggle:hover { .dropdown-toggle:hover {
opacity: 0.7; opacity: 0.7;

View File

@ -5,7 +5,7 @@ import { Component, Input, Output, EventEmitter } from '@angular/core'
templateUrl: './clearable-badge.component.html', templateUrl: './clearable-badge.component.html',
styleUrls: ['./clearable-badge.component.scss'], styleUrls: ['./clearable-badge.component.scss'],
}) })
export class ClearableBadge { export class ClearableBadgeComponent {
constructor() {} constructor() {}
@Input() @Input()

View File

@ -5,7 +5,6 @@ import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { ToastService } from 'src/app/services/toast.service'
@Component({ @Component({
selector: 'app-correspondent-edit-dialog', selector: 'app-correspondent-edit-dialog',
@ -13,12 +12,8 @@ import { ToastService } from 'src/app/services/toast.service'
styleUrls: ['./correspondent-edit-dialog.component.scss'], styleUrls: ['./correspondent-edit-dialog.component.scss'],
}) })
export class CorrespondentEditDialogComponent extends EditDialogComponent<PaperlessCorrespondent> { export class CorrespondentEditDialogComponent extends EditDialogComponent<PaperlessCorrespondent> {
constructor( constructor(service: CorrespondentService, activeModal: NgbActiveModal) {
service: CorrespondentService, super(service, activeModal)
activeModal: NgbActiveModal,
toastService: ToastService
) {
super(service, activeModal, toastService)
} }
getCreateTitle() { getCreateTitle() {

View File

@ -5,7 +5,6 @@ import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type' import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { ToastService } from 'src/app/services/toast.service'
@Component({ @Component({
selector: 'app-document-type-edit-dialog', selector: 'app-document-type-edit-dialog',
@ -13,12 +12,8 @@ import { ToastService } from 'src/app/services/toast.service'
styleUrls: ['./document-type-edit-dialog.component.scss'], styleUrls: ['./document-type-edit-dialog.component.scss'],
}) })
export class DocumentTypeEditDialogComponent extends EditDialogComponent<PaperlessDocumentType> { export class DocumentTypeEditDialogComponent extends EditDialogComponent<PaperlessDocumentType> {
constructor( constructor(service: DocumentTypeService, activeModal: NgbActiveModal) {
service: DocumentTypeService, super(service, activeModal)
activeModal: NgbActiveModal,
toastService: ToastService
) {
super(service, activeModal, toastService)
} }
getCreateTitle() { getCreateTitle() {

View File

@ -2,11 +2,9 @@ import { Directive, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { FormGroup } from '@angular/forms' import { FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model' import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
import { ObjectWithId } from 'src/app/data/object-with-id' import { ObjectWithId } from 'src/app/data/object-with-id'
import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service' import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service'
import { ToastService } from 'src/app/services/toast.service'
@Directive() @Directive()
export abstract class EditDialogComponent<T extends ObjectWithId> export abstract class EditDialogComponent<T extends ObjectWithId>
@ -14,8 +12,7 @@ export abstract class EditDialogComponent<T extends ObjectWithId>
{ {
constructor( constructor(
private service: AbstractPaperlessService<T>, private service: AbstractPaperlessService<T>,
private activeModal: NgbActiveModal, private activeModal: NgbActiveModal
private toastService: ToastService
) {} ) {}
@Input() @Input()
@ -25,7 +22,7 @@ export abstract class EditDialogComponent<T extends ObjectWithId>
object: T object: T
@Output() @Output()
success = new EventEmitter() succeeded = new EventEmitter()
networkActive = false networkActive = false
@ -95,16 +92,16 @@ export abstract class EditDialogComponent<T extends ObjectWithId>
break break
} }
this.networkActive = true this.networkActive = true
serverResponse.subscribe( serverResponse.subscribe({
(result) => { next: (result) => {
this.activeModal.close() this.activeModal.close()
this.success.emit(result) this.succeeded.emit(result)
}, },
(error) => { error: (error) => {
this.error = error.error this.error = error.error
this.networkActive = false this.networkActive = false
} },
) })
} }
cancel() { cancel() {

View File

@ -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>

View File

@ -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
}
}

View File

@ -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>

View File

@ -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
}
}

View File

@ -6,7 +6,7 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p *ngIf="this.dialogMode == 'edit'" i18n> <p *ngIf="this.dialogMode === 'edit'" i18n>
<em>Note that editing a path does not apply changes to stored files until you have run the 'document_renamer' utility. See the <a target="_blank" href="https://docs.paperless-ngx.com/administration/#renamer">documentation</a>.</em> <em>Note that editing a path does not apply changes to stored files until you have run the 'document_renamer' utility. See the <a target="_blank" href="https://docs.paperless-ngx.com/administration/#renamer">documentation</a>.</em>
</p> </p>

View File

@ -5,7 +5,6 @@ import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { ToastService } from 'src/app/services/toast.service'
@Component({ @Component({
selector: 'app-storage-path-edit-dialog', selector: 'app-storage-path-edit-dialog',
@ -13,12 +12,8 @@ import { ToastService } from 'src/app/services/toast.service'
styleUrls: ['./storage-path-edit-dialog.component.scss'], styleUrls: ['./storage-path-edit-dialog.component.scss'],
}) })
export class StoragePathEditDialogComponent extends EditDialogComponent<PaperlessStoragePath> { export class StoragePathEditDialogComponent extends EditDialogComponent<PaperlessStoragePath> {
constructor( constructor(service: StoragePathService, activeModal: NgbActiveModal) {
service: StoragePathService, super(service, activeModal)
activeModal: NgbActiveModal,
toastService: ToastService
) {
super(service, activeModal, toastService)
} }
get pathHint() { get pathHint() {

View File

@ -4,7 +4,6 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { PaperlessTag } from 'src/app/data/paperless-tag' import { PaperlessTag } from 'src/app/data/paperless-tag'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { ToastService } from 'src/app/services/toast.service'
import { randomColor } from 'src/app/utils/color' import { randomColor } from 'src/app/utils/color'
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
@ -14,12 +13,8 @@ import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
styleUrls: ['./tag-edit-dialog.component.scss'], styleUrls: ['./tag-edit-dialog.component.scss'],
}) })
export class TagEditDialogComponent extends EditDialogComponent<PaperlessTag> { export class TagEditDialogComponent extends EditDialogComponent<PaperlessTag> {
constructor( constructor(service: TagService, activeModal: NgbActiveModal) {
service: TagService, super(service, activeModal)
activeModal: NgbActiveModal,
toastService: ToastService
) {
super(service, activeModal, toastService)
} }
getCreateTitle() { getCreateTitle() {

View File

@ -4,7 +4,7 @@
<use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" /> <use attr.xlink:href="assets/bootstrap-icons.svg#{{icon}}" />
</svg> </svg>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div> <div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<ng-container *ngIf="!editing && selectionModel.selectionSize() > 0"> <ng-container *ngIf="!editing && selectionModel.totalCount > 0">
<app-clearable-badge [number]="multiple ? selectionModel.totalCount : undefined" [selected]="!multiple && selectionModel.selectionSize() > 0" (cleared)="reset()"></app-clearable-badge> <app-clearable-badge [number]="multiple ? selectionModel.totalCount : undefined" [selected]="!multiple && selectionModel.selectionSize() > 0" (cleared)="reset()"></app-clearable-badge>
</ng-container> </ng-container>
</button> </button>

View File

@ -321,7 +321,7 @@ export class FilterableDropdownComponent {
apply = new EventEmitter<ChangedItems>() apply = new EventEmitter<ChangedItems>()
@Output() @Output()
open = new EventEmitter() opened = new EventEmitter()
get operatorToggleEnabled(): boolean { get operatorToggleEnabled(): boolean {
return ( return (
@ -356,7 +356,7 @@ export class FilterableDropdownComponent {
if (this.editing) { if (this.editing) {
this.selectionModel.reset() this.selectionModel.reset()
} }
this.open.next(this) this.opened.next(this)
} else { } else {
this.filterText = '' this.filterText = ''
if (this.applyOnClose && this.selectionModel.isDirty()) { if (this.applyOnClose && this.selectionModel.isDirty()) {

View File

@ -2,7 +2,7 @@
<label class="form-label" [for]="inputId">{{title}}</label> <label class="form-label" [for]="inputId">{{title}}</label>
<div class="input-group" [class.is-invalid]="error"> <div class="input-group" [class.is-invalid]="error">
<input type="number" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error"> <input type="number" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error">
<button class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="value">+1</button> <button *ngIf="showAdd" class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="value">+1</button>
</div> </div>
<div class="invalid-feedback"> <div class="invalid-feedback">
{{error}} {{error}}

View File

@ -1,4 +1,4 @@
import { Component, forwardRef } from '@angular/core' import { Component, forwardRef, Input } from '@angular/core'
import { NG_VALUE_ACCESSOR } from '@angular/forms' import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { FILTER_ASN_ISNULL } from 'src/app/data/filter-rule-type' import { FILTER_ASN_ISNULL } from 'src/app/data/filter-rule-type'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
@ -17,6 +17,9 @@ import { AbstractInputComponent } from '../abstract-input'
styleUrls: ['./number.component.scss'], styleUrls: ['./number.component.scss'],
}) })
export class NumberComponent extends AbstractInputComponent<number> { export class NumberComponent extends AbstractInputComponent<number> {
@Input()
showAdd: boolean = true
constructor(private documentService: DocumentService) { constructor(private documentService: DocumentService) {
super() super()
} }

View File

@ -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>

View File

@ -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()
}
}

View File

@ -7,7 +7,7 @@
[closeOnSelect]="false" [closeOnSelect]="false"
[clearSearchOnAdd]="true" [clearSearchOnAdd]="true"
[hideSelected]="true" [hideSelected]="true"
[addTag]="createTagRef" [addTag]="allowCreate ? createTagRef : false"
addTagText="Add tag" addTagText="Add tag"
i18n-addTagText i18n-addTagText
(change)="onChange(value)" (change)="onChange(value)"
@ -31,7 +31,7 @@
</ng-template> </ng-template>
</ng-select> </ng-select>
<button class="btn btn-outline-secondary" type="button" (click)="createTag()"> <button *ngIf="allowCreate" class="btn btn-outline-secondary" type="button" (click)="createTag()">
<svg class="buttonicon" fill="currentColor"> <svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus" /> <use xlink:href="assets/bootstrap-icons.svg#plus" />
</svg> </svg>

View File

@ -54,6 +54,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@Input() @Input()
suggestions: number[] suggestions: number[]
@Input()
allowCreate: boolean = true
value: number[] value: number[]
tags: PaperlessTag[] tags: PaperlessTag[]

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' import { Component, EventEmitter, Input, Output } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { ObjectWithId } from 'src/app/data/object-with-id' import { ObjectWithId } from 'src/app/data/object-with-id'
@ -7,7 +7,7 @@ import { ObjectWithId } from 'src/app/data/object-with-id'
templateUrl: './select-dialog.component.html', templateUrl: './select-dialog.component.html',
styleUrls: ['./select-dialog.component.scss'], styleUrls: ['./select-dialog.component.scss'],
}) })
export class SelectDialogComponent implements OnInit { export class SelectDialogComponent {
constructor(public activeModal: NgbActiveModal) {} constructor(public activeModal: NgbActiveModal) {}
@Output() @Output()
@ -24,8 +24,6 @@ export class SelectDialogComponent implements OnInit {
selected: number selected: number
ngOnInit(): void {}
cancelClicked() { cancelClicked() {
this.activeModal.close() this.activeModal.close()
} }

View File

@ -1,4 +1,4 @@
import { Component, Input, OnInit } from '@angular/core' import { Component, Input } from '@angular/core'
import { PaperlessTag } from 'src/app/data/paperless-tag' import { PaperlessTag } from 'src/app/data/paperless-tag'
@Component({ @Component({
@ -6,7 +6,7 @@ import { PaperlessTag } from 'src/app/data/paperless-tag'
templateUrl: './tag.component.html', templateUrl: './tag.component.html',
styleUrls: ['./tag.component.scss'], styleUrls: ['./tag.component.scss'],
}) })
export class TagComponent implements OnInit { export class TagComponent {
constructor() {} constructor() {}
@Input() @Input()
@ -17,6 +17,4 @@ export class TagComponent implements OnInit {
@Input() @Input()
clickable: boolean = false clickable: boolean = false
ngOnInit(): void {}
} }

View File

@ -11,9 +11,9 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let doc of documents" (click)="openDocumentsService.openDocument(doc)"> <tr *ngFor="let doc of documents">
<td>{{doc.created_date | customDate}}</td> <td><a routerLink="/documents/{{doc.id}}" class="d-block text-dark text-decoration-none">{{doc.created_date | customDate}}</a></td>
<td>{{doc.title | documentTitle}}<app-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t); $event.stopPropagation();"></app-tag></td> <td><a routerLink="/documents/{{doc.id}}" class="d-block text-dark text-decoration-none">{{doc.title | documentTitle}}<app-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t, $event)"></app-tag></a></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@ -7,6 +7,6 @@ th:first-child {
width: 25%; width: 25%;
} }
tbody tr { tbody app-tag {
cursor: pointer; cursor: pointer;
} }

View File

@ -72,7 +72,9 @@ export class SavedViewWidgetComponent implements OnInit, OnDestroy {
} }
} }
clickTag(tag: PaperlessTag) { clickTag(tag: PaperlessTag, event: MouseEvent) {
event.preventDefault()
this.list.quickFilter([ this.list.quickFilter([
{ rule_type: FILTER_HAS_TAGS_ALL, value: tag.id.toString() }, { rule_type: FILTER_HAS_TAGS_ALL, value: tag.id.toString() },
]) ])

View File

@ -1,6 +1,6 @@
<app-widget-frame title="Statistics" [loading]="loading" i18n-title> <app-widget-frame title="Statistics" [loading]="loading" i18n-title>
<ng-container content> <ng-container content>
<p class="card-text" i18n *ngIf="statistics?.documents_inbox != null">Documents in inbox: {{statistics?.documents_inbox}}</p> <p class="card-text" i18n *ngIf="statistics?.documents_inbox !== null">Documents in inbox: {{statistics?.documents_inbox}}</p>
<p class="card-text" i18n>Total documents: {{statistics?.documents_total}}</p> <p class="card-text" i18n>Total documents: {{statistics?.documents_total}}</p>
</ng-container> </ng-container>
</app-widget-frame> </app-widget-frame>

View File

@ -1,6 +1,5 @@
import { HttpEventType } from '@angular/common/http' import { Component } from '@angular/core'
import { Component, OnInit } from '@angular/core' import { NgxFileDropEntry } from 'ngx-file-drop'
import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop'
import { import {
ConsumerStatusService, ConsumerStatusService,
FileStatus, FileStatus,
@ -15,7 +14,7 @@ const MAX_ALERTS = 5
templateUrl: './upload-file-widget.component.html', templateUrl: './upload-file-widget.component.html',
styleUrls: ['./upload-file-widget.component.scss'], styleUrls: ['./upload-file-widget.component.scss'],
}) })
export class UploadFileWidgetComponent implements OnInit { export class UploadFileWidgetComponent {
alertsExpanded = false alertsExpanded = false
constructor( constructor(
@ -109,8 +108,6 @@ export class UploadFileWidgetComponent implements OnInit {
this.consumerStatusService.dismissCompleted() this.consumerStatusService.dismissCompleted()
} }
ngOnInit(): void {}
public fileOver(event) {} public fileOver(event) {}
public fileLeave(event) {} public fileLeave(event) {}

View File

@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core' import { Component } from '@angular/core'
import { TourService } from 'ngx-ui-tour-ng-bootstrap' import { TourService } from 'ngx-ui-tour-ng-bootstrap'
@Component({ @Component({
@ -6,8 +6,6 @@ import { TourService } from 'ngx-ui-tour-ng-bootstrap'
templateUrl: './welcome-widget.component.html', templateUrl: './welcome-widget.component.html',
styleUrls: ['./welcome-widget.component.scss'], styleUrls: ['./welcome-widget.component.scss'],
}) })
export class WelcomeWidgetComponent implements OnInit { export class WelcomeWidgetComponent {
constructor(public readonly tourService: TourService) {} constructor(public readonly tourService: TourService) {}
ngOnInit(): void {}
} }

View File

@ -1,11 +1,11 @@
import { Component, Input, OnInit } from '@angular/core' import { Component, Input } from '@angular/core'
@Component({ @Component({
selector: 'app-widget-frame', selector: 'app-widget-frame',
templateUrl: './widget-frame.component.html', templateUrl: './widget-frame.component.html',
styleUrls: ['./widget-frame.component.scss'], styleUrls: ['./widget-frame.component.scss'],
}) })
export class WidgetFrameComponent implements OnInit { export class WidgetFrameComponent {
constructor() {} constructor() {}
@Input() @Input()
@ -13,6 +13,4 @@ export class WidgetFrameComponent implements OnInit {
@Input() @Input()
loading: boolean = false loading: boolean = false
ngOnInit(): void {}
} }

View File

@ -1,5 +1,5 @@
<app-page-header [(title)]="title"> <app-page-header [(title)]="title">
<div class="input-group input-group-sm me-5 d-none d-md-flex" *ngIf="getContentType() == 'application/pdf' && !useNativePdfViewer"> <div class="input-group input-group-sm me-5 d-none d-md-flex" *ngIf="getContentType() === 'application/pdf' && !useNativePdfViewer">
<div class="input-group-text" i18n>Page</div> <div class="input-group-text" i18n>Page</div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" /> <input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" />
<div class="input-group-text" i18n>of {{previewNumPages}}</div> <div class="input-group-text" i18n>of {{previewNumPages}}</div>
@ -149,9 +149,9 @@
<li [ngbNavItem]="4" class="d-md-none"> <li [ngbNavItem]="4" class="d-md-none">
<a ngbNavLink>Preview</a> <a ngbNavLink>Preview</a>
<ng-template ngbNavContent *ngIf="pdfPreview.offsetParent == undefined"> <ng-template ngbNavContent *ngIf="pdfPreview.offsetParent === undefined">
<div class="position-relative"> <div class="position-relative">
<ng-container *ngIf="getContentType() == 'application/pdf'"> <ng-container *ngIf="getContentType() === 'application/pdf'">
<div class="preview-sticky pdf-viewer-container" *ngIf="!useNativePdfViewer ; else nativePdfViewer"> <div class="preview-sticky pdf-viewer-container" *ngIf="!useNativePdfViewer ; else nativePdfViewer">
<pdf-viewer [src]="{ url: previewUrl, password: password }" [original-size]="false" [show-borders]="true" [show-all]="true" [(page)]="previewCurrentPage" [render-text-mode]="2" (error)="onError($event)" (after-load-complete)="pdfPreviewLoaded($event)"></pdf-viewer> <pdf-viewer [src]="{ url: previewUrl, password: password }" [original-size]="false" [show-borders]="true" [show-all]="true" [(page)]="previewCurrentPage" [render-text-mode]="2" (error)="onError($event)" (after-load-complete)="pdfPreviewLoaded($event)"></pdf-viewer>
</div> </div>
@ -159,7 +159,7 @@
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object> <object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
</ng-template> </ng-template>
</ng-container> </ng-container>
<ng-container *ngIf="getContentType() == 'text/plain'"> <ng-container *ngIf="getContentType() === 'text/plain'">
<object [data]="previewUrl | safeUrl" type="text/plain" class="preview-sticky bg-white" width="100%"></object> <object [data]="previewUrl | safeUrl" type="text/plain" class="preview-sticky bg-white" width="100%"></object>
</ng-container> </ng-container>
<div *ngIf="requiresPassword" class="password-prompt"> <div *ngIf="requiresPassword" class="password-prompt">
@ -180,14 +180,14 @@
<div [ngbNavOutlet]="nav" class="mt-2"></div> <div [ngbNavOutlet]="nav" class="mt-2"></div>
<button type="button" class="btn btn-outline-secondary" (click)="discard()" i18n [disabled]="networkActive || !(isDirty$ | async)">Discard</button>&nbsp; <button type="button" class="btn btn-outline-secondary" (click)="discard()" i18n [disabled]="networkActive || (isDirty$ | async) === false">Discard</button>&nbsp;
<button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n [disabled]="networkActive || !(isDirty$ | async) || error">Save & next</button>&nbsp; <button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n [disabled]="networkActive || (isDirty$ | async) === false || error">Save & next</button>&nbsp;
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive || !(isDirty$ | async) || error">Save</button>&nbsp; <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive || (isDirty$ | async) === false || error">Save</button>&nbsp;
</form> </form>
</div> </div>
<div class="col-md-6 col-xl-8 mb-3 d-none d-md-block position-relative" #pdfPreview> <div class="col-md-6 col-xl-8 mb-3 d-none d-md-block position-relative" #pdfPreview>
<ng-container *ngIf="getContentType() == 'application/pdf'"> <ng-container *ngIf="getContentType() === 'application/pdf'">
<div class="preview-sticky pdf-viewer-container" *ngIf="!useNativePdfViewer ; else nativePdfViewer"> <div class="preview-sticky pdf-viewer-container" *ngIf="!useNativePdfViewer ; else nativePdfViewer">
<pdf-viewer [src]="{ url: previewUrl, password: password }" [original-size]="false" [show-borders]="true" [show-all]="true" [(page)]="previewCurrentPage" [render-text-mode]="2" (error)="onError($event)" (after-load-complete)="pdfPreviewLoaded($event)"></pdf-viewer> <pdf-viewer [src]="{ url: previewUrl, password: password }" [original-size]="false" [show-borders]="true" [show-all]="true" [(page)]="previewCurrentPage" [render-text-mode]="2" (error)="onError($event)" (after-load-complete)="pdfPreviewLoaded($event)"></pdf-viewer>
</div> </div>
@ -195,7 +195,7 @@
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object> <object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
</ng-template> </ng-template>
</ng-container> </ng-container>
<ng-container *ngIf="getContentType() == 'text/plain'"> <ng-container *ngIf="getContentType() === 'text/plain'">
<object [data]="previewUrl | safeUrl" type="text/plain" class="preview-sticky bg-white" width="100%"></object> <object [data]="previewUrl | safeUrl" type="text/plain" class="preview-sticky bg-white" width="100%"></object>
</ng-container> </ng-container>
<div *ngIf="requiresPassword" class="password-prompt"> <div *ngIf="requiresPassword" class="password-prompt">

View File

@ -184,7 +184,7 @@ export class DocumentDetailComponent
this.openDocumentService.getOpenDocument(this.documentId) this.openDocumentService.getOpenDocument(this.documentId)
) )
} else { } else {
this.openDocumentService.openDocument(doc, false) this.openDocumentService.openDocument(doc)
this.updateComponent(doc) this.updateComponent(doc)
} }

View File

@ -1,11 +1,11 @@
import { Component, Input, OnInit } from '@angular/core' import { Component, Input } from '@angular/core'
@Component({ @Component({
selector: 'app-metadata-collapse', selector: 'app-metadata-collapse',
templateUrl: './metadata-collapse.component.html', templateUrl: './metadata-collapse.component.html',
styleUrls: ['./metadata-collapse.component.scss'], styleUrls: ['./metadata-collapse.component.scss'],
}) })
export class MetadataCollapseComponent implements OnInit { export class MetadataCollapseComponent {
constructor() {} constructor() {}
expand = false expand = false
@ -15,6 +15,4 @@ export class MetadataCollapseComponent implements OnInit {
@Input() @Input()
title = $localize`Metadata` title = $localize`Metadata`
ngOnInit(): void {}
} }

View File

@ -66,7 +66,6 @@
</div> </div>
<div class="col-auto ms-auto mb-2 mb-xl-0 d-flex"> <div class="col-auto ms-auto mb-2 mb-xl-0 d-flex">
<div class="btn-group btn-group-sm me-2"> <div class="btn-group btn-group-sm me-2">
<div ngbDropdown class="me-2 d-flex"> <div ngbDropdown class="me-2 d-flex">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
@ -75,26 +74,57 @@
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div> <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<button ngbDropdownItem [disabled]="awaitingDownload" (click)="downloadSelected()" i18n>
Download
<div *ngIf="awaitingDownload" class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Preparing download...</span>
</div>
</button>
<button ngbDropdownItem [disabled]="awaitingDownload" (click)="downloadSelected('originals')" i18n>
Download originals
<div *ngIf="awaitingDownload" class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Preparing download...</span>
</div>
</button>
<button ngbDropdownItem (click)="redoOcrSelected()" i18n>Redo OCR</button> <button ngbDropdownItem (click)="redoOcrSelected()" i18n>Redo OCR</button>
</div> </div>
</div> </div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()"> <div class="btn-group btn-group-sm me-2">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor"> <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
<use xlink:href="assets/bootstrap-icons.svg#trash" /> <svg *ngIf="!awaitingDownload" class="toolbaricon" fill="currentColor">
</svg>&nbsp;<ng-container i18n>Delete</ng-container> <use xlink:href="assets/bootstrap-icons.svg#arrow-down" />
</button> </svg>
<div *ngIf="awaitingDownload" class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Preparing download...</span>
</div>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Download</ng-container></div>
</button>
<div ngbDropdown class="me-2 d-flex btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<form [formGroup]="downloadForm" class="px-3 py-1">
<p class="mb-1" i18n>Include:</p>
<div class="form-group ps-3 mb-2">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
<label class="form-check-label" for="downloadFileType_archive" i18n>
Archived files
</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
<label class="form-check-label" for="downloadFileType_originals" i18n>
Original files
</label>
</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
<label class="form-check-label" for="downloadUseFormatting" i18n>
Use formatted filename
</label>
</div>
</form>
</div>
</div>
</div>
<div class="btn-group btn-group-sm me-2">
<button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
</div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,7 @@
.dropdown-toggle-split {
--bs-border-radius: .25rem;
}
.dropdown-menu{
--bs-dropdown-min-width: 12rem;
}

View File

@ -1,4 +1,4 @@
import { Component } from '@angular/core' import { Component, OnDestroy, OnInit } from '@angular/core'
import { PaperlessTag } from 'src/app/data/paperless-tag' import { PaperlessTag } from 'src/app/data/paperless-tag'
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type' import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
@ -25,13 +25,15 @@ import { saveAs } from 'file-saver'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import { FormControl, FormGroup } from '@angular/forms'
import { first, Subject, takeUntil } from 'rxjs'
@Component({ @Component({
selector: 'app-bulk-editor', selector: 'app-bulk-editor',
templateUrl: './bulk-editor.component.html', templateUrl: './bulk-editor.component.html',
styleUrls: ['./bulk-editor.component.scss'], styleUrls: ['./bulk-editor.component.scss'],
}) })
export class BulkEditorComponent { export class BulkEditorComponent implements OnInit, OnDestroy {
tags: PaperlessTag[] tags: PaperlessTag[]
correspondents: PaperlessCorrespondent[] correspondents: PaperlessCorrespondent[]
documentTypes: PaperlessDocumentType[] documentTypes: PaperlessDocumentType[]
@ -43,6 +45,14 @@ export class BulkEditorComponent {
storagePathsSelectionModel = new FilterableDropdownSelectionModel() storagePathsSelectionModel = new FilterableDropdownSelectionModel()
awaitingDownload: boolean awaitingDownload: boolean
unsubscribeNotifier: Subject<any> = new Subject()
downloadForm = new FormGroup({
downloadFileTypeArchive: new FormControl(true),
downloadFileTypeOriginals: new FormControl(false),
downloadUseFormatting: new FormControl(false),
})
constructor( constructor(
private documentTypeService: DocumentTypeService, private documentTypeService: DocumentTypeService,
private tagService: TagService, private tagService: TagService,
@ -66,16 +76,46 @@ export class BulkEditorComponent {
ngOnInit() { ngOnInit() {
this.tagService this.tagService
.listAll() .listAll()
.pipe(first())
.subscribe((result) => (this.tags = result.results)) .subscribe((result) => (this.tags = result.results))
this.correspondentService this.correspondentService
.listAll() .listAll()
.pipe(first())
.subscribe((result) => (this.correspondents = result.results)) .subscribe((result) => (this.correspondents = result.results))
this.documentTypeService this.documentTypeService
.listAll() .listAll()
.pipe(first())
.subscribe((result) => (this.documentTypes = result.results)) .subscribe((result) => (this.documentTypes = result.results))
this.storagePathService this.storagePathService
.listAll() .listAll()
.pipe(first())
.subscribe((result) => (this.storagePaths = result.results)) .subscribe((result) => (this.storagePaths = result.results))
this.downloadForm
.get('downloadFileTypeArchive')
.valueChanges.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newValue) => {
if (!newValue) {
this.downloadForm
.get('downloadFileTypeOriginals')
.patchValue(true, { emitEvent: false })
}
})
this.downloadForm
.get('downloadFileTypeOriginals')
.valueChanges.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newValue) => {
if (!newValue) {
this.downloadForm
.get('downloadFileTypeArchive')
.patchValue(true, { emitEvent: false })
}
})
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next(this)
this.unsubscribeNotifier.complete()
} }
private executeBulkOperation(modal, method: string, args) { private executeBulkOperation(modal, method: string, args) {
@ -84,8 +124,9 @@ export class BulkEditorComponent {
} }
this.documentService this.documentService
.bulkEdit(Array.from(this.list.selected), method, args) .bulkEdit(Array.from(this.list.selected), method, args)
.subscribe( .pipe(first())
(response) => { .subscribe({
next: () => {
this.list.reload() this.list.reload()
this.list.reduceSelectionToFilter() this.list.reduceSelectionToFilter()
this.list.selected.forEach((id) => { this.list.selected.forEach((id) => {
@ -95,7 +136,7 @@ export class BulkEditorComponent {
modal.close() modal.close()
} }
}, },
(error) => { error: (error) => {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled = true modal.componentInstance.buttonsEnabled = true
} }
@ -104,8 +145,8 @@ export class BulkEditorComponent {
error.error error.error
)}` )}`
) )
} },
) })
} }
private applySelectionData( private applySelectionData(
@ -126,6 +167,7 @@ export class BulkEditorComponent {
openTagsDropdown() { openTagsDropdown() {
this.documentService this.documentService
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.applySelectionData(s.selected_tags, this.tagSelectionModel) this.applySelectionData(s.selected_tags, this.tagSelectionModel)
}) })
@ -134,6 +176,7 @@ export class BulkEditorComponent {
openDocumentTypeDropdown() { openDocumentTypeDropdown() {
this.documentService this.documentService
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.applySelectionData( this.applySelectionData(
s.selected_document_types, s.selected_document_types,
@ -145,6 +188,7 @@ export class BulkEditorComponent {
openCorrespondentDropdown() { openCorrespondentDropdown() {
this.documentService this.documentService
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.applySelectionData( this.applySelectionData(
s.selected_correspondents, s.selected_correspondents,
@ -156,6 +200,7 @@ export class BulkEditorComponent {
openStoragePathDropdown() { openStoragePathDropdown() {
this.documentService this.documentService
.getSelectionData(Array.from(this.list.selected)) .getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => { .subscribe((s) => {
this.applySelectionData( this.applySelectionData(
s.selected_storage_paths, s.selected_storage_paths,
@ -232,12 +277,14 @@ export class BulkEditorComponent {
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm` modal.componentInstance.btnCaption = $localize`Confirm`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked
this.executeBulkOperation(modal, 'modify_tags', { .pipe(takeUntil(this.unsubscribeNotifier))
add_tags: changedTags.itemsToAdd.map((t) => t.id), .subscribe(() => {
remove_tags: changedTags.itemsToRemove.map((t) => t.id), this.executeBulkOperation(modal, 'modify_tags', {
add_tags: changedTags.itemsToAdd.map((t) => t.id),
remove_tags: changedTags.itemsToRemove.map((t) => t.id),
})
}) })
})
} else { } else {
this.executeBulkOperation(null, 'modify_tags', { this.executeBulkOperation(null, 'modify_tags', {
add_tags: changedTags.itemsToAdd.map((t) => t.id), add_tags: changedTags.itemsToAdd.map((t) => t.id),
@ -270,11 +317,13 @@ export class BulkEditorComponent {
} }
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm` modal.componentInstance.btnCaption = $localize`Confirm`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked
this.executeBulkOperation(modal, 'set_correspondent', { .pipe(takeUntil(this.unsubscribeNotifier))
correspondent: correspondent ? correspondent.id : null, .subscribe(() => {
this.executeBulkOperation(modal, 'set_correspondent', {
correspondent: correspondent ? correspondent.id : null,
})
}) })
})
} else { } else {
this.executeBulkOperation(null, 'set_correspondent', { this.executeBulkOperation(null, 'set_correspondent', {
correspondent: correspondent ? correspondent.id : null, correspondent: correspondent ? correspondent.id : null,
@ -306,11 +355,13 @@ export class BulkEditorComponent {
} }
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm` modal.componentInstance.btnCaption = $localize`Confirm`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked
this.executeBulkOperation(modal, 'set_document_type', { .pipe(takeUntil(this.unsubscribeNotifier))
document_type: documentType ? documentType.id : null, .subscribe(() => {
this.executeBulkOperation(modal, 'set_document_type', {
document_type: documentType ? documentType.id : null,
})
}) })
})
} else { } else {
this.executeBulkOperation(null, 'set_document_type', { this.executeBulkOperation(null, 'set_document_type', {
document_type: documentType ? documentType.id : null, document_type: documentType ? documentType.id : null,
@ -342,11 +393,13 @@ export class BulkEditorComponent {
} }
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm` modal.componentInstance.btnCaption = $localize`Confirm`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked
this.executeBulkOperation(modal, 'set_storage_path', { .pipe(takeUntil(this.unsubscribeNotifier))
storage_path: storagePath ? storagePath.id : null, .subscribe(() => {
this.executeBulkOperation(modal, 'set_storage_path', {
storage_path: storagePath ? storagePath.id : null,
})
}) })
})
} else { } else {
this.executeBulkOperation(null, 'set_storage_path', { this.executeBulkOperation(null, 'set_storage_path', {
storage_path: storagePath ? storagePath.id : null, storage_path: storagePath ? storagePath.id : null,
@ -364,16 +417,30 @@ export class BulkEditorComponent {
modal.componentInstance.message = $localize`This operation cannot be undone.` modal.componentInstance.message = $localize`This operation cannot be undone.`
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Delete document(s)` modal.componentInstance.btnCaption = $localize`Delete document(s)`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked
modal.componentInstance.buttonsEnabled = false .pipe(takeUntil(this.unsubscribeNotifier))
this.executeBulkOperation(modal, 'delete', {}) .subscribe(() => {
}) modal.componentInstance.buttonsEnabled = false
this.executeBulkOperation(modal, 'delete', {})
})
} }
downloadSelected(content = 'archive') { downloadSelected() {
this.awaitingDownload = true this.awaitingDownload = true
let downloadFileType: string =
this.downloadForm.get('downloadFileTypeArchive').value &&
this.downloadForm.get('downloadFileTypeOriginals').value
? 'both'
: this.downloadForm.get('downloadFileTypeArchive').value
? 'archive'
: 'originals'
this.documentService this.documentService
.bulkDownload(Array.from(this.list.selected), content) .bulkDownload(
Array.from(this.list.selected),
downloadFileType,
this.downloadForm.get('downloadUseFormatting').value
)
.pipe(first())
.subscribe((result: any) => { .subscribe((result: any) => {
saveAs(result, 'documents.zip') saveAs(result, 'documents.zip')
this.awaitingDownload = false this.awaitingDownload = false
@ -389,9 +456,11 @@ export class BulkEditorComponent {
modal.componentInstance.message = $localize`This operation cannot be undone.` modal.componentInstance.message = $localize`This operation cannot be undone.`
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked
modal.componentInstance.buttonsEnabled = false .pipe(takeUntil(this.unsubscribeNotifier))
this.executeBulkOperation(modal, 'redo_ocr', {}) .subscribe(() => {
}) modal.componentInstance.buttonsEnabled = false
this.executeBulkOperation(modal, 'redo_ocr', {})
})
} }
} }

View File

@ -37,7 +37,7 @@
<use xlink:href="assets/bootstrap-icons.svg#diagram-3"/> <use xlink:href="assets/bootstrap-icons.svg#diagram-3"/>
</svg>&nbsp;<span class="d-none d-md-inline" i18n>More like this</span> </svg>&nbsp;<span class="d-none d-md-inline" i18n>More like this</span>
</a> </a>
<a (click)="openDocumentsService.openDocument(document)" class="btn btn-sm btn-outline-secondary"> <a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary">
<svg class="sidebaricon" fill="currentColor" class="sidebaricon"> <svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<use xlink:href="assets/bootstrap-icons.svg#pencil"/> <use xlink:href="assets/bootstrap-icons.svg#pencil"/>
</svg>&nbsp;<span class="d-none d-md-inline" i18n>Edit</span> </svg>&nbsp;<span class="d-none d-md-inline" i18n>Edit</span>

View File

@ -2,7 +2,6 @@ import {
Component, Component,
EventEmitter, EventEmitter,
Input, Input,
OnInit,
Output, Output,
ViewChild, ViewChild,
} from '@angular/core' } from '@angular/core'
@ -10,9 +9,6 @@ import { PaperlessDocument } from 'src/app/data/paperless-document'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
@Component({ @Component({
@ -23,11 +19,10 @@ import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
'../popover-preview/popover-preview.scss', '../popover-preview/popover-preview.scss',
], ],
}) })
export class DocumentCardLargeComponent implements OnInit { export class DocumentCardLargeComponent {
constructor( constructor(
private documentService: DocumentService, private documentService: DocumentService,
private settingsService: SettingsService, private settingsService: SettingsService
public openDocumentsService: OpenDocumentsService
) {} ) {}
@Input() @Input()
@ -75,8 +70,6 @@ export class DocumentCardLargeComponent implements OnInit {
} }
} }
ngOnInit(): void {}
getIsThumbInverted() { getIsThumbInverted() {
return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED) return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED)
} }
@ -119,6 +112,9 @@ export class DocumentCardLargeComponent implements OnInit {
} }
get contentTrimmed() { get contentTrimmed() {
return this.document.content.substr(0, 500) return (
this.document.content.substr(0, 500) +
(this.document.content.length > 500 ? '...' : '')
)
} }
} }

View File

@ -67,7 +67,7 @@
</div> </div>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="btn-group w-100"> <div class="btn-group w-100">
<a (click)="openDocumentsService.openDocument(document)" class="btn btn-sm btn-outline-secondary" title="Edit" i18n-title> <a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" title="Edit" i18n-title>
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/> <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg> </svg>

View File

@ -2,7 +2,6 @@ import {
Component, Component,
EventEmitter, EventEmitter,
Input, Input,
OnInit,
Output, Output,
ViewChild, ViewChild,
} from '@angular/core' } from '@angular/core'
@ -11,7 +10,6 @@ import { PaperlessDocument } from 'src/app/data/paperless-document'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
@Component({ @Component({
@ -22,11 +20,10 @@ import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
'../popover-preview/popover-preview.scss', '../popover-preview/popover-preview.scss',
], ],
}) })
export class DocumentCardSmallComponent implements OnInit { export class DocumentCardSmallComponent {
constructor( constructor(
private documentService: DocumentService, private documentService: DocumentService,
private settingsService: SettingsService, private settingsService: SettingsService
public openDocumentsService: OpenDocumentsService
) {} ) {}
@Input() @Input()
@ -57,8 +54,6 @@ export class DocumentCardSmallComponent implements OnInit {
mouseOnPreview = false mouseOnPreview = false
popoverHidden = true popoverHidden = true
ngOnInit(): void {}
getIsThumbInverted() { getIsThumbInverted() {
return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED) return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED)
} }

View File

@ -53,7 +53,7 @@
</div> </div>
<div> <div>
<button *ngFor="let f of getSortFields()" ngbDropdownItem (click)="setSortField(f.field)" <button *ngFor="let f of getSortFields()" ngbDropdownItem (click)="setSortField(f.field)"
[class.active]="list.sortField == f.field">{{f.name}} [class.active]="list.sortField === f.field">{{f.name}}
</button> </button>
</div> </div>
</div> </div>
@ -94,7 +94,7 @@
</ng-container> </ng-container>
<span i18n *ngIf="list.selected.size > 0">{list.collectionSize, plural, =1 {Selected {{list.selected.size}} of one document} other {Selected {{list.selected.size}} of {{list.collectionSize || 0}} documents}}</span> <span i18n *ngIf="list.selected.size > 0">{list.collectionSize, plural, =1 {Selected {{list.selected.size}} of one document} other {Selected {{list.selected.size}} of {{list.collectionSize || 0}} documents}}</span>
<ng-container *ngIf="!list.isReloading"> <ng-container *ngIf="!list.isReloading">
<span i18n *ngIf="list.selected.size == 0">{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span>&nbsp;<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>&nbsp;<span i18n *ngIf="isFiltered">(filtered)</span>
</ng-container> </ng-container>
</p> </p>
<ngb-pagination *ngIf="list.collectionSize" [pageSize]="list.currentPageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5" <ngb-pagination *ngIf="list.collectionSize" [pageSize]="list.currentPageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5"
@ -111,52 +111,52 @@
</ng-container> </ng-container>
<ng-template #documentListNoError> <ng-template #documentListNoError>
<div *ngIf="displayMode == 'largeCards'"> <div *ngIf="displayMode === 'largeCards'">
<app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)" (clickStoragePath)="clickStoragePath($event)" (clickMoreLike)="clickMoreLike(d.id)"> <app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)" (clickStoragePath)="clickStoragePath($event)" (clickMoreLike)="clickMoreLike(d.id)">
</app-document-card-large> </app-document-card-large>
</div> </div>
<table class="table table-sm align-middle border shadow-sm" *ngIf="displayMode == 'details'"> <table class="table table-sm align-middle border shadow-sm" *ngIf="displayMode === 'details'">
<thead> <thead>
<th></th> <th></th>
<th class="d-none d-lg-table-cell" <th class="d-none d-lg-table-cell"
sortable="archive_serial_number" appSortable="archive_serial_number"
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>ASN</th> i18n>ASN</th>
<th class="d-none d-md-table-cell" <th class="d-none d-md-table-cell"
sortable="correspondent__name" appSortable="correspondent__name"
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Correspondent</th> i18n>Correspondent</th>
<th <th
sortable="title" appSortable="title"
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Title</th> i18n>Title</th>
<th class="d-none d-xl-table-cell" <th class="d-none d-xl-table-cell"
sortable="document_type__name" appSortable="document_type__name"
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Document type</th> i18n>Document type</th>
<th class="d-none d-xl-table-cell" <th class="d-none d-xl-table-cell"
sortable="storage_path__name" appSortable="storage_path__name"
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Storage path</th> i18n>Storage path</th>
<th <th
sortable="created" appSortable="created"
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Created</th> i18n>Created</th>
<th class="d-none d-xl-table-cell" <th class="d-none d-xl-table-cell"
sortable="added" appSortable="added"
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
@ -179,7 +179,7 @@
</ng-container> </ng-container>
</td> </td>
<td> <td>
<a (click)="openDocumentsService.openDocument(d)" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a> <a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
<app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(t.id);$event.stopPropagation()"></app-tag> <app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(t.id);$event.stopPropagation()"></app-tag>
</td> </td>
<td class="d-none d-xl-table-cell"> <td class="d-none d-xl-table-cell">
@ -202,7 +202,7 @@
</tbody> </tbody>
</table> </table>
<div class="row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'"> <div class="row row-cols-paperless-cards" *ngIf="displayMode === 'smallCards'">
<app-document-card-small class="p-0" [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickStoragePath)="clickStoragePath($event)" (clickDocumentType)="clickDocumentType($event)"></app-document-card-small> <app-document-card-small class="p-0" [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickStoragePath)="clickStoragePath($event)" (clickDocumentType)="clickDocumentType($event)"></app-document-card-small>
</div> </div>
<div *ngIf="list.documents?.length > 15" class="mt-3"> <div *ngIf="list.documents?.length > 15" class="mt-3">

View File

@ -5,10 +5,10 @@
<div ngbDropdown> <div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{textFilterTargetName}}</button> <button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{textFilterTargetName}}</button>
<div class="dropdown-menu shadow" ngbDropdownMenu> <div class="dropdown-menu shadow" ngbDropdownMenu>
<button *ngFor="let t of textFilterTargets" ngbDropdownItem [class.active]="textFilterTarget == t.id" (click)="changeTextFilterTarget(t.id)">{{t.name}}</button> <button *ngFor="let t of textFilterTargets" ngbDropdownItem [class.active]="textFilterTarget === t.id" (click)="changeTextFilterTarget(t.id)">{{t.name}}</button>
</div> </div>
</div> </div>
<select *ngIf="textFilterTarget == 'asn'" class="form-select flex-grow-0 w-auto" [(ngModel)]="textFilterModifier" (change)="textFilterModifierChange()"> <select *ngIf="textFilterTarget === 'asn'" class="form-select flex-grow-0 w-auto" [(ngModel)]="textFilterModifier" (change)="textFilterModifierChange()">
<option *ngFor="let m of textFilterModifiers" ngbDropdownItem [value]="m.id">{{m.label}}</option> <option *ngFor="let m of textFilterModifiers" ngbDropdownItem [value]="m.id">{{m.label}}</option>
</select> </select>
<button *ngIf="_textFilter" class="btn btn-link btn-sm px-0 position-absolute top-0 end-0 z-10" (click)="resetTextField()"> <button *ngIf="_textFilter" class="btn btn-link btn-sm px-0 position-absolute top-0 end-0 z-10" (click)="resetTextField()">
@ -16,7 +16,7 @@
<path fill-rule="evenodd" d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/> <path fill-rule="evenodd" d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
</svg> </svg>
</button> </button>
<input #textFilterInput class="form-control form-control-sm" type="text" [disabled]="textFilterModifierIsNull" [(ngModel)]="textFilter" (keyup)="textFilterKeyup($event)" [readonly]="textFilterTarget == 'fulltext-morelike'"> <input #textFilterInput class="form-control form-control-sm" type="text" [disabled]="textFilterModifierIsNull" [(ngModel)]="textFilter" (keyup)="textFilterKeyup($event)" [readonly]="textFilterTarget === 'fulltext-morelike'">
</div> </div>
</div> </div>
</div> </div>

View File

@ -16,10 +16,10 @@
<table class="table table-striped align-middle border shadow-sm"> <table class="table table-striped align-middle border shadow-sm">
<thead> <thead>
<tr> <tr>
<th scope="col" sortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th> <th scope="col" appSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th>
<th scope="col" class="d-none d-sm-table-cell" sortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th> <th scope="col" class="d-none d-sm-table-cell" appSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
<th scope="col" sortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th> <th scope="col" appSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
<th scope="col" *ngFor="let column of extraColumns" sortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th> <th scope="col" *ngFor="let column of extraColumns" appSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
<th scope="col" i18n>Actions</th> <th scope="col" i18n>Actions</th>
</tr> </tr>
</thead> </thead>

View File

@ -120,8 +120,20 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
backdrop: 'static', backdrop: 'static',
}) })
activeModal.componentInstance.dialogMode = 'create' activeModal.componentInstance.dialogMode = 'create'
activeModal.componentInstance.success.subscribe((o) => { activeModal.componentInstance.success.subscribe({
this.reloadData() next: () => {
this.reloadData()
this.toastService.showInfo(
$localize`Successfully created ${this.typeName}.`
)
},
error: (e) => {
this.toastService.showInfo(
$localize`Error occurred while creating ${
this.typeName
} : ${e.toString()}.`
)
},
}) })
} }
@ -131,8 +143,20 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
}) })
activeModal.componentInstance.object = object activeModal.componentInstance.object = object
activeModal.componentInstance.dialogMode = 'edit' activeModal.componentInstance.dialogMode = 'edit'
activeModal.componentInstance.success.subscribe((o) => { activeModal.componentInstance.success.subscribe({
this.reloadData() next: () => {
this.reloadData()
this.toastService.showInfo(
$localize`Successfully updated ${this.typeName}.`
)
},
error: (e) => {
this.toastService.showInfo(
$localize`Error occurred while saving ${
this.typeName
} : ${e.toString()}.`
)
},
}) })
} }

View File

@ -1,12 +1,17 @@
<app-page-header title="Settings" i18n-title> <app-page-header title="Settings" i18n-title>
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()"><ng-container i18n>Start tour</ng-container></button> <button class="btn btn-sm btn-outline-primary" (click)="tourService.start()"><ng-container i18n>Start tour</ng-container></button>
<a class="btn btn-sm btn-primary ms-3" href="admin/" target="_blank">
<ng-container i18n>Open Django Admin</ng-container>
<svg class="sidebaricon ms-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-up-right"/>
</svg>
</a>
</app-page-header> </app-page-header>
<!-- <p>items per page, documents per view type</p> -->
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()"> <form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
<ul ngbNav #nav="ngbNav" class="nav-tabs"> <ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-tabs">
<li [ngbNavItem]="1"> <li [ngbNavItem]="SettingsNavIDs.General">
<a ngbNavLink i18n>General</a> <a ngbNavLink i18n>General</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
@ -19,7 +24,7 @@
<div class="col"> <div class="col">
<select class="form-select" formControlName="displayLanguage"> <select class="form-select" formControlName="displayLanguage">
<option *ngFor="let lang of displayLanguageOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code && currentLocale != 'en-US'"> - {{lang.englishName}}</span></option> <option *ngFor="let lang of displayLanguageOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code && currentLocale !== 'en-US'"> - {{lang.englishName}}</span></option>
</select> </select>
<small *ngIf="displayLanguageIsDirty" class="form-text text-primary" i18n>You need to reload the page after applying a new language.</small> <small *ngIf="displayLanguageIsDirty" class="form-text text-primary" i18n>You need to reload the page after applying a new language.</small>
@ -162,7 +167,7 @@
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="2"> <li [ngbNavItem]="SettingsNavIDs.Notifications">
<a ngbNavLink i18n>Notifications</a> <a ngbNavLink i18n>Notifications</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
@ -180,7 +185,7 @@
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="3"> <li [ngbNavItem]="SettingsNavIDs.SavedViews" (mouseover)="maybeInitializeTab(SettingsNavIDs.SavedViews)" (focusin)="maybeInitializeTab(SettingsNavIDs.SavedViews)">
<a ngbNavLink i18n>Saved views</a> <a ngbNavLink i18n>Saved views</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
@ -210,8 +215,97 @@
</div> </div>
</div> </div>
<div *ngIf="savedViews.length == 0" i18n>No saved views defined.</div> <div *ngIf="savedViews && savedViews.length === 0" i18n>No saved views defined.</div>
<div *ngIf="!savedViews">
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</div>
</div>
</ng-template>
</li>
<li [ngbNavItem]="SettingsNavIDs.Mail" (mouseover)="maybeInitializeTab(SettingsNavIDs.Mail)" (focusin)="maybeInitializeTab(SettingsNavIDs.Mail)">
<a ngbNavLink i18n>Mail</a>
<ng-template ngbNavContent>
<ng-container *ngIf="mailAccounts && mailRules">
<h4>
<ng-container i18n>Mail accounts</ng-container>
<button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailAccount()">
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>
<ng-container i18n>Add Account</ng-container>
</button>
</h4>
<ul class="list-group" formGroupName="mailAccounts">
<li class="list-group-item">
<div class="row">
<div class="col" i18n>Name</div>
<div class="col" i18n>Server</div>
<div class="col" i18n>Actions</div>
</div>
</li>
<li *ngFor="let account of mailAccounts" class="list-group-item" [formGroupName]="account.id">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)">{{account.name}}</button></div>
<div class="col d-flex align-items-center">{{account.imap_server}}</div>
<div class="col">
<div class="btn-group">
<button class="btn btn-sm btn-primary" type="button" (click)="editMailAccount(account)" i18n>Edit</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailAccount(account)" i18n>Delete</button>
</div>
</div>
</div>
</li>
<div *ngIf="mailAccounts.length === 0" i18n>No mail accounts defined.</div>
</ul>
<h4 class="mt-4">
<ng-container i18n>Mail rules</ng-container>
<button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailRule()">
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>
<ng-container i18n>Add Rule</ng-container>
</button>
</h4>
<ul class="list-group" formGroupName="mailRules">
<li class="list-group-item">
<div class="row">
<div class="col" i18n>Name</div>
<div class="col" i18n>Account</div>
<div class="col" i18n>Actions</div>
</div>
</li>
<li *ngFor="let rule of mailRules" class="list-group-item" [formGroupName]="rule.id">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)">{{rule.name}}</button></div>
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
<div class="col">
<div class="btn-group">
<button class="btn btn-sm btn-primary" type="button" (click)="editMailRule(rule)" i18n>Edit</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailRule(rule)" i18n>Delete</button>
</div>
</div>
</div>
</li>
<div *ngIf="mailRules.length === 0" i18n>No mail rules defined.</div>
</ul>
</ng-container>
<div *ngIf="!mailAccounts || !mailRules">
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</div> </div>
</ng-template> </ng-template>
@ -220,5 +314,5 @@
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div> <div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
<button type="submit" class="btn btn-primary mb-2" [disabled]="!(isDirty$ | async)" i18n>Save</button> <button type="submit" class="btn btn-primary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
</form> </form>

View File

@ -26,9 +26,26 @@ import {
Subject, Subject,
} from 'rxjs' } from 'rxjs'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { ViewportScroller } from '@angular/common' import { ViewportScroller } from '@angular/common'
import { TourService } from 'ngx-ui-tour-ng-bootstrap' import { TourService } from 'ngx-ui-tour-ng-bootstrap'
import { PaperlessMailAccount } from 'src/app/data/paperless-mail-account'
import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule'
import { MailAccountService } from 'src/app/services/rest/mail-account.service'
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap'
enum SettingsNavIDs {
General = 1,
Notifications = 2,
SavedViews = 3,
Mail = 4,
UsersGroups = 5,
}
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
@ -38,8 +55,14 @@ import { TourService } from 'ngx-ui-tour-ng-bootstrap'
export class SettingsComponent export class SettingsComponent
implements OnInit, AfterViewInit, OnDestroy, DirtyComponent implements OnInit, AfterViewInit, OnDestroy, DirtyComponent
{ {
SettingsNavIDs = SettingsNavIDs
activeNavID: number
savedViewGroup = new FormGroup({}) savedViewGroup = new FormGroup({})
mailAccountGroup = new FormGroup({})
mailRuleGroup = new FormGroup({})
settingsForm = new FormGroup({ settingsForm = new FormGroup({
bulkEditConfirmationDialogs: new FormControl(null), bulkEditConfirmationDialogs: new FormControl(null),
bulkEditApplyOnClose: new FormControl(null), bulkEditApplyOnClose: new FormControl(null),
@ -50,20 +73,28 @@ export class SettingsComponent
darkModeInvertThumbs: new FormControl(null), darkModeInvertThumbs: new FormControl(null),
themeColor: new FormControl(null), themeColor: new FormControl(null),
useNativePdfViewer: new FormControl(null), useNativePdfViewer: new FormControl(null),
savedViews: this.savedViewGroup,
displayLanguage: new FormControl(null), displayLanguage: new FormControl(null),
dateLocale: new FormControl(null), dateLocale: new FormControl(null),
dateFormat: new FormControl(null), dateFormat: new FormControl(null),
commentsEnabled: new FormControl(null),
updateCheckingEnabled: new FormControl(null),
notificationsConsumerNewDocument: new FormControl(null), notificationsConsumerNewDocument: new FormControl(null),
notificationsConsumerSuccess: new FormControl(null), notificationsConsumerSuccess: new FormControl(null),
notificationsConsumerFailed: new FormControl(null), notificationsConsumerFailed: new FormControl(null),
notificationsConsumerSuppressOnDashboard: new FormControl(null), notificationsConsumerSuppressOnDashboard: new FormControl(null),
commentsEnabled: new FormControl(null),
updateCheckingEnabled: new FormControl(null), savedViews: this.savedViewGroup,
mailAccounts: this.mailAccountGroup,
mailRules: this.mailRuleGroup,
}) })
savedViews: PaperlessSavedView[] savedViews: PaperlessSavedView[]
mailAccounts: PaperlessMailAccount[]
mailRules: PaperlessMailRule[]
store: BehaviorSubject<any> store: BehaviorSubject<any>
storeSub: Subscription storeSub: Subscription
isDirty$: Observable<boolean> isDirty$: Observable<boolean>
@ -81,19 +112,40 @@ export class SettingsComponent
constructor( constructor(
public savedViewService: SavedViewService, public savedViewService: SavedViewService,
public mailAccountService: MailAccountService,
public mailRuleService: MailRuleService,
private documentListViewService: DocumentListViewService, private documentListViewService: DocumentListViewService,
private toastService: ToastService, private toastService: ToastService,
private settings: SettingsService, private settings: SettingsService,
@Inject(LOCALE_ID) public currentLocale: string, @Inject(LOCALE_ID) public currentLocale: string,
private viewportScroller: ViewportScroller, private viewportScroller: ViewportScroller,
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
public readonly tourService: TourService private router: Router,
public readonly tourService: TourService,
private modalService: NgbModal
) { ) {
this.settings.settingsSaved.subscribe(() => { this.settings.settingsSaved.subscribe(() => {
if (!this.savePending) this.initialize() if (!this.savePending) this.initialize()
}) })
} }
ngOnInit() {
this.initialize()
this.activatedRoute.paramMap.subscribe((paramMap) => {
const section = paramMap.get('section')
if (section) {
const navIDKey: string = Object.keys(SettingsNavIDs).find(
(navID) => navID.toLowerCase() == section
)
if (navIDKey) {
this.activeNavID = SettingsNavIDs[navIDKey]
this.maybeInitializeTab(this.activeNavID)
}
}
})
}
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (this.activatedRoute.snapshot.fragment) { if (this.activatedRoute.snapshot.fragment) {
this.viewportScroller.scrollToAnchor( this.viewportScroller.scrollToAnchor(
@ -123,10 +175,13 @@ export class SettingsComponent
useNativePdfViewer: this.settings.get( useNativePdfViewer: this.settings.get(
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
), ),
savedViews: {},
displayLanguage: this.settings.getLanguage(), displayLanguage: this.settings.getLanguage(),
dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE), dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE),
dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT), dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT),
commentsEnabled: this.settings.get(SETTINGS_KEYS.COMMENTS_ENABLED),
updateCheckingEnabled: this.settings.get(
SETTINGS_KEYS.UPDATE_CHECKING_ENABLED
),
notificationsConsumerNewDocument: this.settings.get( notificationsConsumerNewDocument: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
), ),
@ -139,41 +194,147 @@ export class SettingsComponent
notificationsConsumerSuppressOnDashboard: this.settings.get( notificationsConsumerSuppressOnDashboard: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
), ),
commentsEnabled: this.settings.get(SETTINGS_KEYS.COMMENTS_ENABLED), savedViews: {},
updateCheckingEnabled: this.settings.get( mailAccounts: {},
SETTINGS_KEYS.UPDATE_CHECKING_ENABLED mailRules: {},
),
} }
} }
ngOnInit() { onNavChange(navChangeEvent: NgbNavChangeEvent) {
this.savedViewService.listAll().subscribe((r) => { this.maybeInitializeTab(navChangeEvent.nextId)
this.savedViews = r.results const [foundNavIDkey, foundNavIDValue] = Object.entries(
this.initialize() SettingsNavIDs
}) ).find(([navIDkey, navIDValue]) => navIDValue == navChangeEvent.nextId)
if (foundNavIDkey)
// if its dirty we need to wait for confirmation
this.router
.navigate(['settings', foundNavIDkey.toLowerCase()])
.then((navigated) => {
if (!navigated && this.isDirty) {
this.activeNavID = navChangeEvent.activeId
} else if (navigated && this.isDirty) {
this.initialize()
}
})
} }
initialize() { // Load tab contents 'on demand', either on mouseover or focusin (i.e. before click) or called from nav change event
maybeInitializeTab(navID: number): void {
if (navID == SettingsNavIDs.SavedViews && !this.savedViews) {
this.savedViewService.listAll().subscribe((r) => {
this.savedViews = r.results
this.initialize(false)
})
} else if (
navID == SettingsNavIDs.Mail &&
(!this.mailAccounts || !this.mailRules)
) {
this.mailAccountService.listAll().subscribe((r) => {
this.mailAccounts = r.results
this.mailRuleService.listAll().subscribe((r) => {
this.mailRules = r.results
this.initialize(false)
})
})
}
}
initialize(resetSettings: boolean = true) {
this.unsubscribeNotifier.next(true) this.unsubscribeNotifier.next(true)
const currentFormValue = this.settingsForm.value
let storeData = this.getCurrentSettings() let storeData = this.getCurrentSettings()
for (let view of this.savedViews) { if (this.savedViews) {
storeData.savedViews[view.id.toString()] = { for (let view of this.savedViews) {
id: view.id, storeData.savedViews[view.id.toString()] = {
name: view.name, id: view.id,
show_on_dashboard: view.show_on_dashboard, name: view.name,
show_in_sidebar: view.show_in_sidebar, show_on_dashboard: view.show_on_dashboard,
show_in_sidebar: view.show_in_sidebar,
}
this.savedViewGroup.addControl(
view.id.toString(),
new FormGroup({
id: new FormControl(null),
name: new FormControl(null),
show_on_dashboard: new FormControl(null),
show_in_sidebar: new FormControl(null),
})
)
}
}
if (this.mailAccounts && this.mailRules) {
for (let account of this.mailAccounts) {
storeData.mailAccounts[account.id.toString()] = {
id: account.id,
name: account.name,
imap_server: account.imap_server,
imap_port: account.imap_port,
imap_security: account.imap_security,
username: account.username,
password: account.password,
character_set: account.character_set,
}
this.mailAccountGroup.addControl(
account.id.toString(),
new FormGroup({
id: new FormControl(null),
name: new FormControl(null),
imap_server: new FormControl(null),
imap_port: new FormControl(null),
imap_security: new FormControl(null),
username: new FormControl(null),
password: new FormControl(null),
character_set: new FormControl(null),
})
)
}
for (let rule of this.mailRules) {
storeData.mailRules[rule.id.toString()] = {
name: rule.name,
account: rule.account,
folder: rule.folder,
filter_from: rule.filter_from,
filter_subject: rule.filter_subject,
filter_body: rule.filter_body,
filter_attachment_filename: rule.filter_attachment_filename,
maximum_age: rule.maximum_age,
attachment_type: rule.attachment_type,
action: rule.action,
action_parameter: rule.action_parameter,
assign_title_from: rule.assign_title_from,
assign_tags: rule.assign_tags,
assign_document_type: rule.assign_document_type,
assign_correspondent_from: rule.assign_correspondent_from,
assign_correspondent: rule.assign_correspondent,
}
this.mailRuleGroup.addControl(
rule.id.toString(),
new FormGroup({
name: new FormControl(null),
account: new FormControl(null),
folder: new FormControl(null),
filter_from: new FormControl(null),
filter_subject: new FormControl(null),
filter_body: new FormControl(null),
filter_attachment_filename: new FormControl(null),
maximum_age: new FormControl(null),
attachment_type: new FormControl(null),
action: new FormControl(null),
action_parameter: new FormControl(null),
assign_title_from: new FormControl(null),
assign_tags: new FormControl(null),
assign_document_type: new FormControl(null),
assign_correspondent_from: new FormControl(null),
assign_correspondent: new FormControl(null),
})
)
} }
this.savedViewGroup.addControl(
view.id.toString(),
new FormGroup({
id: new FormControl(null),
name: new FormControl(null),
show_on_dashboard: new FormControl(null),
show_in_sidebar: new FormControl(null),
})
)
} }
this.store = new BehaviorSubject(storeData) this.store = new BehaviorSubject(storeData)
@ -202,6 +363,11 @@ export class SettingsComponent
this.settingsForm.get('themeColor').value this.settingsForm.get('themeColor').value
) )
}) })
if (!resetSettings && currentFormValue) {
// prevents loss of unsaved changes
this.settingsForm.patchValue(currentFormValue)
}
} }
ngOnDestroy() { ngOnDestroy() {
@ -372,4 +538,121 @@ export class SettingsComponent
clearThemeColor() { clearThemeColor() {
this.settingsForm.get('themeColor').patchValue('') this.settingsForm.get('themeColor').patchValue('')
} }
editMailAccount(account: PaperlessMailAccount) {
const modal = this.modalService.open(MailAccountEditDialogComponent, {
backdrop: 'static',
size: 'xl',
})
modal.componentInstance.dialogMode = account ? 'edit' : 'create'
modal.componentInstance.object = account
modal.componentInstance.success
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (newMailAccount) => {
this.toastService.showInfo(
$localize`Saved account "${newMailAccount.name}".`
)
this.mailAccountService.clearCache()
this.mailAccountService.listAll().subscribe((r) => {
this.mailAccounts = r.results
this.initialize()
})
},
error: (e) => {
this.toastService.showError(
$localize`Error saving account: ${e.toString()}.`
)
},
})
}
deleteMailAccount(account: PaperlessMailAccount) {
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm delete mail account`
modal.componentInstance.messageBold = $localize`This operation will permanently delete this mail account.`
modal.componentInstance.message = $localize`This operation cannot be undone.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.mailAccountService.delete(account).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted mail account`)
this.mailAccountService.clearCache()
this.mailAccountService.listAll().subscribe((r) => {
this.mailAccounts = r.results
this.initialize()
})
},
error: (e) => {
this.toastService.showError(
$localize`Error deleting mail account: ${e.toString()}.`
)
},
})
})
}
editMailRule(rule: PaperlessMailRule) {
const modal = this.modalService.open(MailRuleEditDialogComponent, {
backdrop: 'static',
size: 'xl',
})
modal.componentInstance.dialogMode = rule ? 'edit' : 'create'
modal.componentInstance.object = rule
modal.componentInstance.success
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (newMailRule) => {
this.toastService.showInfo(
$localize`Saved rule "${newMailRule.name}".`
)
this.mailRuleService.clearCache()
this.mailRuleService.listAll().subscribe((r) => {
this.mailRules = r.results
this.initialize()
})
},
error: (e) => {
this.toastService.showError(
$localize`Error saving rule: ${e.toString()}.`
)
},
})
}
deleteMailRule(rule: PaperlessMailRule) {
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm delete mail rule`
modal.componentInstance.messageBold = $localize`This operation will permanently delete this mail rule.`
modal.componentInstance.message = $localize`This operation cannot be undone.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.mailRuleService.delete(rule).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted mail rule`)
this.mailRuleService.clearCache()
this.mailRuleService.listAll().subscribe((r) => {
this.mailRules = r.results
this.initialize()
})
},
error: (e) => {
this.toastService.showError(
$localize`Error deleting mail rule: ${e.toString()}.`
)
},
})
})
}
} }

View File

@ -1,11 +1,11 @@
<app-page-header title="File Tasks" i18n-title> <app-page-header title="File Tasks" i18n-title>
<div class="btn-toolbar col col-md-auto"> <div class="btn-toolbar col col-md-auto">
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size == 0"> <button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#x"/> <use xlink:href="assets/bootstrap-icons.svg#x"/>
</svg>&nbsp;<ng-container i18n>Clear selection</ng-container> </svg>&nbsp;<ng-container i18n>Clear selection</ng-container>
</button> </button>
<button class="btn btn-sm btn-outline-primary me-4" (click)="dismissTasks()" [disabled]="tasksService.total == 0"> <button class="btn btn-sm btn-outline-primary me-4" (click)="dismissTasks()" [disabled]="tasksService.total === 0">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#check2-all"/> <use xlink:href="assets/bootstrap-icons.svg#check2-all"/>
</svg>&nbsp;<ng-container i18n>{{dismissButtonText}}</ng-container> </svg>&nbsp;<ng-container i18n>{{dismissButtonText}}</ng-container>
@ -33,13 +33,13 @@
<tr> <tr>
<th scope="col"> <th scope="col">
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length == 0" (click)="toggleAll($event); $event.stopPropagation();"> <input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<label class="form-check-label" for="all-tasks"></label> <label class="form-check-label" for="all-tasks"></label>
</div> </div>
</th> </th>
<th scope="col" i18n>Name</th> <th scope="col" i18n>Name</th>
<th scope="col" class="d-none d-lg-table-cell" i18n>Created</th> <th scope="col" class="d-none d-lg-table-cell" i18n>Created</th>
<th scope="col" class="d-none d-lg-table-cell" *ngIf="activeTab != 'started' && activeTab != 'queued'" i18n>Results</th> <th scope="col" class="d-none d-lg-table-cell" *ngIf="activeTab !== 'started' && activeTab !== 'queued'" i18n>Results</th>
<th scope="col" class="d-table-cell d-lg-none" i18n>Info</th> <th scope="col" class="d-table-cell d-lg-none" i18n>Info</th>
<th scope="col" i18n>Actions</th> <th scope="col" i18n>Actions</th>
</tr> </tr>
@ -55,7 +55,7 @@
</th> </th>
<td class="overflow-auto">{{ task.task_file_name }}</td> <td class="overflow-auto">{{ task.task_file_name }}</td>
<td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td> <td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td>
<td class="d-none d-lg-table-cell" *ngIf="activeTab != 'started' && activeTab != 'queued'"> <td class="d-none d-lg-table-cell" *ngIf="activeTab !== 'started' && activeTab !== 'queued'">
<div *ngIf="task.result?.length > 50" class="result" (click)="expandTask(task); $event.stopPropagation();" <div *ngIf="task.result?.length > 50" class="result" (click)="expandTask(task); $event.stopPropagation();"
[ngbPopover]="resultPopover" popoverClass="shadow small mobile" triggers="mouseenter:mouseleave" container="body"> [ngbPopover]="resultPopover" popoverClass="shadow small mobile" triggers="mouseenter:mouseleave" container="body">
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result | slice:0:50 }}&hellip;</span> <span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result | slice:0:50 }}&hellip;</span>
@ -89,7 +89,7 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td class="p-0" [class.border-0]="expandedTask != task.id" colspan="5"> <td class="p-0" [class.border-0]="expandedTask !== task.id" colspan="5">
<pre #collapse="ngbCollapse" [ngbCollapse]="expandedTask !== task.id" class="small mb-0"><div class="small p-1 p-lg-3 ms-lg-3">{{ task.result }}</div></pre> <pre #collapse="ngbCollapse" [ngbCollapse]="expandedTask !== task.id" class="small mb-0"><div class="small p-1 p-lg-3 ms-lg-3">{{ task.result }}</div></pre>
</td> </td>
</tr> </tr>

View File

@ -1,12 +1,10 @@
import { Component, OnInit } from '@angular/core' import { Component } from '@angular/core'
@Component({ @Component({
selector: 'app-not-found', selector: 'app-not-found',
templateUrl: './not-found.component.html', templateUrl: './not-found.component.html',
styleUrls: ['./not-found.component.scss'], styleUrls: ['./not-found.component.scss'],
}) })
export class NotFoundComponent implements OnInit { export class NotFoundComponent {
constructor() {} constructor() {}
ngOnInit(): void {}
} }

View 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
}

View 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
}

View File

@ -1,4 +1,11 @@
import { Directive, EventEmitter, Input, Output } from '@angular/core' import {
Directive,
EventEmitter,
HostBinding,
HostListener,
Input,
Output,
} from '@angular/core'
export interface SortEvent { export interface SortEvent {
column: string column: string
@ -6,18 +13,13 @@ export interface SortEvent {
} }
@Directive({ @Directive({
selector: 'th[sortable]', selector: 'th[appSortable]',
host: {
'[class.asc]': 'currentSortField == sortable && !currentSortReverse',
'[class.des]': 'currentSortField == sortable && currentSortReverse',
'(click)': 'rotate()',
},
}) })
export class SortableDirective { export class SortableDirective {
constructor() {} constructor() {}
@Input() @Input()
sortable: string = '' appSortable: string = ''
@Input() @Input()
currentSortReverse: boolean = false currentSortReverse: boolean = false
@ -27,11 +29,20 @@ export class SortableDirective {
@Output() sort = new EventEmitter<SortEvent>() @Output() sort = new EventEmitter<SortEvent>()
rotate() { @HostBinding('class.asc') get asc() {
if (this.currentSortField != this.sortable) { return (
this.sort.emit({ column: this.sortable, reverse: false }) this.currentSortField === this.appSortable && !this.currentSortReverse
)
}
@HostBinding('class.des') get des() {
return this.currentSortField === this.appSortable && this.currentSortReverse
}
@HostListener('click') rotate() {
if (this.currentSortField != this.appSortable) {
this.sort.emit({ column: this.appSortable, reverse: false })
} else if ( } else if (
this.currentSortField == this.sortable && this.currentSortField == this.appSortable &&
!this.currentSortReverse !this.currentSortReverse
) { ) {
this.sort.emit({ column: this.currentSortField, reverse: true }) this.sort.emit({ column: this.currentSortField, reverse: true })

View File

@ -1,7 +1,6 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { DirtyCheckGuard } from '@ngneat/dirty-check-forms' import { DirtyCheckGuard } from '@ngneat/dirty-check-forms'
import { Observable, Subject } from 'rxjs' import { Observable, Subject } from 'rxjs'
import { map } from 'rxjs/operators'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'

View File

@ -213,7 +213,8 @@ export class DocumentListViewService {
this.currentPageSize, this.currentPageSize,
activeListViewState.sortField, activeListViewState.sortField,
activeListViewState.sortReverse, activeListViewState.sortReverse,
activeListViewState.filterRules activeListViewState.filterRules,
{ truncate_content: true }
) )
.subscribe({ .subscribe({
next: (result) => { next: (result) => {

View File

@ -6,7 +6,6 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { Observable, Subject, of } from 'rxjs' import { Observable, Subject, of } from 'rxjs'
import { first } from 'rxjs/operators' import { first } from 'rxjs/operators'
import { Router } from '@angular/router'
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -16,8 +15,7 @@ export class OpenDocumentsService {
constructor( constructor(
private documentService: DocumentService, private documentService: DocumentService,
private modalService: NgbModal, private modalService: NgbModal
private router: Router
) { ) {
if (sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS)) { if (sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS)) {
try { try {
@ -57,39 +55,28 @@ export class OpenDocumentsService {
return this.openDocuments.find((d) => d.id == id) return this.openDocuments.find((d) => d.id == id)
} }
openDocument( openDocument(doc: PaperlessDocument): Observable<boolean> {
doc: PaperlessDocument,
navigate: boolean = true
): Observable<boolean> {
if (this.openDocuments.find((d) => d.id == doc.id) == null) { if (this.openDocuments.find((d) => d.id == doc.id) == null) {
if (this.openDocuments.length == this.MAX_OPEN_DOCUMENTS) { if (this.openDocuments.length == this.MAX_OPEN_DOCUMENTS) {
// at max, ensure changes arent lost // at max, ensure changes arent lost
const docToRemove = this.openDocuments[this.MAX_OPEN_DOCUMENTS - 1] const docToRemove = this.openDocuments[this.MAX_OPEN_DOCUMENTS - 1]
const closeObservable = this.closeDocument(docToRemove) const closeObservable = this.closeDocument(docToRemove)
closeObservable.pipe(first()).subscribe((closed) => { closeObservable.pipe(first()).subscribe((closed) => {
if (closed) this.finishOpenDocument(doc, navigate) if (closed) this.finishOpenDocument(doc)
}) })
return closeObservable return closeObservable
} else { } else {
// not at max // not at max
this.finishOpenDocument(doc, navigate) this.finishOpenDocument(doc)
}
} else {
// doc is open, just maybe navigate
if (navigate) {
this.router.navigate(['documents', doc.id])
} }
} }
return of(true) return of(true)
} }
private finishOpenDocument(doc: PaperlessDocument, navigate: boolean) { private finishOpenDocument(doc: PaperlessDocument) {
this.openDocuments.unshift(doc) this.openDocuments.unshift(doc)
this.dirtyDocuments.delete(doc.id) this.dirtyDocuments.delete(doc.id)
this.save() this.save()
if (navigate) {
this.router.navigate(['documents', doc.id])
}
} }
setDirty(doc: PaperlessDocument, dirty: boolean) { setDirty(doc: PaperlessDocument, dirty: boolean) {

View File

@ -174,10 +174,18 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument>
) )
} }
bulkDownload(ids: number[], content = 'both') { bulkDownload(
ids: number[],
content = 'both',
useFilenameFormatting: boolean = false
) {
return this.http.post( return this.http.post(
this.getResourceUrl(null, 'bulk_download'), this.getResourceUrl(null, 'bulk_download'),
{ documents: ids, content: content }, {
documents: ids,
content: content,
follow_formatting: useFilenameFormatting,
},
{ responseType: 'blob' } { responseType: 'blob' }
) )
} }

View 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()))
}
}

View 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()))
}
}

View File

@ -5,7 +5,7 @@ export const environment = {
apiBaseUrl: document.baseURI + 'api/', apiBaseUrl: document.baseURI + 'api/',
apiVersion: '2', apiVersion: '2',
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
version: '1.10.2', version: '1.10.2-dev',
webSocketHost: window.location.host, webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/', webSocketBaseUrl: base_url.pathname + 'ws/',

Some files were not shown because too many files have changed in this diff Show More