Merge branch 'dev' into feature-consume-eml

This commit is contained in:
phail 2022-05-20 19:29:52 +02:00
commit d0a0ae91c4
189 changed files with 34855 additions and 17114 deletions

View File

@ -7,34 +7,34 @@ body:
attributes:
value: |
Have a question? 👉 [Start a new discussion](https://github.com/paperless-ngx/paperless-ngx/discussions/new) or [ask in chat](https://matrix.to/#/#paperless:adnidor.de).
Before opening an issue, please check [the documentation](https://paperless-ngx.readthedocs.io/en/latest/troubleshooting.html) and see if it helps you resolve your issue. Please also make sure that you followed the installation instructions.
If you encounter issues while installing or configuring Paperless-ngx, please post in the ["Support" section of the discussions](https://github.com/paperless-ngx/paperless-ngx/discussions/new?category=support). Remember that Paperless successfully runs on a variety of different systems. If Paperless-ngx does not start, it's likely an issue with your system, not an issue of Paperless-ngx.
Finally, please search issues and discussions before opening a new bug report.
Before opening an issue, please double check:
- [The troubleshooting documentation](https://paperless-ngx.readthedocs.io/en/latest/troubleshooting.html).
- [The installation instructions](https://paperless-ngx.readthedocs.io/en/latest/setup.html#installation).
- [Existing issues and discussions](https://github.com/paperless-ngx/paperless-ngx/search?q=&type=issues).
If you encounter issues while installing or configuring Paperless-ngx, please post in the ["Support" section of the discussions](https://github.com/paperless-ngx/paperless-ngx/discussions/new?category=support).
- type: textarea
id: description
attributes:
label: Description
description: A clear and concise description of what the bug is.
placeholder: Currently...
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen.
placeholder: In this situation...
description: A clear and concise description of what the bug is. If applicable, add screenshots to help explain your problem.
placeholder: |
Currently Paperless does not work when...
[Screenshot if applicable]
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Steps to reproduce the behavior
placeholder: "1. Go to '...', 2. Click on '....', 3. See error"
description: Steps to reproduce the behavior.
placeholder: |
1. Go to '...'
2. Click on '....'
3. See error
validations:
required: true
- type: textarea
@ -43,11 +43,6 @@ body:
label: Webserver logs
description: If available, post any logs from the web server related to your issue.
render: bash
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem.
- type: input
id: version
attributes:
@ -59,8 +54,8 @@ body:
id: host-os
attributes:
label: Host OS
description: Host OS of the machine running paperless-ngx
placeholder: e.g. Archlinux / Ubuntu 20.04
description: Host OS of the machine running paperless-ngx. Please add the architecture (uname -m) if applicable.
placeholder: e.g. Archlinux / Ubuntu 20.04 / Raspberry Pi `arm64`
validations:
required: true
- type: dropdown
@ -77,7 +72,7 @@ body:
id: browser
attributes:
label: Browser
description: Which browser you are using, if relevant
description: Which browser you are using, if relevant.
placeholder: e.g. Chrome, Safari
- type: input
id: config-changes
@ -88,4 +83,4 @@ body:
id: other
attributes:
label: Other
description: Any other relevant details
description: Any other relevant details.

View File

@ -6,11 +6,14 @@ updates:
# Enable version updates for npm
- package-ecosystem: "npm"
target-branch: "dev"
# Look for `package.json` and `lock` files in the `root` directory
# Look for `package.json` and `lock` files in the `/src-ui` directory
directory: "/src-ui"
# Check the npm registry for updates every month
schedule:
interval: "monthly"
labels:
- "frontend"
- "dependencies"
# Add reviewers
reviewers:
- "paperless-ngx/frontend"
@ -26,9 +29,13 @@ updates:
labels:
- "backend"
- "dependencies"
# Add reviewers
reviewers:
- "paperless-ngx/backend"
# Enable updates for Github Actions
- package-ecosystem: "github-actions"
target-branch: "dev"
directory: "/"
schedule:
# Check for updates to GitHub Actions every month
@ -38,4 +45,4 @@ updates:
- "dependencies"
# Add reviewers
reviewers:
- "paperless-ngx/backend"
- "paperless-ngx/ci-cd"

View File

@ -28,9 +28,10 @@ include-labels:
replacers: # Changes "Feature: Update checker" to "Update checker"
- search: '/Feature:|Feat:|\[feature\]/gi'
replace: ''
change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
category-template: '### $TITLE'
change-template: '- $TITLE [@$AUTHOR](https://github.com/$AUTHOR) ([#$NUMBER]($URL))'
change-title-escapes: '\<*_&#@'
template: |
# Changelog
## paperless-ngx $RESOLVED_VERSION
$CHANGES

View File

@ -57,6 +57,12 @@ jobs:
name: Prepare Docker Pipeline Data
if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v'))
runs-on: ubuntu-20.04
# If the push triggered the installer library workflow, wait for it to
# complete here. This ensures the required versions for the final
# image have been built, while not waiting at all if the versions haven't changed
concurrency:
group: build-installer-library
cancel-in-progress: false
needs:
- documentation
- ci-backend
@ -117,55 +123,6 @@ jobs:
jbig2enc-json: ${{ steps.jbig2enc-setup.outputs.jbig2enc-json}}
build-qpdf-debs:
name: qpdf
needs:
- prepare-docker-build
uses: ./.github/workflows/reusable-workflow-builder.yml
with:
dockerfile: ./docker-builders/Dockerfile.qpdf
build-json: ${{ needs.prepare-docker-build.outputs.qpdf-json }}
build-args: |
QPDF_VERSION=${{ fromJSON(needs.prepare-docker-build.outputs.qpdf-json).version }}
build-jbig2enc:
name: jbig2enc
needs:
- prepare-docker-build
uses: ./.github/workflows/reusable-workflow-builder.yml
with:
dockerfile: ./docker-builders/Dockerfile.jbig2enc
build-json: ${{ needs.prepare-docker-build.outputs.jbig2enc-json }}
build-args: |
JBIG2ENC_VERSION=${{ fromJSON(needs.prepare-docker-build.outputs.jbig2enc-json).version }}
build-psycopg2-wheel:
name: psycopg2
needs:
- prepare-docker-build
uses: ./.github/workflows/reusable-workflow-builder.yml
with:
dockerfile: ./docker-builders/Dockerfile.psycopg2
build-json: ${{ needs.prepare-docker-build.outputs.psycopg2-json }}
build-args: |
PSYCOPG2_GIT_TAG=${{ fromJSON(needs.prepare-docker-build.outputs.psycopg2-json).git_tag }}
PSYCOPG2_VERSION=${{ fromJSON(needs.prepare-docker-build.outputs.psycopg2-json).version }}
build-pikepdf-wheel:
name: pikepdf
needs:
- prepare-docker-build
- build-qpdf-debs
uses: ./.github/workflows/reusable-workflow-builder.yml
with:
dockerfile: ./docker-builders/Dockerfile.pikepdf
build-json: ${{ needs.prepare-docker-build.outputs.pikepdf-json }}
build-args: |
REPO=${{ github.repository }}
QPDF_VERSION=${{ fromJSON(needs.prepare-docker-build.outputs.qpdf-json).version }}
PIKEPDF_GIT_TAG=${{ fromJSON(needs.prepare-docker-build.outputs.pikepdf-json).git_tag }}
PIKEPDF_VERSION=${{ fromJSON(needs.prepare-docker-build.outputs.pikepdf-json).version }}
# build and push image to docker hub.
build-docker-image:
runs-on: ubuntu-20.04
@ -174,10 +131,6 @@ jobs:
cancel-in-progress: true
needs:
- prepare-docker-build
- build-psycopg2-wheel
- build-jbig2enc
- build-qpdf-debs
- build-pikepdf-wheel
steps:
-
name: Check pushing to Docker Hub
@ -381,3 +334,21 @@ jobs:
asset_path: ./paperless-ngx.tar.xz
asset_name: paperless-ngx-${{ steps.get_version.outputs.version }}.tar.xz
asset_content_type: application/x-xz
-
name: Checkout
uses: actions/checkout@v3
with:
ref: main
-
name: Append Changelog to docs
id: append-Changelog
working-directory: docs
run: |
echo -e "# Changelog\n\n${{ steps.create-release.outputs.body }}\n" > changelog-new.md
CURRENT_CHANGELOG=`tail --lines +2 changelog.md`
echo -e "$CURRENT_CHANGELOG" >> changelog-new.md
mv changelog-new.md changelog.md
git config --global user.name "github-actions"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "Changelog ${{ steps.get_version.outputs.version }} - GHA"
git push origin HEAD:main

141
.github/workflows/installer-library.yml vendored Normal file
View File

@ -0,0 +1,141 @@
# This workflow will run to update the installer library of
# Docker images. These are the images which provide updated wheels
# .deb installation packages or maybe just some compiled library
name: Build Image Library
on:
push:
# Must match one of these branches AND one of the paths
# to be triggered
branches:
- "main"
- "dev"
- "library-*"
- "feature-*"
paths:
# Trigger the workflow if a Dockerfile changed
- "docker-builders/**"
# Trigger if a package was updated
- ".build-config.json"
- "Pipfile.lock"
# Also trigger on workflow changes related to the library
- ".github/workflows/installer-library.yml"
- ".github/workflows/reusable-workflow-builder.yml"
- ".github/scripts/**"
# Set a workflow level concurrency group so primary workflow
# can wait for this to complete if needed
# DO NOT CHANGE without updating main workflow group
concurrency:
group: build-installer-library
cancel-in-progress: false
jobs:
prepare-docker-build:
name: Prepare Docker Image Version Data
runs-on: ubuntu-20.04
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Set up Python
uses: actions/setup-python@v3
with:
python-version: "3.9"
-
name: Setup qpdf image
id: qpdf-setup
run: |
build_json=$(python ${GITHUB_WORKSPACE}/.github/scripts/get-build-json.py qpdf)
echo ${build_json}
echo ::set-output name=qpdf-json::${build_json}
-
name: Setup psycopg2 image
id: psycopg2-setup
run: |
build_json=$(python ${GITHUB_WORKSPACE}/.github/scripts/get-build-json.py psycopg2)
echo ${build_json}
echo ::set-output name=psycopg2-json::${build_json}
-
name: Setup pikepdf image
id: pikepdf-setup
run: |
build_json=$(python ${GITHUB_WORKSPACE}/.github/scripts/get-build-json.py pikepdf)
echo ${build_json}
echo ::set-output name=pikepdf-json::${build_json}
-
name: Setup jbig2enc image
id: jbig2enc-setup
run: |
build_json=$(python ${GITHUB_WORKSPACE}/.github/scripts/get-build-json.py jbig2enc)
echo ${build_json}
echo ::set-output name=jbig2enc-json::${build_json}
outputs:
qpdf-json: ${{ steps.qpdf-setup.outputs.qpdf-json }}
pikepdf-json: ${{ steps.pikepdf-setup.outputs.pikepdf-json }}
psycopg2-json: ${{ steps.psycopg2-setup.outputs.psycopg2-json }}
jbig2enc-json: ${{ steps.jbig2enc-setup.outputs.jbig2enc-json}}
build-qpdf-debs:
name: qpdf
needs:
- prepare-docker-build
uses: ./.github/workflows/reusable-workflow-builder.yml
with:
dockerfile: ./docker-builders/Dockerfile.qpdf
build-json: ${{ needs.prepare-docker-build.outputs.qpdf-json }}
build-args: |
QPDF_VERSION=${{ fromJSON(needs.prepare-docker-build.outputs.qpdf-json).version }}
build-jbig2enc:
name: jbig2enc
needs:
- prepare-docker-build
uses: ./.github/workflows/reusable-workflow-builder.yml
with:
dockerfile: ./docker-builders/Dockerfile.jbig2enc
build-json: ${{ needs.prepare-docker-build.outputs.jbig2enc-json }}
build-args: |
JBIG2ENC_VERSION=${{ fromJSON(needs.prepare-docker-build.outputs.jbig2enc-json).version }}
build-psycopg2-wheel:
name: psycopg2
needs:
- prepare-docker-build
uses: ./.github/workflows/reusable-workflow-builder.yml
with:
dockerfile: ./docker-builders/Dockerfile.psycopg2
build-json: ${{ needs.prepare-docker-build.outputs.psycopg2-json }}
build-args: |
PSYCOPG2_GIT_TAG=${{ fromJSON(needs.prepare-docker-build.outputs.psycopg2-json).git_tag }}
PSYCOPG2_VERSION=${{ fromJSON(needs.prepare-docker-build.outputs.psycopg2-json).version }}
build-pikepdf-wheel:
name: pikepdf
needs:
- prepare-docker-build
- build-qpdf-debs
uses: ./.github/workflows/reusable-workflow-builder.yml
with:
dockerfile: ./docker-builders/Dockerfile.pikepdf
build-json: ${{ needs.prepare-docker-build.outputs.pikepdf-json }}
build-args: |
REPO=${{ github.repository }}
QPDF_VERSION=${{ fromJSON(needs.prepare-docker-build.outputs.qpdf-json).version }}
PIKEPDF_GIT_TAG=${{ fromJSON(needs.prepare-docker-build.outputs.pikepdf-json).git_tag }}
PIKEPDF_VERSION=${{ fromJSON(needs.prepare-docker-build.outputs.pikepdf-json).version }}

7
.gitignore vendored
View File

@ -63,6 +63,8 @@ target/
# VS Code
.vscode
/src-ui/.vscode
/docs/.vscode
# Other stuff that doesn't belong
.virtualenv
@ -84,8 +86,9 @@ scripts/nuke
/paperless.conf
/consume/
/export/
/src-ui/.vscode
# this is where the compiled frontend is moved to.
/src/documents/static/frontend/
/docs/.vscode/settings.json
# mac os
.DS_Store

View File

@ -37,7 +37,7 @@ repos:
exclude: "(^Pipfile\\.lock$)"
# Python hooks
- repo: https://github.com/asottile/reorder_python_imports
rev: v3.0.1
rev: v3.1.0
hooks:
- id: reorder-python-imports
exclude: "(migrations)"
@ -62,6 +62,13 @@ repos:
rev: 22.3.0
hooks:
- id: black
- repo: https://github.com/asottile/pyupgrade
rev: v2.32.1
hooks:
- id: pyupgrade
exclude: "(migrations)"
args:
- "--py38-plus"
# Dockerfile hooks
- repo: https://github.com/AleksaC/hadolint-py
rev: v2.10.0

View File

@ -1,3 +1,5 @@
# syntax=docker/dockerfile:1.4
# Pull the installer images from the library
# These are all built previously
# They provide either a .deb or .whl
@ -24,7 +26,7 @@ COPY ./src-ui /src/src-ui
WORKDIR /src/src-ui
RUN set -eux \
&& npm update npm -g \
&& npm ci --no-optional
&& npm ci --omit=optional
RUN set -eux \
&& ./node_modules/.bin/ng build --configuration production
@ -38,11 +40,16 @@ LABEL org.opencontainers.image.licenses="GPL-3.0-only"
ARG DEBIAN_FRONTEND=noninteractive
# Packages needed only for building
ARG BUILD_PACKAGES="\
build-essential \
git \
python3-dev"
#
# Begin installation and configuration
# Order the steps below from least often changed to most
#
# copy jbig2enc
# Basically will never change again
COPY --from=jbig2enc-builder /usr/src/jbig2enc/src/.libs/libjbig2enc* /usr/local/lib/
COPY --from=jbig2enc-builder /usr/src/jbig2enc/src/jbig2 /usr/local/bin/
COPY --from=jbig2enc-builder /usr/src/jbig2enc/src/*.h /usr/local/include/
# Packages need for running
ARG RUNTIME_PACKAGES="\
@ -94,45 +101,81 @@ ARG RUNTIME_PACKAGES="\
libzbar0 \
poppler-utils"
WORKDIR /usr/src/paperless/src/
# Install basic runtime packages.
# These change very infrequently
RUN set -eux \
echo "Installing system packages" \
&& apt-get update \
&& apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES} \
&& rm -rf /var/lib/apt/lists/* \
&& echo "Installing supervisor" \
&& python3 -m pip install --default-timeout=1000 --upgrade --no-cache-dir supervisor==4.2.4
# Copy qpdf and runtime library
COPY --from=qpdf-builder /usr/src/qpdf/libqpdf28_*.deb ./
COPY --from=qpdf-builder /usr/src/qpdf/qpdf_*.deb ./
# Copy gunicorn config
# Changes very infrequently
WORKDIR /usr/src/paperless/
# Copy pikepdf wheel and dependencies
COPY --from=pikepdf-builder /usr/src/pikepdf/wheels/*.whl ./
COPY gunicorn.conf.py .
# Copy psycopg2 wheel
COPY --from=psycopg2-builder /usr/src/psycopg2/wheels/psycopg2*.whl ./
# setup docker-specific things
# Use mounts to avoid copying installer files into the image
# These change sometimes, but rarely
WORKDIR /usr/src/paperless/src/docker/
# copy jbig2enc
COPY --from=jbig2enc-builder /usr/src/jbig2enc/src/.libs/libjbig2enc* /usr/local/lib/
COPY --from=jbig2enc-builder /usr/src/jbig2enc/src/jbig2 /usr/local/bin/
COPY --from=jbig2enc-builder /usr/src/jbig2enc/src/*.h /usr/local/include/
RUN --mount=type=bind,readwrite,source=docker,target=./ \
set -eux \
&& echo "Configuring ImageMagick" \
&& cp imagemagick-policy.xml /etc/ImageMagick-6/policy.xml \
&& echo "Configuring supervisord" \
&& mkdir /var/log/supervisord /var/run/supervisord \
&& cp supervisord.conf /etc/supervisord.conf \
&& echo "Setting up Docker scripts" \
&& cp docker-entrypoint.sh /sbin/docker-entrypoint.sh \
&& chmod 755 /sbin/docker-entrypoint.sh \
&& cp docker-prepare.sh /sbin/docker-prepare.sh \
&& chmod 755 /sbin/docker-prepare.sh \
&& cp wait-for-redis.py /sbin/wait-for-redis.py \
&& chmod 755 /sbin/wait-for-redis.py \
&& echo "Installing managment commands" \
&& chmod +x install_management_commands.sh \
&& ./install_management_commands.sh
COPY requirements.txt ../
# Install the built packages from the installer library images
# Use mounts to avoid copying installer files into the image
# These change sometimes
RUN --mount=type=bind,from=qpdf-builder,target=/qpdf \
--mount=type=bind,from=psycopg2-builder,target=/psycopg2 \
--mount=type=bind,from=pikepdf-builder,target=/pikepdf \
set -eux \
&& echo "Installing qpdf" \
&& apt-get install --yes --no-install-recommends /qpdf/usr/src/qpdf/libqpdf28_*.deb \
&& apt-get install --yes --no-install-recommends /qpdf/usr/src/qpdf/qpdf_*.deb \
&& echo "Installing pikepdf and dependencies" \
&& python3 -m pip install --no-cache-dir /pikepdf/usr/src/pikepdf/wheels/packaging*.whl \
&& python3 -m pip install --no-cache-dir /pikepdf/usr/src/pikepdf/wheels/lxml*.whl \
&& python3 -m pip install --no-cache-dir /pikepdf/usr/src/pikepdf/wheels/Pillow*.whl \
&& python3 -m pip install --no-cache-dir /pikepdf/usr/src/pikepdf/wheels/pyparsing*.whl \
&& python3 -m pip install --no-cache-dir /pikepdf/usr/src/pikepdf/wheels/pikepdf*.whl \
&& python -m pip list \
&& echo "Installing psycopg2" \
&& python3 -m pip install --no-cache-dir /psycopg2/usr/src/psycopg2/wheels/psycopg2*.whl \
&& python -m pip list
# Python dependencies
# Change pretty frequently
COPY requirements.txt ../
# Packages needed only for building a few quick Python
# dependencies
ARG BUILD_PACKAGES="\
build-essential \
python3-dev"
RUN set -eux \
&& apt-get update \
&& apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES} ${BUILD_PACKAGES} \
&& python3 -m pip install --no-cache-dir --upgrade wheel \
&& echo "Installing qpdf" \
&& apt-get install --yes --no-install-recommends ./libqpdf28_*.deb \
&& apt-get install --yes --no-install-recommends ./qpdf_*.deb \
&& echo "Installing pikepdf and dependencies wheel" \
&& python3 -m pip install --no-cache-dir packaging*.whl \
&& python3 -m pip install --no-cache-dir lxml*.whl \
&& python3 -m pip install --no-cache-dir Pillow*.whl \
&& python3 -m pip install --no-cache-dir pyparsing*.whl \
&& python3 -m pip install --no-cache-dir pikepdf*.whl \
&& python -m pip list \
&& echo "Installing psycopg2 wheel" \
&& python3 -m pip install --no-cache-dir psycopg2*.whl \
&& python -m pip list \
&& echo "Installing supervisor" \
&& python3 -m pip install --default-timeout=1000 --upgrade --no-cache-dir supervisor \
&& echo "Installing build system packages" \
&& apt-get update \
&& apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \
&& python3 -m pip install --no-cache-dir --upgrade wheel \
&& echo "Installing Python requirements" \
&& python3 -m pip install --default-timeout=1000 --no-cache-dir -r ../requirements.txt \
&& echo "Cleaning up image" \
@ -145,28 +188,6 @@ RUN set -eux \
&& rm -rf /var/cache/apt/archives/* \
&& truncate -s 0 /var/log/*log
# setup docker-specific things
COPY docker/ ./docker/
WORKDIR /usr/src/paperless/src/docker/
RUN set -eux \
&& cp imagemagick-policy.xml /etc/ImageMagick-6/policy.xml \
&& mkdir /var/log/supervisord /var/run/supervisord \
&& cp supervisord.conf /etc/supervisord.conf \
&& cp docker-entrypoint.sh /sbin/docker-entrypoint.sh \
&& chmod 755 /sbin/docker-entrypoint.sh \
&& cp docker-prepare.sh /sbin/docker-prepare.sh \
&& chmod 755 /sbin/docker-prepare.sh \
&& cp wait-for-redis.py /sbin/wait-for-redis.py \
&& chmod 755 /sbin/wait-for-redis.py \
&& chmod +x install_management_commands.sh \
&& ./install_management_commands.sh
WORKDIR /usr/src/paperless/
COPY gunicorn.conf.py .
WORKDIR /usr/src/paperless/src/
# copy backend

View File

@ -19,7 +19,7 @@ djangorestframework = "~=3.13"
filelock = "*"
fuzzywuzzy = {extras = ["speedup"], version = "*"}
gunicorn = "*"
imap-tools = "~=0.54.0"
imap-tools = "*"
langdetect = "*"
pathvalidate = "*"
pillow = "~=9.1"
@ -70,3 +70,5 @@ sphinx_rtd_theme = "*"
tox = "*"
black = "*"
pre-commit = "*"
sphinx-autobuild = "*"
myst-parser = "*"

444
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "9573af313c811561d467d814c52c6bd1439bc48e3b31d7f56afed5f0ebe4b648"
"sha256": "818f3513df4a757e6302baf5a17ce61e85c7d69a7666e7d49e7e50e78e064ae3"
},
"pipfile-spec": 6,
"requires": {},
@ -28,11 +28,11 @@
},
"anyio": {
"hashes": [
"sha256:a0aeffe2fb1fdf374a8e4b471444f0f3ac4fb9f5a5b542b48824475e0042a5a6",
"sha256:b5fa16c5ff93fa1046f2eeb5bbff2dad4d3514d6cda61d02816dba34fa8c3c2e"
"sha256:413adf95f93886e442aea925f3ee43baa5a765a64a0f52c6081894f9992fdd0b",
"sha256:cb29b9c70620506a9a8f87a309591713446953302d7d995344d0d7c6c0c9a7be"
],
"markers": "python_full_version >= '3.6.2'",
"version": "==3.5.0"
"version": "==3.6.1"
},
"arrow": {
"hashes": [
@ -44,11 +44,11 @@
},
"asgiref": {
"hashes": [
"sha256:45a429524fba18aba9d512498b19d220c4d628e75b40cf5c627524dbaebc5cc1",
"sha256:fddeea3c53fa99d0cdb613c3941cc6e52d822491fc2753fba25768fb5bf4e865"
"sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4",
"sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"
],
"markers": "python_version >= '3.7'",
"version": "==3.5.1"
"version": "==3.5.2"
},
"async-timeout": {
"hashes": [
@ -68,10 +68,10 @@
},
"autobahn": {
"hashes": [
"sha256:58a887c7a196bb08d8b6624cb3695f493a9e5c9f00fd350d8d6f829b47ff9036"
"sha256:57b7acf228d50d83cf327372b889e2a168a869275b26e17917ed0b4cf4d823a6"
],
"markers": "python_version >= '3.7'",
"version": "==22.3.2"
"version": "==22.4.2"
},
"automat": {
"hashes": [
@ -99,7 +99,6 @@
"sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac",
"sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"
],
"index": "pypi",
"markers": "python_version < '3.9'",
"version": "==0.2.1"
},
@ -189,20 +188,12 @@
"index": "pypi",
"version": "==3.4.0"
},
"chardet": {
"hashes": [
"sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
"sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
],
"markers": "python_version >= '3.1'",
"version": "==4.0.0"
},
"charset-normalizer": {
"hashes": [
"sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597",
"sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"
],
"markers": "python_version >= '3'",
"markers": "python_version >= '3.5'",
"version": "==2.0.12"
},
"click": {
@ -238,31 +229,29 @@
},
"cryptography": {
"hashes": [
"sha256:06bfafa6e53ccbfb7a94be4687b211a025ce0625e3f3c60bb15cd048a18f3ed8",
"sha256:0db5cf21bd7d092baacb576482b0245102cea2d3cf09f09271ce9f69624ecb6f",
"sha256:125702572be12bcd318e3a14e9e70acd4be69a43664a75f0397e8650fe3c6cc3",
"sha256:1858eff6246bb8bbc080eee78f3dd1528739e3f416cba5f9914e8631b8df9871",
"sha256:315af6268de72bcfa0bb3401350ce7d921f216e6b60de12a363dad128d9d459f",
"sha256:451aaff8b8adf2dd0597cbb1fdcfc8a7d580f33f843b7cce75307a7f20112dd8",
"sha256:58021d6e9b1d88b1105269d0da5e60e778b37dfc0e824efc71343dd003726831",
"sha256:618391152147a1221c87b1b0b7f792cafcfd4b5a685c5c72eeea2ddd29aeceff",
"sha256:6d4daf890e674d191757d8d7d60dc3a29c58c72c7a76a05f1c0a326013f47e8b",
"sha256:74b55f67f4cf026cb84da7a1b04fc2a1d260193d4ad0ea5e9897c8b74c1e76ac",
"sha256:7ceae26f876aabe193b13a0c36d1bb8e3e7e608d17351861b437bd882f617e9f",
"sha256:930b829e8a2abaf43a19f38277ae3c5e1ffcf547b936a927d2587769ae52c296",
"sha256:a18ff4bfa9d64914a84d7b06c46eb86e0cc03113470b3c111255aceb6dcaf81d",
"sha256:ae1cd29fbe6b716855454e44f4bf743465152e15d2d317303fe3b58ee9e5af7a",
"sha256:b1ee5c82cf03b30f6ae4e32d2bcb1e167ef74d6071cbb77c2af30f101d0b360b",
"sha256:bf585476fcbcd37bed08072e8e2db3954ce1bfc68087a2dc9c19cfe0b90979ca",
"sha256:c4a58eeafbd7409054be41a377e726a7904a17c26f45abf18125d21b1215b08b",
"sha256:cce90609e01e1b192fae9e13665058ab46b2ea53a3c05a3ea74a3eb8c3af8857",
"sha256:d610d0ee14dd9109006215c7c0de15eee91230b70a9bce2263461cf7c3720b83",
"sha256:e69a0e36e62279120e648e787b76d79b41e0f9e86c1c636a4f38d415595c722e",
"sha256:f095988548ec5095e3750cdb30e6962273d239b1998ba1aac66c0d5bee7111c1",
"sha256:faf0f5456c059c7b1c29441bdd5e988f0ba75bdc3eea776520d8dcb1e30e1b5c"
"sha256:0a3bf09bb0b7a2c93ce7b98cb107e9170a90c51a0162a20af1c61c765b90e60b",
"sha256:1f64a62b3b75e4005df19d3b5235abd43fa6358d5516cfc43d87aeba8d08dd51",
"sha256:32db5cc49c73f39aac27574522cecd0a4bb7384e71198bc65a0d23f901e89bb7",
"sha256:4881d09298cd0b669bb15b9cfe6166f16fc1277b4ed0d04a22f3d6430cb30f1d",
"sha256:4e2dddd38a5ba733be6a025a1475a9f45e4e41139d1321f412c6b360b19070b6",
"sha256:53e0285b49fd0ab6e604f4c5d9c5ddd98de77018542e88366923f152dbeb3c29",
"sha256:70f8f4f7bb2ac9f340655cbac89d68c527af5bb4387522a8413e841e3e6628c9",
"sha256:7b2d54e787a884ffc6e187262823b6feb06c338084bbe80d45166a1cb1c6c5bf",
"sha256:7be666cc4599b415f320839e36367b273db8501127b38316f3b9f22f17a0b815",
"sha256:8241cac0aae90b82d6b5c443b853723bcc66963970c67e56e71a2609dc4b5eaf",
"sha256:82740818f2f240a5da8dfb8943b360e4f24022b093207160c77cadade47d7c85",
"sha256:8897b7b7ec077c819187a123174b645eb680c13df68354ed99f9b40a50898f77",
"sha256:c2c5250ff0d36fd58550252f54915776940e4e866f38f3a7866d92b32a654b86",
"sha256:ca9f686517ec2c4a4ce930207f75c00bf03d94e5063cbc00a1dc42531511b7eb",
"sha256:d2b3d199647468d410994dbeb8cec5816fb74feb9368aedf300af709ef507e3e",
"sha256:da73d095f8590ad437cd5e9faf6628a218aa7c387e1fdf67b888b47ba56a17f0",
"sha256:e167b6b710c7f7bc54e67ef593f8731e1f45aa35f8a8a7b72d6e42ec76afd4b3",
"sha256:ea634401ca02367c1567f012317502ef3437522e2fc44a3ea1844de028fa4b84",
"sha256:ec6597aa85ce03f3e507566b8bcdf9da2227ec86c4266bd5e6ab4d9e0cc8dab2",
"sha256:f64b232348ee82f13aac22856515ce0195837f6968aeaa94a3d0353ea2ec06a6"
],
"markers": "python_version >= '3.6'",
"version": "==37.0.1"
"version": "==36.0.2"
},
"daphne": {
"hashes": [
@ -290,11 +279,11 @@
},
"django-cors-headers": {
"hashes": [
"sha256:a22be2befd4069c4fc174f11cf067351df5c061a3a5f94a01650b4e928b0372b",
"sha256:eb98389bf7a2afc5d374806af4a9149697e3a6955b5a2dc2bf049f7d33647456"
"sha256:39d1d5acb872c1860ecfd88b8572bfbb3a1f201b5685ede951d71fc57c7dfae5",
"sha256:5f07e2ff8a95c887698e748588a4a0b2ad0ad1b5a292e2d33132f1253e2a97cb"
],
"index": "pypi",
"version": "==3.11.0"
"version": "==3.12.0"
},
"django-extensions": {
"hashes": [
@ -338,11 +327,11 @@
},
"filelock": {
"hashes": [
"sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85",
"sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"
"sha256:b795f1b42a61bbf8ec7113c341dad679d772567b936fbd1bf43c9a238e673e20",
"sha256:c7b5fdb219b398a5b28c8e4c1893ef5f98ece6a38c6ab2c22e26ec161556fed6"
],
"index": "pypi",
"version": "==3.6.0"
"version": "==3.7.0"
},
"fuzzywuzzy": {
"extras": [
@ -477,16 +466,16 @@
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
],
"markers": "python_version >= '3'",
"markers": "python_version >= '3.5'",
"version": "==3.3"
},
"imap-tools": {
"hashes": [
"sha256:15d20ac8695fc4978a913c2186f482a802f5229c41c6e0c66c7bad8f1f590cf1",
"sha256:606b73a1b5ecc4c72eea5ad19231e07a88bf9ba9adbdd4acb8cf71a359dd43ec"
"sha256:81e0069d81483aecc3ca46e57f5c41ffc39f1ba0041e416591d829f99f682623",
"sha256:d32f3e165af9e56542c1a5beb2866537265ef4832c8bd2eb25982ee8ac70ea4f"
],
"index": "pypi",
"version": "==0.54.0"
"version": "==0.55.0"
},
"img2pdf": {
"hashes": [
@ -675,11 +664,11 @@
},
"ocrmypdf": {
"hashes": [
"sha256:0c1cc0a7596fa9da1bfde67141227eeb813aba5e954f88199eacc5f51f1d67d9",
"sha256:48bbdd5d15b76f34aa3a91910918e51f91bb3833b4e86da45f8542afda118404"
"sha256:1169e7acee4cb12d0d61ca1c2cf1f78250ed5d2d0e33fd68f58defbaf3770af7",
"sha256:d132a9e5dcd73a477f8bd89f15de2c4ae64b394ca296644971fcd004168ace9d"
],
"index": "pypi",
"version": "==13.4.3"
"version": "==13.4.4"
},
"packaging": {
"hashes": [
@ -707,44 +696,45 @@
},
"pdfminer.six": {
"hashes": [
"sha256:af0630f98a292bad4170f54e80f82ca81b916dd0b2c996437ec45c02f11d8762",
"sha256:eff2ce0abeaa4df94dc3461f70eab104487c7b4a2b3c7e9fd0aeec6c5f44d6a6"
"sha256:0960be95fe8724a4847f83d53d0331b890871f6035ba706841568caa2b541bf5",
"sha256:3d65c1a0f4a0465c709e191550ec77a684ebe0bcb562337ccbfb7aa228c52076"
],
"index": "pypi",
"version": "==20220319"
"version": "==20220506"
},
"pikepdf": {
"hashes": [
"sha256:101ec256a8d312c17decae52226cf32a3e7dc834583134300c2f4e60b70e6e91",
"sha256:12b5b3cfc649e2542576a7e55c11e245278f14f727f116904893e54329102867",
"sha256:1b8f68a75c0a6f6d4d102d0821365ae2aaa9ab635c6eb6c840569a56b1a266f4",
"sha256:1bef3512be59fe0f481375b7eb415ca51ee7c80555031401f5f17ee3392e4add",
"sha256:1d3141916dc9efc433fd22beba544f67a53a805800c3ff902baffa398ef4c85e",
"sha256:3052df8514d26b676c50e65afc49a1d260c43a08c322c75cc2592c10a9a5b26d",
"sha256:356d5554516a295fc10db3f25cfde4e92326f6d015da55d71b84f5ced2a07a5a",
"sha256:55330c24b8e04ee09f1bc514c2b6107bb03a5eeb0b74929a61100cd6be22ae29",
"sha256:553cf11933fdfe07fdd357ab40b9732db102e921b27c1065239308d42b7b858f",
"sha256:5626312990a894c5db3a269455f7eb98df5f59188dde1797c0e352d60fdf89af",
"sha256:59c24a65c94693ab4a7e92f4809f847b57461120256c083054e61c99c4952e84",
"sha256:607deb1181a7cf5369cf70edfc41574d46c0a17c0cac1f6234272bd4cb3487e4",
"sha256:60bdd49e6251f8c99989e6769d4ad29b209c1eaf88090f49d4b30fba98442e40",
"sha256:73a7cc3c42609e00393b9d4e1b9ee132f528060254a174bf18ef31a154be0386",
"sha256:75f1e2917b4d2d6573fe3d1c3b2ec70829b64515b2f723f5c3bebcdd65761e6c",
"sha256:92ca9191680eccc21697e9e9c218e600ab31e7c24f6125749738c10ae2dc7c07",
"sha256:9bcaf96e2f571f0fc7e3178cdf1bcca7c13e5c68128e8246031226d47ecf23f1",
"sha256:a8f3e2229e2683497fe4ccc4af06050c125160a11bb3562b6c4ddaee4d0cc5c5",
"sha256:c277066938ca0ddb2bfe75874ef8dd3aa259936fe15c4cf7d4282f89ba82ab3a",
"sha256:c532542a99757d9f41df0cf1fc8f64a044d0eb822822cc069c80be35731df275",
"sha256:dfa89bd86e01413531c1d7d201fb01f0e62b52ea926a8e8ca6f99f86ed761e95",
"sha256:e064010b733b0a2ec4ec97982cd2887f9025292f2d228c6d5e6eca9d84851e53",
"sha256:ea927afe7cb04cc7ade30b961f528ef53e8d9cf467dcc4639cf944fef872a1a1",
"sha256:f40703b6267aa43d7f72468fa0a3b505ffff74ece2a4c69cfd3c90e023c41381",
"sha256:f45cc4544bbd4c308a525a6bb8e2e29b3f849803ee557c6e35c684447f0a92e5",
"sha256:f8ccda5ee992c73f647bcd96c9aa30f5eb9e8a6c5bdd6e3dcb29ebbffbe01a69",
"sha256:fe386d93345c9b5a9690f7a7bfb789a5ec5467c34402628e10bda8a4f5bac73e"
"sha256:00e66dcb803cc4f5348ae117287b85bd940744be38c111a88503843b0787ddfe",
"sha256:0e392c32e1f82f8cb4e891cadaf137c8cbdb1e88b4714eeb3dcd2cc6ee227575",
"sha256:143eef60457c828874000e11a34bc79c348544c7453f89e09ad9697b7102a108",
"sha256:18907145f6d2772fc77b1ea8d4eb3971dbd8e2594698764a64c22c1c43645eee",
"sha256:210cea8dc30bc9e668de1a380c83a31ec1b0edf8a2f91334ac99b23ec74b01e1",
"sha256:270b4805e00f4599108231c4342701574de47b1c7df3573c2d80aed176dd6843",
"sha256:35e0a82fd01e3fd57de473b65a56231e99cf17aaa632de0bafc02df5d25fd443",
"sha256:397dc297ec4390a67f920466b868cfd100b9be6bc12ab4134f455226c3d1ddef",
"sha256:44c909f2e16fe4646a3175273fad60e102750383c448a01ca8073b05cc086ffa",
"sha256:4f4dcc07d9222faa976b9ea6e21a30f6669cd8152cd2fa5e7a898d325da059c2",
"sha256:524785518325fac3cda133b339ff4cd56687cefc4a43b14aef102f06fabc0ffa",
"sha256:7a2aaaa6968b7268c97dc6beffc5be5148470f9675fcb940681a30ca68f00f6d",
"sha256:84915fcff1c28bfba4caa9df0a72a53264e1218b17f0870744aad43a477a13c0",
"sha256:85965ff4f42542998db1daa641f33b9b7a6a03a0e7531a76c06a185d8bf6653b",
"sha256:85ce51ac8354c1d6912edcb3b018e39fc9678cf1946087ef526d990eedf49756",
"sha256:8ce07b03affe29628f7babf25a54010f765f89d887a02e12393f365a8dc37d65",
"sha256:a074af7054206d91a70513269254b932e7ad244fed6cfb37c590cf7b665dcd53",
"sha256:aadb3e93b10299c8d3a6c5366e507ccdded727fc22e5b7ebae61bcd6883774dc",
"sha256:afcd1d4a1c4b9c99226b6ce1ceb5132fa219b420e562984564a431bed7160949",
"sha256:b43c57f37cd6278dcb52d3bda7f482b1961c2eb8a5fc1127cabe941914a858a9",
"sha256:c34e4239661d2ddf23caa1c4256f636c866ce8069a5052a2bf8ee06e3cae22f3",
"sha256:d3015baf32d9a559c50aa5ff15ac7bf5d2733efa4d1996eb846fb289e3f5a0e4",
"sha256:d37aee5705ee23f28513f480749773495f88d72a9177de6c5af32b20a2ad1592",
"sha256:e1b7edde3d3599a23a283341f15a41943a5894308df623cf6e04ff58304bd4b3",
"sha256:e552419963bdc4adab4f5a912994de96ba2dfc313ce935d8b727a9e40d6ccd67",
"sha256:e5941c4c6e61895114cf0caff871e7b17ce9145822c5068b6c6f16114dd1267b",
"sha256:f808eadb454adc6f4fb72b651eaa41a2926812e2b0306e2c320e5e3181440e49",
"sha256:ffa2b7d68fe45202895e74ba660619172e8528950905bee0ec862529760c4246"
],
"index": "pypi",
"version": "==5.1.2"
"version": "==5.1.3"
},
"pillow": {
"hashes": [
@ -875,11 +865,11 @@
},
"pyparsing": {
"hashes": [
"sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954",
"sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"
"sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb",
"sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"
],
"markers": "python_full_version >= '3.6.8'",
"version": "==3.0.8"
"version": "==3.0.9"
},
"python-dateutil": {
"hashes": [
@ -1195,11 +1185,11 @@
},
"setuptools": {
"hashes": [
"sha256:26ead7d1f93efc0f8c804d9fafafbe4a44b179580a7105754b245155f9af05a8",
"sha256:47c7b0c0f8fc10eec4cf1e71c6fdadf8decaa74ffa087e68cd1c20db7ad6a592"
"sha256:5534570b9980fc650d45c62877ff603c7aaaf24893371708736cc016bd221c3c",
"sha256:ca6ba73b7fd5f734ae70ece8c4c1f7062b07f3352f6428f6277e27c8f5c64237"
],
"markers": "python_version >= '3.7'",
"version": "==62.1.0"
"version": "==62.2.0"
},
"six": {
"hashes": [
@ -1333,33 +1323,34 @@
},
"watchdog": {
"hashes": [
"sha256:03b43d583df0f18782a0431b6e9e9965c5b3f7cf8ec36a00b930def67942c385",
"sha256:0908bb50f6f7de54d5d31ec3da1654cb7287c6b87bce371954561e6de379d690",
"sha256:0b4a1fe6201c6e5a1926f5767b8664b45f0fcb429b62564a41f490ff1ce1dc7a",
"sha256:177bae28ca723bc00846466016d34f8c1d6a621383b6caca86745918d55c7383",
"sha256:19b36d436578eb437e029c6b838e732ed08054956366f6dd11875434a62d2b99",
"sha256:1d1cf7dfd747dec519486a98ef16097e6c480934ef115b16f18adb341df747a4",
"sha256:1e877c70245424b06c41ac258023ea4bd0c8e4ff15d7c1368f17cd0ae6e351dd",
"sha256:340b875aecf4b0e6672076a6f05cfce6686935559bb6d34cebedee04126a9566",
"sha256:351e09b6d9374d5bcb947e6ac47a608ec25b9d70583e9db00b2fcdb97b00b572",
"sha256:3fd47815353be9c44eebc94cc28fe26b2b0c5bd889dafc4a5a7cbdf924143480",
"sha256:49639865e3db4be032a96695c98ac09eed39bbb43fe876bb217da8f8101689a6",
"sha256:4d0e98ac2e8dd803a56f4e10438b33a2d40390a72750cff4939b4b274e7906fa",
"sha256:6e6ae29b72977f2e1ee3d0b760d7ee47896cb53e831cbeede3e64485e5633cc8",
"sha256:7f14ce6adea2af1bba495acdde0e510aecaeb13b33f7bd2f6324e551b26688ca",
"sha256:81982c7884aac75017a6ecc72f1a4fedbae04181a8665a34afce9539fc1b3fab",
"sha256:81a5861d0158a7e55fe149335fb2bbfa6f48cbcbd149b52dbe2cd9a544034bbd",
"sha256:ae934e34c11aa8296c18f70bf66ed60e9870fcdb4cc19129a04ca83ab23e7055",
"sha256:b26e13e8008dcaea6a909e91d39b629a39635d1a8a7239dd35327c74f4388601",
"sha256:b3750ee5399e6e9c69eae8b125092b871ee9e2fcbd657a92747aea28f9056a5c",
"sha256:b61acffaf5cd5d664af555c0850f9747cc5f2baf71e54bbac164c58398d6ca7b",
"sha256:b9777664848160449e5b4260e0b7bc1ae0f6f4992a8b285db4ec1ef119ffa0e2",
"sha256:bdcbf75580bf4b960fb659bbccd00123d83119619195f42d721e002c1621602f",
"sha256:d802d65262a560278cf1a65ef7cae4e2bc7ecfe19e5451349e4c67e23c9dc420",
"sha256:ed6d9aad09a2a948572224663ab00f8975fae242aa540509737bb4507133fa2d"
"sha256:036ed15f7cd656351bf4e17244447be0a09a61aaa92014332d50719fc5973bc0",
"sha256:0c520009b8cce79099237d810aaa19bc920941c268578436b62013b2f0102320",
"sha256:0fb60c7d31474b21acba54079ce9ff0136411183e9a591369417cddb1d7d00d7",
"sha256:156ec3a94695ea68cfb83454b98754af6e276031ba1ae7ae724dc6bf8973b92a",
"sha256:1ae17b6be788fb8e4d8753d8d599de948f0275a232416e16436363c682c6f850",
"sha256:1e5d0fdfaa265c29dc12621913a76ae99656cf7587d03950dfeb3595e5a26102",
"sha256:24dedcc3ce75e150f2a1d704661f6879764461a481ba15a57dc80543de46021c",
"sha256:2962628a8777650703e8f6f2593065884c602df7bae95759b2df267bd89b2ef5",
"sha256:47598fe6713fc1fee86b1ca85c9cbe77e9b72d002d6adeab9c3b608f8a5ead10",
"sha256:4978db33fc0934c92013ee163a9db158ec216099b69fce5aec790aba704da412",
"sha256:5e2e51c53666850c3ecffe9d265fc5d7351db644de17b15e9c685dd3cdcd6f97",
"sha256:676263bee67b165f16b05abc52acc7a94feac5b5ab2449b491f1a97638a79277",
"sha256:68dbe75e0fa1ba4d73ab3f8e67b21770fbed0651d32ce515cd38919a26873266",
"sha256:6d03149126864abd32715d4e9267d2754cede25a69052901399356ad3bc5ecff",
"sha256:6ddf67bc9f413791072e3afb466e46cc72c6799ba73dea18439b412e8f2e3257",
"sha256:746e4c197ec1083581bb1f64d07d1136accf03437badb5ff8fcb862565c193b2",
"sha256:7721ac736170b191c50806f43357407138c6748e4eb3e69b071397f7f7aaeedd",
"sha256:88ef3e8640ef0a64b7ad7394b0f23384f58ac19dd759da7eaa9bc04b2898943f",
"sha256:aa68d2d9a89d686fae99d28a6edf3b18595e78f5adf4f5c18fbfda549ac0f20c",
"sha256:b962de4d7d92ff78fb2dbc6a0cb292a679dea879a0eb5568911484d56545b153",
"sha256:ce7376aed3da5fd777483fe5ebc8475a440c6d18f23998024f832134b2938e7b",
"sha256:ddde157dc1447d8130cb5b8df102fad845916fe4335e3d3c3f44c16565becbb7",
"sha256:efcc8cbc1b43902571b3dce7ef53003f5b97fe4f275fe0489565fc6e2ebe3314",
"sha256:f9ee4c6bf3a1b2ed6be90a2d78f3f4bbd8105b6390c04a86eb48ed67bbfa0b0b",
"sha256:fed4de6e45a4f16e4046ea00917b4fe1700b97244e5d114f594b4a1b9de6bed8"
],
"index": "pypi",
"version": "==2.1.7"
"version": "==2.1.8"
},
"watchgod": {
"hashes": [
@ -1585,7 +1576,7 @@
"sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597",
"sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"
],
"markers": "python_version >= '3'",
"markers": "python_version >= '3.5'",
"version": "==2.0.12"
},
"click": {
@ -1596,53 +1587,63 @@
"markers": "python_version >= '3.7'",
"version": "==8.1.3"
},
"coverage": {
"extras": [],
"colorama": {
"hashes": [
"sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9",
"sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d",
"sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf",
"sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7",
"sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6",
"sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4",
"sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059",
"sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39",
"sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536",
"sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac",
"sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c",
"sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903",
"sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d",
"sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05",
"sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684",
"sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1",
"sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f",
"sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7",
"sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca",
"sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad",
"sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca",
"sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d",
"sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92",
"sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4",
"sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf",
"sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6",
"sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1",
"sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4",
"sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359",
"sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3",
"sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620",
"sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512",
"sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69",
"sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2",
"sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518",
"sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0",
"sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa",
"sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4",
"sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e",
"sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1",
"sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"
"sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b",
"sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==0.4.4"
},
"coverage": {
"extras": [
"toml"
],
"hashes": [
"sha256:06f54765cdbce99901871d50fe9f41d58213f18e98b170a30ca34f47de7dd5e8",
"sha256:114944e6061b68a801c5da5427b9173a0dd9d32cd5fcc18a13de90352843737d",
"sha256:1414e8b124611bf4df8d77215bd32cba6e3425da8ce9c1f1046149615e3a9a31",
"sha256:2781c43bffbbec2b8867376d4d61916f5e9c4cc168232528562a61d1b4b01879",
"sha256:2ab88a01cd180b5640ccc9c47232e31924d5f9967ab7edd7e5c91c68eee47a69",
"sha256:338c417613f15596af9eb7a39353b60abec9d8ce1080aedba5ecee6a5d85f8d3",
"sha256:3401b0d2ed9f726fadbfa35102e00d1b3547b73772a1de5508ef3bdbcb36afe7",
"sha256:462105283de203df8de58a68c1bb4ba2a8a164097c2379f664fa81d6baf94b81",
"sha256:4cd696aa712e6cd16898d63cf66139dc70d998f8121ab558f0e1936396dbc579",
"sha256:4d06380e777dd6b35ee936f333d55b53dc4a8271036ff884c909cf6e94be8b6c",
"sha256:61f4fbf3633cb0713437291b8848634ea97f89c7e849c2be17a665611e433f53",
"sha256:6d4a6f30f611e657495cc81a07ff7aa8cd949144e7667c5d3e680d73ba7a70e4",
"sha256:6f5fee77ec3384b934797f1873758f796dfb4f167e1296dc00f8b2e023ce6ee9",
"sha256:75b5dbffc334e0beb4f6c503fb95e6d422770fd2d1b40a64898ea26d6c02742d",
"sha256:7835f76a081787f0ca62a53504361b3869840a1620049b56d803a8cb3a9eeea3",
"sha256:79bf405432428e989cad7b8bc60581963238f7645ae8a404f5dce90236cc0293",
"sha256:8329635c0781927a2c6ae068461e19674c564e05b86736ab8eb29c420ee7dc20",
"sha256:8586b177b4407f988731eb7f41967415b2197f35e2a6ee1a9b9b561f6323c8e9",
"sha256:892e7fe32191960da559a14536768a62e83e87bbb867e1b9c643e7e0fbce2579",
"sha256:91502bf27cbd5c83c95cfea291ef387469f2387508645602e1ca0fd8a4ba7548",
"sha256:93b16b08f94c92cab88073ffd185070cdcb29f1b98df8b28e6649145b7f2c90d",
"sha256:9c9441d57b0963cf8340268ad62fc83de61f1613034b79c2b1053046af0c5284",
"sha256:ad8f9068f5972a46d50fe5f32c09d6ee11da69c560fcb1b4c3baea246ca4109b",
"sha256:afb03f981fadb5aed1ac6e3dd34f0488e1a0875623d557b6fad09b97a942b38a",
"sha256:b5ba058610e8289a07db2a57bce45a1793ec0d3d11db28c047aae2aa1a832572",
"sha256:baa8be8aba3dd1e976e68677be68a960a633a6d44c325757aefaa4d66175050f",
"sha256:c06455121a089252b5943ea682187a4e0a5cf0a3fb980eb8e7ce394b144430a9",
"sha256:c1a9942e282cc9d3ed522cd3e3cab081149b27ea3bda72d6f61f84eaf88c1a63",
"sha256:c488db059848702aff30aa1d90ef87928d4e72e4f00717343800546fdbff0a94",
"sha256:cb5311d6ccbd22578c80028c5e292a7ab9adb91bd62c1982087fad75abe2e63d",
"sha256:cbe91bc84be4e5ef0b1480d15c7b18e29c73bdfa33e07d3725da7d18e1b0aff2",
"sha256:cc692c9ee18f0dd3214843779ba6b275ee4bb9b9a5745ba64265bce911aefd1a",
"sha256:cc972d829ad5ef4d4c5fcabd2bbe2add84ce8236f64ba1c0c72185da3a273130",
"sha256:ceb6534fcdfb5c503affb6b1130db7b5bfc8a0f77fa34880146f7a5c117987d0",
"sha256:d522f1dc49127eab0bfbba4e90fa068ecff0899bbf61bf4065c790ddd6c177fe",
"sha256:db094a6a4ae6329ed322a8973f83630b12715654c197dd392410400a5bfa1a73",
"sha256:df32ee0f4935a101e4b9a5f07b617d884a531ed5666671ff6ac66d2e8e8246d8",
"sha256:e5af1feee71099ae2e3b086ec04f57f9950e1be9ecf6c420696fea7977b84738",
"sha256:e814a4a5a1d95223b08cdb0f4f57029e8eab22ffdbae2f97107aeef28554517e",
"sha256:f8cabc5fd0091976ab7b020f5708335033e422de25e20ddf9416bdce2b7e07d8",
"sha256:fbc86ae8cc129c801e7baaafe3addf3c8d49c9c1597c44bdf2d78139707c3c62"
],
"markers": "python_version >= '3.7'",
"version": "==6.3.2"
"version": "==6.3.3"
},
"coveralls": {
"hashes": [
@ -1691,19 +1692,19 @@
},
"faker": {
"hashes": [
"sha256:0301ace8365d98f3d0bf6e9a40200c8548e845d3812402ae1daf589effe3fb01",
"sha256:b1903db92175d78051858128ada397c7dc76f376f6967975419da232b3ebd429"
"sha256:c6ff91847d7c820afc0a74d95e824b48aab71ddfd9003f300641e42d58ae886f",
"sha256:cad1f69d72a68878cd67855140b6fe3e44c11628971cd838595d289c98bc45de"
],
"markers": "python_version >= '3.6'",
"version": "==13.7.0"
"version": "==13.11.1"
},
"filelock": {
"hashes": [
"sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85",
"sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"
"sha256:b795f1b42a61bbf8ec7113c341dad679d772567b936fbd1bf43c9a238e673e20",
"sha256:c7b5fdb219b398a5b28c8e4c1893ef5f98ece6a38c6ab2c22e26ec161556fed6"
],
"index": "pypi",
"version": "==3.6.0"
"version": "==3.7.0"
},
"identify": {
"hashes": [
@ -1718,7 +1719,7 @@
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
],
"markers": "python_version >= '3'",
"markers": "python_version >= '3.5'",
"version": "==3.3"
},
"imagesize": {
@ -1752,6 +1753,20 @@
"markers": "python_version >= '3.7'",
"version": "==3.1.2"
},
"livereload": {
"hashes": [
"sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"
],
"version": "==2.6.3"
},
"markdown-it-py": {
"hashes": [
"sha256:93de681e5c021a432c63147656fe21790bc01231e0cd2da73626f1aa3ac0fe27",
"sha256:cf7e59fed14b5ae17c0006eff14a2d9a00ed5f3a846148153899a0224e2c07da"
],
"markers": "python_version >= '3.7'",
"version": "==2.1.0"
},
"markupsafe": {
"hashes": [
"sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003",
@ -1798,6 +1813,22 @@
"markers": "python_version >= '3.7'",
"version": "==2.1.1"
},
"mdit-py-plugins": {
"hashes": [
"sha256:b1279701cee2dbf50e188d3da5f51fee8d78d038cdf99be57c6b9d1aa93b4073",
"sha256:ecc24f51eeec6ab7eecc2f9724e8272c2fb191c2e93cf98109120c2cace69750"
],
"markers": "python_version ~= '3.6'",
"version": "==0.3.0"
},
"mdurl": {
"hashes": [
"sha256:6a8f6804087b7128040b2fb2ebe242bdc2affaeaa034d5fc9feeed30b443651b",
"sha256:f79c9709944df218a4cdb0fcc0b0c7ead2f44594e3e84dc566606f04ad749c20"
],
"markers": "python_version >= '3.7'",
"version": "==0.1.1"
},
"mypy-extensions": {
"hashes": [
"sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
@ -1805,6 +1836,14 @@
],
"version": "==0.4.3"
},
"myst-parser": {
"hashes": [
"sha256:1635ce3c18965a528d6de980f989ff64d6a1effb482e1f611b1bfb79e38f3d98",
"sha256:4c076d649e066f9f5c7c661bae2658be1ca06e76b002bb97f02a09398707686c"
],
"index": "pypi",
"version": "==0.17.2"
},
"nodeenv": {
"hashes": [
"sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b",
@ -1845,11 +1884,11 @@
},
"pre-commit": {
"hashes": [
"sha256:02226e69564ebca1a070bd1f046af866aa1c318dbc430027c50ab832ed2b73f2",
"sha256:5d445ee1fa8738d506881c5d84f83c62bb5be6b2838e32207433647e8e5ebe10"
"sha256:10c62741aa5704faea2ad69cb550ca78082efe5697d6f04e5710c3c229afdd10",
"sha256:4233a1e38621c87d9dda9808c6606d7e7ba0e087cd56d3fe03202a01d2919615"
],
"index": "pypi",
"version": "==2.18.1"
"version": "==2.19.0"
},
"py": {
"hashes": [
@ -1877,11 +1916,11 @@
},
"pyparsing": {
"hashes": [
"sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954",
"sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"
"sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb",
"sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"
],
"markers": "python_full_version >= '3.6.8'",
"version": "==3.0.8"
"version": "==3.0.9"
},
"pytest": {
"hashes": [
@ -2021,6 +2060,14 @@
"index": "pypi",
"version": "==4.5.0"
},
"sphinx-autobuild": {
"hashes": [
"sha256:8fe8cbfdb75db04475232f05187c776f46f6e9e04cacf1e49ce81bdac649ccac",
"sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05"
],
"index": "pypi",
"version": "==2021.3.14"
},
"sphinx-rtd-theme": {
"hashes": [
"sha256:4d35a56f4508cfee4c4fb604373ede6feae2a306731d533f409ef5c3496fdbd8",
@ -2099,6 +2146,53 @@
"markers": "python_version >= '3.7'",
"version": "==2.0.1"
},
"tornado": {
"hashes": [
"sha256:0a00ff4561e2929a2c37ce706cb8233b7907e0cdc22eab98888aca5dd3775feb",
"sha256:0d321a39c36e5f2c4ff12b4ed58d41390460f798422c4504e09eb5678e09998c",
"sha256:1e8225a1070cd8eec59a996c43229fe8f95689cb16e552d130b9793cb570a288",
"sha256:20241b3cb4f425e971cb0a8e4ffc9b0a861530ae3c52f2b0434e6c1b57e9fd95",
"sha256:25ad220258349a12ae87ede08a7b04aca51237721f63b1808d39bdb4b2164558",
"sha256:33892118b165401f291070100d6d09359ca74addda679b60390b09f8ef325ffe",
"sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791",
"sha256:3447475585bae2e77ecb832fc0300c3695516a47d46cefa0528181a34c5b9d3d",
"sha256:34ca2dac9e4d7afb0bed4677512e36a52f09caa6fded70b4e3e1c89dbd92c326",
"sha256:3e63498f680547ed24d2c71e6497f24bca791aca2fe116dbc2bd0ac7f191691b",
"sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4",
"sha256:6196a5c39286cc37c024cd78834fb9345e464525d8991c21e908cc046d1cc02c",
"sha256:61b32d06ae8a036a6607805e6720ef00a3c98207038444ba7fd3d169cd998910",
"sha256:6286efab1ed6e74b7028327365cf7346b1d777d63ab30e21a0f4d5b275fc17d5",
"sha256:65d98939f1a2e74b58839f8c4dab3b6b3c1ce84972ae712be02845e65391ac7c",
"sha256:66324e4e1beede9ac79e60f88de548da58b1f8ab4b2f1354d8375774f997e6c0",
"sha256:6c77c9937962577a6a76917845d06af6ab9197702a42e1346d8ae2e76b5e3675",
"sha256:70dec29e8ac485dbf57481baee40781c63e381bebea080991893cd297742b8fd",
"sha256:7250a3fa399f08ec9cb3f7b1b987955d17e044f1ade821b32e5f435130250d7f",
"sha256:748290bf9112b581c525e6e6d3820621ff020ed95af6f17fedef416b27ed564c",
"sha256:7da13da6f985aab7f6f28debab00c67ff9cbacd588e8477034c0652ac141feea",
"sha256:8f959b26f2634a091bb42241c3ed8d3cedb506e7c27b8dd5c7b9f745318ddbb6",
"sha256:9de9e5188a782be6b1ce866e8a51bc76a0fbaa0e16613823fc38e4fc2556ad05",
"sha256:a48900ecea1cbb71b8c71c620dee15b62f85f7c14189bdeee54966fbd9a0c5bd",
"sha256:b87936fd2c317b6ee08a5741ea06b9d11a6074ef4cc42e031bc6403f82a32575",
"sha256:c77da1263aa361938476f04c4b6c8916001b90b2c2fdd92d8d535e1af48fba5a",
"sha256:cb5ec8eead331e3bb4ce8066cf06d2dfef1bfb1b2a73082dfe8a161301b76e37",
"sha256:cc0ee35043162abbf717b7df924597ade8e5395e7b66d18270116f8745ceb795",
"sha256:d14d30e7f46a0476efb0deb5b61343b1526f73ebb5ed84f23dc794bdb88f9d9f",
"sha256:d371e811d6b156d82aa5f9a4e08b58debf97c302a35714f6f45e35139c332e32",
"sha256:d3d20ea5782ba63ed13bc2b8c291a053c8d807a8fa927d941bd718468f7b950c",
"sha256:d3f7594930c423fd9f5d1a76bee85a2c36fd8b4b16921cae7e965f22575e9c01",
"sha256:dcef026f608f678c118779cd6591c8af6e9b4155c44e0d1bc0c87c036fb8c8c4",
"sha256:e0791ac58d91ac58f694d8d2957884df8e4e2f6687cdf367ef7eb7497f79eaa2",
"sha256:e385b637ac3acaae8022e7e47dfa7b83d3620e432e3ecb9a3f7f58f150e50921",
"sha256:e519d64089b0876c7b467274468709dadf11e41d65f63bba207e04217f47c085",
"sha256:e7229e60ac41a1202444497ddde70a48d33909e484f96eb0da9baf8dc68541df",
"sha256:ed3ad863b1b40cd1d4bd21e7498329ccaece75db5a5bf58cd3c9f130843e7102",
"sha256:f0ba29bafd8e7e22920567ce0d232c26d4d47c8b5cf4ed7b562b5db39fa199c5",
"sha256:fa2ba70284fa42c2a5ecb35e322e68823288a4251f9ba9cc77be04ae15eada68",
"sha256:fba85b6cd9c39be262fcd23865652920832b61583de2a2ca907dbd8e8a8c81e5"
],
"markers": "python_version >= '3.5'",
"version": "==6.1"
},
"tox": {
"hashes": [
"sha256:0805727eb4d6b049de304977dfc9ce315a1938e6619c3ab9f38682bb04662a5a",

View File

@ -102,7 +102,7 @@ For bugs please [open an issue](https://github.com/paperless-ngx/paperless-ngx/i
Paperless has been around a while now, and people are starting to build stuff on top of it. If you're one of those people, we can add your project to this list:
- [Paperless App](https://github.com/bauerj/paperless_app): An Android/iOS app for Paperless-ngx. Also works with the original Paperless and Paperless-ngx.
- [Paperless App](https://github.com/bauerj/paperless_app): An Android/iOS app for Paperless-ngx. Also works with the original Paperless and Paperless-ng.
- [Paperless Share](https://github.com/qcasey/paperless_share). Share any files from your Android application with paperless. Very simple, but works with all of the mobile scanning apps out there that allow you to share scanned documents.
- [Scan to Paperless](https://github.com/sbrunner/scan-to-paperless): Scan and prepare (crop, deskew, OCR, ...) your documents for Paperless.

View File

@ -9,6 +9,6 @@ COPY ./src-ui /src/src-ui
WORKDIR /src/src-ui
RUN set -eux \
&& npm update npm -g \
&& npm ci --no-optional
&& npm ci --omit=optional
RUN set -eux \
&& ./node_modules/.bin/ng build --configuration production

View File

@ -55,7 +55,7 @@ services:
ports:
- 8010:8000
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000"]
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
interval: 30s
timeout: 10s
retries: 5

View File

@ -59,7 +59,7 @@ services:
ports:
- 8000:8000
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000"]
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
interval: 30s
timeout: 10s
retries: 5

View File

@ -53,7 +53,7 @@ services:
ports:
- 8000:8000
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000"]
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
interval: 30s
timeout: 10s
retries: 5

View File

@ -48,7 +48,7 @@ services:
ports:
- 8000:8000
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000"]
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
interval: 30s
timeout: 10s
retries: 5

View File

@ -39,7 +39,7 @@ services:
ports:
- 8000:8000
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000"]
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
interval: 30s
timeout: 10s
retries: 5

View File

@ -52,7 +52,7 @@ search_index() {
if [[ (! -f "$index_version_file") || $(<$index_version_file) != "$index_version" ]]; then
echo "Search index out of date. Updating..."
python3 manage.py document_index reindex
python3 manage.py document_index reindex --no-progress-bar
echo $index_version | tee $index_version_file >/dev/null
fi
}

View File

@ -28,6 +28,7 @@ stderr_logfile_maxbytes=0
[program:scheduler]
command=python3 manage.py qcluster
user=paperless
stopasgroup = true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0

View File

@ -24,6 +24,7 @@ I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
help:
@echo "Please use \`make <target>' where <target> is one of"
@echo " html to make standalone HTML files"
@echo " livehtml to preview changes with live reload in your browser"
@echo " dirhtml to make HTML files named index.html in directories"
@echo " singlehtml to make a single large HTML file"
@echo " pickle to make pickle files"
@ -54,6 +55,9 @@ html:
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
livehtml:
sphinx-autobuild "./" "$(BUILDDIR)" $(O)
dirhtml:
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo

View File

@ -64,6 +64,10 @@ body {
color: var(--color-text-body);
}
.rst-content p {
word-break: break-word;
}
h1, h2, h3, h4, h5, h6 {
font-family: inherit;
}

View File

@ -1,47 +1,47 @@
let toggleButton;
let icon;
let toggleButton
let icon
function load() {
"use strict";
'use strict'
toggleButton = document.createElement("button");
toggleButton.setAttribute("title", "Toggle dark mode");
toggleButton.classList.add("dark-mode-toggle");
icon = document.createElement("i");
icon.classList.add("fa", darkModeState ? "fa-sun-o" : "fa-moon-o");
toggleButton.appendChild(icon);
document.body.prepend(toggleButton);
toggleButton = document.createElement('button')
toggleButton.setAttribute('title', 'Toggle dark mode')
toggleButton.classList.add('dark-mode-toggle')
icon = document.createElement('i')
icon.classList.add('fa', darkModeState ? 'fa-sun-o' : 'fa-moon-o')
toggleButton.appendChild(icon)
document.body.prepend(toggleButton)
// Listen for changes in the OS settings
// addListener is used because older versions of Safari don't support addEventListener
// prefersDarkQuery set in <head>
if (prefersDarkQuery) {
prefersDarkQuery.addListener(function (evt) {
toggleDarkMode(evt.matches);
});
toggleDarkMode(evt.matches)
})
}
// Initial setting depending on the prefers-color-mode or localstorage
// darkModeState should be set in the document <head> to prevent flash
if (darkModeState == undefined) darkModeState = false;
toggleDarkMode(darkModeState);
if (darkModeState == undefined) darkModeState = false
toggleDarkMode(darkModeState)
// Toggles the "dark-mode" class on click and sets localStorage state
toggleButton.addEventListener("click", () => {
darkModeState = !darkModeState;
toggleButton.addEventListener('click', () => {
darkModeState = !darkModeState
toggleDarkMode(darkModeState);
localStorage.setItem("dark-mode", darkModeState);
});
toggleDarkMode(darkModeState)
localStorage.setItem('dark-mode', darkModeState)
})
}
function toggleDarkMode(state) {
document.documentElement.classList.toggle("dark-mode", state);
document.documentElement.classList.toggle("light-mode", !state);
icon.classList.remove("fa-sun-o");
icon.classList.remove("fa-moon-o");
icon.classList.add(state ? "fa-sun-o" : "fa-moon-o");
darkModeState = state;
document.documentElement.classList.toggle('dark-mode', state)
document.documentElement.classList.toggle('light-mode', !state)
icon.classList.remove('fa-sun-o')
icon.classList.remove('fa-moon-o')
icon.classList.add(state ? 'fa-sun-o' : 'fa-moon-o')
darkModeState = state
}
document.addEventListener("DOMContentLoaded", load);
document.addEventListener('DOMContentLoaded', load)

View File

@ -118,10 +118,10 @@ Then you can start paperless-ngx with ``-d`` to have it run in the background.
image: ghcr.io/paperless-ngx/paperless-ngx:latest
.. note::
In version 1.7.1 and onwards, the Docker image can now pinned to a release series.
In version 1.7.1 and onwards, the Docker image can now be pinned to a release series.
This is often combined with automatic updaters such as Watchtower to allow safer
unattended upgrading to new bugfix releases only. It is still recommended to always
review release notes before upgrading. To ping your install to a release series, edit
review release notes before upgrading. To pin your install to a release series, edit
the ``docker-compose.yml`` find the line that says
.. code::
@ -287,6 +287,10 @@ When you use the provided docker compose script, put the export inside the
``export`` folder in your paperless source directory. Specify ``../export``
as the ``source``.
.. note::
Importing from a previous version of Paperless may work, but for best results
it is suggested to match the versions.
.. _utilities-retagger:
@ -386,8 +390,8 @@ the naming scheme.
.. warning::
Since this command moves you documents around a lot, it is advised to to
a backup before. The renaming logic is robust and will never overwrite
Since this command moves your documents, it is advised to do
a backup beforehand. The renaming logic is robust and will never overwrite
or delete a file, but you can't ever be careful enough.
.. code::

View File

@ -7,12 +7,12 @@ easier.
.. _advanced-matching:
Matching tags, correspondents and document types
################################################
Matching tags, correspondents, document types, and storage paths
################################################################
Paperless will compare the matching algorithms defined by every tag and
correspondent already set in your database to see if they apply to the text in
a document. In other words, if you defined a tag called ``Home Utility``
Paperless will compare the matching algorithms defined by every tag, correspondent,
document type, and storage path in your database to see if they apply to the text
in a document. In other words, if you define a tag called ``Home Utility``
that had a ``match`` property of ``bc hydro`` and a ``matching_algorithm`` of
``literal``, Paperless will automatically tag your newly-consumed document with
your ``Home Utility`` tag so long as the text ``bc hydro`` appears in the body
@ -22,10 +22,10 @@ The matching logic is quite powerful. It supports searching the text of your
document with different algorithms, and as such, some experimentation may be
necessary to get things right.
In order to have a tag, correspondent, or type assigned automatically to newly
consumed documents, assign a match and matching algorithm using the web
interface. These settings define when to assign correspondents, tags, and types
to documents.
In order to have a tag, correspondent, document type, or storage path assigned
automatically to newly consumed documents, assign a match and matching algorithm
using the web interface. These settings define when to assign tags, correspondents,
document types, and storage paths to documents.
The following algorithms are available:
@ -37,7 +37,7 @@ The following algorithms are available:
* **Literal:** Matches only if the match appears exactly as provided (i.e. preserve ordering) in the PDF.
* **Regular expression:** Parses the match as a regular expression and tries to
find a match within the document.
* **Fuzzy match:** I dont know. Look at the source.
* **Fuzzy match:** I don't know. Look at the source.
* **Auto:** Tries to automatically match new documents. This does not require you
to set a match. See the notes below.
@ -47,9 +47,9 @@ defining a match text of ``"Bank of America" BofA`` using the *any* algorithm,
will match documents that contain either "Bank of America" or "BofA", but will
not match documents containing "Bank of South America".
Then just save your tag/correspondent and run another document through the
consumer. Once complete, you should see the newly-created document,
automatically tagged with the appropriate data.
Then just save your tag, correspondent, document type, or storage path and run
another document through the consumer. Once complete, you should see the
newly-created document, automatically tagged with the appropriate data.
.. _advanced-automatic_matching:
@ -58,9 +58,9 @@ Automatic matching
==================
Paperless-ngx comes with a new matching algorithm called *Auto*. This matching
algorithm tries to assign tags, correspondents, and document types to your
documents based on how you have already assigned these on existing documents. It
uses a neural network under the hood.
algorithm tries to assign tags, correspondents, document types, and storage paths
to your documents based on how you have already assigned these on existing documents.
It uses a neural network under the hood.
If, for example, all your bank statements of your account 123 at the Bank of
America are tagged with the tag "bofa_123" and the matching algorithm of this
@ -80,20 +80,21 @@ feature:
that the neural network only learns from documents which you have correctly
tagged before.
* The matching algorithm can only work if there is a correlation between the
tag, correspondent, or document type and the document itself. Your bank
statements usually contain your bank account number and the name of the bank,
so this works reasonably well, However, tags such as "TODO" cannot be
automatically assigned.
tag, correspondent, document type, or storage path and the document itself.
Your bank statements usually contain your bank account number and the name
of the bank, so this works reasonably well, However, tags such as "TODO"
cannot be automatically assigned.
* The matching algorithm needs a reasonable number of documents to identify when
to assign tags, correspondents, and types. If one out of a thousand documents
has the correspondent "Very obscure web shop I bought something five years
ago", it will probably not assign this correspondent automatically if you buy
something from them again. The more documents, the better.
to assign tags, correspondents, storage paths, and types. If one out of a
thousand documents has the correspondent "Very obscure web shop I bought
something five years ago", it will probably not assign this correspondent
automatically if you buy something from them again. The more documents, the better.
* Paperless also needs a reasonable amount of negative examples to decide when
not to assign a certain tag, correspondent or type. This will usually be the
case as you start filling up paperless with documents. Example: If all your
documents are either from "Webshop" and "Bank", paperless will assign one of
these correspondents to ANY new document, if both are set to automatic matching.
not to assign a certain tag, correspondent, document type, or storage path. This will
usually be the case as you start filling up paperless with documents.
Example: If all your documents are either from "Webshop" and "Bank", paperless
will assign one of these correspondents to ANY new document, if both are set
to automatic matching.
Hooking into the consumption process
####################################
@ -268,6 +269,17 @@ If paperless detects that two documents share the same filename, paperless will
append ``_01``, ``_02``, etc to the filename. This happens if all the placeholders in a filename
evaluate to the same value.
.. hint::
You can affect how empty placeholders are treated by changing the following setting to
`true`.
.. code::
PAPERLESS_FILENAME_FORMAT_REMOVE_NONE=True
Doing this results in all empty placeholders resolving to "" instead of "none" as stated above.
Spaces before empty placeholders are removed as well, empty directories are omitted.
.. hint::
Paperless checks the filename of a document whenever it is saved. Therefore,
@ -290,3 +302,59 @@ evaluate to the same value.
However, keep in mind that inside docker, if files get stored outside of the
predefined volumes, they will be lost after a restart of paperless.
Storage paths
#############
One of the best things in Paperless is that you can not only access the documents via the
web interface, but also via the file system.
When as single storage layout is not sufficient for your use case, storage paths come to
the rescue. Storage paths allow you to configure more precisely where each document is stored
in the file system.
- Each storage path is a `PAPERLESS_FILENAME_FORMAT` and follows the rules described above
- Each document is assigned a storage path using the matching algorithms described above, but
can be overwritten at any time
For example, you could define the following two storage paths:
1. Normal communications are put into a folder structure sorted by `year/correspondent`
2. Communications with insurance companies are stored in a flat structure with longer file names,
but containing the full date of the correspondence.
.. code::
By Year = {created_year}/{correspondent}/{title}
Insurances = Insurances/{correspondent}/{created_year}-{created_month}-{created_day} {title}
If you then map these storage paths to the documents, you might get the following result.
For simplicity, `By Year` defines the same structure as in the previous example above.
.. code:: text
2019/ # By Year
My bank/
Statement January.pdf
Statement February.pdf
Insurances/ # Insurances
Healthcare 123/
2022-01-01 Statement January.pdf
2022-02-02 Letter.pdf
2022-02-03 Letter.pdf
Dental 456/
2021-12-01 New Conditions.pdf
.. hint::
Defining a storage path is optional. If no storage path is defined for a document, the global
`PAPERLESS_FILENAME_FORMAT` is applied.
.. caution::
If you adjust the format of an existing storage path, old documents don't get relocated automatically.
You need to run the :ref:`document renamer <utilities-renamer>` to adjust their pathes.

View File

@ -240,11 +240,13 @@ be instructed to consume the document from there.
The endpoint supports the following optional form fields:
* ``title``: Specify a title that the consumer should use for the document.
* ``created``: Specify a DateTime where the document was created (e.g. "2016-04-19" or "2016-04-19 06:15:00+02:00").
* ``correspondent``: Specify the ID of a correspondent that the consumer should use for the document.
* ``document_type``: Similar to correspondent.
* ``tags``: Similar to correspondent. Specify this multiple times to have multiple tags added
to the document.
The endpoint will immediately return "OK" if the document consumption process
was started successfully. No additional status information about the consumption
process itself is available, since that happens in a different process.

1947
docs/changelog.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -14,13 +14,17 @@ extensions = [
"sphinx.ext.imgmath",
"sphinx.ext.viewcode",
"sphinx_rtd_theme",
"myst_parser",
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]
# The suffix of source filenames.
source_suffix = ".rst"
source_suffix = {
".rst": "restructuredtext",
".md": "markdown",
}
# The encoding of source files.
# source_encoding = 'utf-8-sig'

View File

@ -111,6 +111,14 @@ PAPERLESS_FILENAME_FORMAT=<format>
Default is none, which disables this feature.
PAPERLESS_FILENAME_FORMAT_REMOVE_NONE=<bool>
Tells paperless to replace placeholders in `PAPERLESS_FILENAME_FORMAT` that would resolve
to 'none' to be omitted from the resulting filename. This also holds true for directory
names.
See :ref:`advanced-file_name_handling` for details.
Defaults to `false` which disables this feature.
PAPERLESS_LOGGING_DIR=<path>
This is where paperless will store log files.
@ -587,6 +595,28 @@ PAPERLESS_CONSUMER_POLLING=<num>
Defaults to 0, which disables polling and uses filesystem notifications.
PAPERLESS_CONSUMER_POLLING_RETRY_COUNT=<num>
If consumer polling is enabled, sets the number of times paperless will check for a
file to remain unmodified.
Defaults to 5.
PAPERLESS_CONSUMER_POLLING_DELAY=<num>
If consumer polling is enabled, sets the delay in seconds between each check (above) paperless
will do while waiting for a file to remain unmodified.
Defaults to 5.
.. _configuration-inotify:
PAPERLESS_CONSUMER_INOTIFY_DELAY=<num>
Sets the time in seconds the consumer will wait for additional events
from inotify before the consumer will consider a file ready and begin consumption.
Certain scanners or network setups may generate multiple events for a single file,
leading to multiple consumers working on the same file. Configure this to
prevent that.
Defaults to 0.5 seconds.
PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>
When the consumer detects a duplicate document, it will not touch the
@ -647,7 +677,6 @@ PAPERLESS_CONSUMER_BARCODE_STRING=PATCHT
Defaults to "PATCHT"
PAPERLESS_CONVERT_MEMORY_LIMIT=<num>
On smaller systems, or even in the case of Very Large Documents, the consumer
may explode, complaining about how it's "unable to extend pixel cache". In
@ -693,6 +722,9 @@ PAPERLESS_FILENAME_DATE_ORDER=<format>
The filename will be checked first, and if nothing is found, the document
text will be checked as normal.
A date in a filename must have some separators (`.`, `-`, `/`, etc)
for it to be parsed.
Defaults to none, which disables this feature.
PAPERLESS_THUMBNAIL_FONT_NAME=<filename>
@ -710,10 +742,7 @@ PAPERLESS_IGNORE_DATES=<string>
this process. This is useful for special dates (like date of birth) that appear
in documents regularly but are very unlikely to be the documents creation date.
You may specify dates in a multitude of formats supported by dateparser (see
https://dateparser.readthedocs.io/en/latest/#popular-formats) but as the dates
need to be comma separated, the options are limited.
Example: "2020-12-02,22.04.1999"
The date is parsed using the order specified in PAPERLESS_DATE_ORDER
Defaults to an empty string to not ignore any dates.

View File

@ -52,7 +52,7 @@ resources in the documentation:
* Paperless is now integrated with a
:ref:`task processing queue <setup-task_processor>` that tells you
at a glance when and why something is not working.
* The :ref:`changelog <paperless_changelog>` contains a detailed list of all changes
* The :doc:`changelog </changelog>` contains a detailed list of all changes
in paperless-ngx.
Contents

View File

@ -0,0 +1 @@
myst-parser==0.17.2

View File

@ -13,43 +13,45 @@ that works right for you based on recommendations from other Paperless users.
Physical scanners
=================
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brand | Model | Supports | Recommended By |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| | | FTP | SFTP | NFS | SMB | SMTP | API [1]_ | |
+=========+================+=====+======+=====+=====+======+==========+================+
| Brother | `ADS-1700W`_ | yes | | | yes | yes | |`holzhannes`_ |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `ADS-1600W`_ | yes | | | yes | yes | |`holzhannes`_ |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `ADS-1500W`_ | yes | | | yes | yes | |`danielquinn`_ |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `ADS-1100W`_ | yes | | | | | |`ytzelf`_ |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `ADS-2800W`_ | yes | yes | | yes | yes | |`philpagel`_ |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `MFC-J6930DW`_ | yes | | | | | |`ayounggun`_ |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `MFC-L5850DW`_ | yes | | | | yes | |`holzhannes`_ |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `MFC-L2750DW`_ | yes | | | yes | yes | |`muued`_ |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `MFC-J5910DW`_ | yes | | | | | |`bmsleight`_ |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `MFC-8950DW`_ | yes | | | yes | yes | |`philpagel`_ |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `MFC-9142CDN`_ | yes | | | yes | | |`REOLDEV`_ |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Fujitsu | `ix500`_ | yes | | | yes | | |`eonist`_ |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Epson | `ES-580W`_ | yes | | | yes | yes | |`fignew`_ |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Epson | `WF-7710DWF`_ | yes | | | yes | | |`Skylinar`_ |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Fujitsu | `S1300i`_ | yes | | | yes | | |`jonaswinkler`_ |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Doxie | `Q2`_ | | | | | | yes |`Unkn0wnCat`_ |
+---------+----------------+-----+------+-----+-----+------+----------+----------------+
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Brand | Model | Supports | Recommended By |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| | | FTP | SFTP | NFS | SMB | SMTP | API [1]_ | |
+=========+===================+=====+======+=====+==========+======+==========+================+
| Brother | `ADS-1700W`_ | yes | | | yes | yes | |`holzhannes`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Brother | `ADS-1600W`_ | yes | | | yes | yes | |`holzhannes`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Brother | `ADS-1500W`_ | yes | | | yes | yes | |`danielquinn`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Brother | `ADS-1100W`_ | yes | | | | | |`ytzelf`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Brother | `ADS-2800W`_ | yes | yes | | yes | yes | |`philpagel`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Brother | `MFC-J6930DW`_ | yes | | | | | |`ayounggun`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Brother | `MFC-L5850DW`_ | yes | | | | yes | |`holzhannes`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Brother | `MFC-L2750DW`_ | yes | | | yes | yes | |`muued`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Brother | `MFC-J5910DW`_ | yes | | | | | |`bmsleight`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Brother | `MFC-8950DW`_ | yes | | | yes | yes | |`philpagel`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Brother | `MFC-9142CDN`_ | yes | | | yes | | |`REOLDEV`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Canon | `Maxify MB 5350`_ | | | | yes [2]_ | yes | |`eingemaischt`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Fujitsu | `ix500`_ | yes | | | yes | | |`eonist`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Epson | `ES-580W`_ | yes | | | yes | yes | |`fignew`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Epson | `WF-7710DWF`_ | yes | | | yes | | |`Skylinar`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Fujitsu | `S1300i`_ | yes | | | yes | | |`jonaswinkler`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
| Doxie | `Q2`_ | | | | | | yes |`Unkn0wnCat`_ |
+---------+-------------------+-----+------+-----+----------+------+----------+----------------+
.. _MFC-L5850DW: https://www.brother-usa.com/products/mfcl5850dw
.. _MFC-L2750DW: https://www.brother.de/drucker/laserdrucker/mfc-l2750dw
@ -58,6 +60,7 @@ Physical scanners
.. _ADS-1500W: https://www.brother.ca/en/p/ads1500w
.. _ADS-1100W: https://support.brother.com/g/b/downloadtop.aspx?c=fr&lang=fr&prod=ads1100w_eu_as_cn
.. _ADS-2800W: https://www.brother-usa.com/products/ads2800w
.. _Maxify MB 5350: https://www.canon.de/printers/inkjet/maxify/maxify_mb5350/specification.html
.. _MFC-J6930DW: https://www.brother.ca/en/p/MFCJ6930DW
.. _MFC-J5910DW: https://www.brother.co.uk/printers/inkjet-printers/mfcj5910dw
.. _MFC-8950DW: https://www.brother-usa.com/products/mfc8950dw
@ -81,8 +84,11 @@ Physical scanners
.. _Unkn0wnCat: https://github.com/Unkn0wnCat
.. _muued: https://github.com/muued
.. _philpagel: https://github.com/philpagel
.. _eingemaischt: https://github.com/eingemaischt
.. [1] Scanners with API Integration allow to push scanned documents directly to :ref:`Paperless API <api-file_uploads>`, sometimes referred to as Webhook or Document POST.
.. [2] Canon Multi Function Printers show strange behavior over SMB. They close and reopen the file after every page. It's recommended to tune the
:ref:`polling <configuration-polling>` and :ref:`inotify <configuration-inotify>` configuration values for your scanner. The scanner timeout is 3 minutes, so ``180`` is a good starting point.
Mobile phone software
=====================
@ -105,6 +111,9 @@ You can use your phone to "scan" documents. The regular camera app will work, bu
On Android, you can use these applications in combination with one of the :ref:`Paperless-ngx compatible apps <usage-mobile_upload>` to "Share" the documents produced by these scanner apps with paperless. On iOS, you can share the scanned documents via iOS-Sharing to other mail, WebDav or FTP apps.
There is also an iOS Shortcut that allows you to directly upload text, PDF and image documents available here: https://www.icloud.com/shortcuts/d234abc0885040129d9d75fa45fe1154
Please note this only works for documents downloaded to iCloud / the device, in other words not directly from a URL.
.. _Office Lens: https://play.google.com/store/apps/details?id=com.microsoft.office.officelens
.. _Genius Scan: https://play.google.com/store/apps/details?id=com.thegrizzlylabs.geniusscan.free
.. _OCR Scanner - QuickScan: https://apps.apple.com/us/app/quickscan-scanner-text-ocr/id1513790291

View File

@ -332,6 +332,12 @@ writing. Windows is not and will never be supported.
3. Optional. Install ``postgresql`` and configure a database, user and password for paperless. If you do not wish
to use PostgreSQL, SQLite is available as well.
.. note::
On bare-metal installations using SQLite, ensure the
`JSON1 extension <https://code.djangoproject.com/wiki/JSON1Extension>`_ is enabled. This is
usually the case, but not always.
4. Get the release archive from `<https://github.com/paperless-ngx/paperless-ngx/releases>`_.
If you clone the git repo as it is, you also have to compile the front end by yourself.
Extract the archive to a place from where you wish to execute it, such as ``/opt/paperless``.
@ -513,7 +519,7 @@ how you installed paperless.
This setup describes how to update an existing paperless Docker installation.
The important things to keep in mind are as follows:
* Read the :ref:`changelog <paperless_changelog>` and take note of breaking changes.
* Read the :doc:`changelog </changelog>` and take note of breaking changes.
* You should decide if you want to stick with SQLite or want to migrate your database
to PostgreSQL. See :ref:`setup-sqlite_to_psql` for details on how to move your data from
SQLite to PostgreSQL. Both work fine with paperless. However, if you already have a

View File

@ -234,3 +234,69 @@ You might find messages like these in your log files:
This indicates that paperless failed to read PDF metadata from one of your documents. This happens when you
open the affected documents in paperless for editing. Paperless will continue to work, and will simply not
show the invalid metadata.
Consumer fails with a FileNotFoundError
#######################################
You might find messages like these in your log files:
.. code::
[ERROR] [paperless.consumer] Error while consuming document SCN_0001.pdf: FileNotFoundError: [Errno 2] No such file or directory: '/tmp/ocrmypdf.io.yhk3zbv0/origin.pdf'
Traceback (most recent call last):
File "/app/paperless/src/paperless_tesseract/parsers.py", line 261, in parse
ocrmypdf.ocr(**args)
File "/usr/local/lib/python3.8/dist-packages/ocrmypdf/api.py", line 337, in ocr
return run_pipeline(options=options, plugin_manager=plugin_manager, api=True)
File "/usr/local/lib/python3.8/dist-packages/ocrmypdf/_sync.py", line 385, in run_pipeline
exec_concurrent(context, executor)
File "/usr/local/lib/python3.8/dist-packages/ocrmypdf/_sync.py", line 302, in exec_concurrent
pdf = post_process(pdf, context, executor)
File "/usr/local/lib/python3.8/dist-packages/ocrmypdf/_sync.py", line 235, in post_process
pdf_out = metadata_fixup(pdf_out, context)
File "/usr/local/lib/python3.8/dist-packages/ocrmypdf/_pipeline.py", line 798, in metadata_fixup
with pikepdf.open(context.origin) as original, pikepdf.open(working_file) as pdf:
File "/usr/local/lib/python3.8/dist-packages/pikepdf/_methods.py", line 923, in open
pdf = Pdf._open(
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/ocrmypdf.io.yhk3zbv0/origin.pdf'
This probably indicates paperless tried to consume the same file twice. This can happen for a number of reasons,
depending on how documents are placed into the consume folder. If paperless is using inotify (the default) to
check for documents, try adjusting the :ref:`inotify configuration <configuration-inotify>`. If polling is enabled,
try adjusting the :ref:`polling configuration <configuration-polling>`.
Consumer fails waiting for file to remain unmodified.
#####################################################
You might find messages like these in your log files:
.. code::
[ERROR] [paperless.management.consumer] Timeout while waiting on file /usr/src/paperless/src/../consume/SCN_0001.pdf to remain unmodified.
This indicates paperless timed out while waiting for the file to be completely written to the consume folder.
Adjusting :ref:`polling configuration <configuration-polling>` values should resolve the issue.
.. note::
The user will need to manually move the file out of the consume folder and
back in, for the initial failing file to be consumed.
Consumer fails reporting "OS reports file as busy still".
#########################################################
You might find messages like these in your log files:
.. code::
[WARNING] [paperless.management.consumer] Not consuming file /usr/src/paperless/src/../consume/SCN_0001.pdf: OS reports file as busy still
This indicates paperless was unable to open the file, as the OS reported the file as still being in use. To prevent a
crash, paperless did not try to consume the file. If paperless is using inotify (the default) to
check for documents, try adjusting the :ref:`inotify configuration <configuration-inotify>`. If polling is enabled,
try adjusting the :ref:`polling configuration <configuration-polling>`.
.. note::
The user will need to manually move the file out of the consume folder and
back in, for the initial failing file to be consumed.

View File

@ -1,6 +1,6 @@
import os
bind = f'0.0.0.0:{os.getenv("PAPERLESS_PORT", 8000)}'
bind = f'[::]:{os.getenv("PAPERLESS_PORT", 8000)}'
workers = int(os.getenv("PAPERLESS_WEBSERVER_WORKERS", 2))
worker_class = "paperless.workers.ConfigurableWorker"
timeout = 120
@ -24,7 +24,7 @@ def worker_int(worker):
## get traceback info
import threading, sys, traceback
id2name = dict([(th.ident, th.name) for th in threading.enumerate()])
id2name = {th.ident: th.name for th in threading.enumerate()}
code = []
for threadId, stack in sys._current_frames().items():
code.append("\n# Thread: %s(%d)" % (id2name.get(threadId, ""), threadId))

View File

@ -23,6 +23,7 @@
#PAPERLESS_MEDIA_ROOT=../media
#PAPERLESS_STATICDIR=../static
#PAPERLESS_FILENAME_FORMAT=
#PAPERLESS_FILENAME_FORMAT_REMOVE_NONE=
# Security and hosting

View File

@ -8,12 +8,12 @@
-i https://pypi.python.org/simple
--extra-index-url https://www.piwheels.org/simple
aioredis==1.3.1
anyio==3.5.0; python_full_version >= '3.6.2'
anyio==3.6.1; python_full_version >= '3.6.2'
arrow==1.2.2; python_version >= '3.6'
asgiref==3.5.1; python_version >= '3.7'
asgiref==3.5.2; python_version >= '3.7'
async-timeout==4.0.2; python_version >= '3.6'
attrs==21.4.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
autobahn==22.3.2; python_version >= '3.7'
autobahn==22.4.2; python_version >= '3.7'
automat==20.2.0
backports.zoneinfo==0.2.1; python_version < '3.9'
blessed==1.19.1; python_version >= '2.7'
@ -21,23 +21,22 @@ certifi==2021.10.8
cffi==1.15.0
channels-redis==3.4.0
channels==3.0.4
chardet==4.0.0; python_version >= '3.1'
charset-normalizer==2.0.12; python_version >= '3'
charset-normalizer==2.0.12; python_version >= '3.5'
click==8.1.3; python_version >= '3.7'
coloredlogs==15.0.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
concurrent-log-handler==0.9.20
constantly==15.1.0
cryptography==37.0.1; python_version >= '3.6'
cryptography==36.0.2; python_version >= '3.6'
daphne==3.0.2; python_version >= '3.6'
dateparser==1.1.1
django-cors-headers==3.11.0
django-cors-headers==3.12.0
django-extensions==3.1.5
django-filter==21.1
django-picklefield==3.0.1; python_version >= '3'
django-q==1.3.9
django==4.0.4
djangorestframework==3.13.1
filelock==3.6.0
filelock==3.7.0
fuzzywuzzy[speedup]==0.18.0
gunicorn==20.1.0
h11==0.13.0; python_version >= '3.6'
@ -46,7 +45,7 @@ httptools==0.4.0
humanfriendly==10.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
hyperlink==21.0.0
idna==3.3; python_version >= '3'
imap-tools==0.54.0
imap-tools==0.55.0
img2pdf==0.4.4
importlib-resources==5.7.1; python_version < '3.9'
incremental==21.3.0
@ -57,12 +56,12 @@ langdetect==1.0.9
lxml==4.8.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
msgpack==1.0.3
numpy==1.22.3; python_version >= '3.8'
ocrmypdf==13.4.3
ocrmypdf==13.4.4
packaging==21.3; python_version >= '3.6'
pathvalidate==2.5.0
pdf2image==1.16.0
pdfminer.six==20220319
pikepdf==5.1.2
pdfminer.six==20220506
pikepdf==5.1.3
pillow==9.1.0
pluggy==1.0.0; python_version >= '3.6'
portalocker==2.4.0; python_version >= '3'
@ -71,7 +70,7 @@ pyasn1-modules==0.2.8
pyasn1==0.4.8
pycparser==2.21
pyopenssl==22.0.0
pyparsing==3.0.8; python_full_version >= '3.6.8'
pyparsing==3.0.9; python_full_version >= '3.6.8'
python-dateutil==2.8.2
python-dotenv==0.20.0
python-gnupg==0.4.8
@ -88,7 +87,7 @@ requests==2.27.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3
scikit-learn==1.0.2
scipy==1.8.0; python_version < '3.11' and python_version >= '3.8'
service-identity==21.1.0
setuptools==62.1.0; python_version >= '3.7'
setuptools==62.2.0; python_version >= '3.7'
six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
sniffio==1.2.0; python_version >= '3.5'
sqlparse==0.4.2; python_version >= '3.5'
@ -103,7 +102,7 @@ tzlocal==4.2; python_version >= '3.6'
urllib3==1.26.9; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'
uvicorn[standard]==0.17.6
uvloop==0.16.0
watchdog==2.1.7
watchdog==2.1.8
watchgod==0.8.2
wcwidth==0.2.5
websockets==10.3

View File

@ -6,4 +6,4 @@
"pluginsFile": "cypress/plugins/index.ts",
"fixturesFolder": "cypress/fixtures",
"baseUrl": "http://localhost:4200"
}
}

View File

@ -0,0 +1,34 @@
{
"user_id": 1,
"username": "admin",
"display_name": "Admin",
"settings": {
"language": "",
"bulk_edit": {
"confirmation_dialogs": true,
"apply_on_close": false
},
"documentListSize": 50,
"dark_mode": {
"use_system": true,
"enabled": "false",
"thumb_inverted": "true"
},
"theme": {
"color": "#b198e5"
},
"document_details": {
"native_pdf_viewer": false
},
"date_display": {
"date_locale": "",
"date_format": "mediumDate"
},
"notifications": {
"consumer_new_documents": true,
"consumer_success": true,
"consumer_failed": true,
"consumer_suppress_on_dashboard": true
}
}
}

View File

@ -2,6 +2,9 @@ describe('document-detail', () => {
beforeEach(() => {
this.modifiedDocuments = []
cy.intercept('http://localhost:8000/api/ui_settings/', {
fixture: 'ui_settings/settings.json',
})
cy.fixture('documents/documents.json').then((documentsJson) => {
cy.intercept('GET', 'http://localhost:8000/api/documents/1/', (req) => {
let response = { ...documentsJson }

View File

@ -3,6 +3,9 @@ describe('documents-list', () => {
this.bulkEdits = {}
// mock API methods
cy.intercept('http://localhost:8000/api/ui_settings/', {
fixture: 'ui_settings/settings.json',
})
cy.fixture('documents/documents.json').then((documentsJson) => {
// bulk edit
cy.intercept(

View File

@ -1,5 +1,8 @@
describe('manage', () => {
beforeEach(() => {
cy.intercept('http://localhost:8000/api/ui_settings/', {
fixture: 'ui_settings/settings.json',
})
cy.intercept('http://localhost:8000/api/correspondents/*', {
fixture: 'correspondents/correspondents.json',
})
@ -26,7 +29,7 @@ describe('manage', () => {
req.reply({ count: 3, next: null, previous: null, results: [] })
})
cy.visit('/tags')
cy.get('tbody').find('button').contains('Documents').first().click() // id = 4
cy.get('tbody').find('button:visible').contains('Documents').first().click() // id = 4
cy.contains('3 documents')
})
})

View File

@ -3,45 +3,53 @@ describe('settings', () => {
this.modifiedViews = []
// mock API methods
cy.fixture('saved_views/savedviews.json').then((savedViewsJson) => {
// saved views PATCH
cy.intercept(
'PATCH',
'http://localhost:8000/api/saved_views/*',
(req) => {
this.modifiedViews.push(req.body) // store this for later
req.reply({ result: 'OK' })
}
)
cy.intercept('http://localhost:8000/api/ui_settings/', {
fixture: 'ui_settings/settings.json',
}).then(() => {
cy.fixture('saved_views/savedviews.json').then((savedViewsJson) => {
// saved views PATCH
cy.intercept(
'PATCH',
'http://localhost:8000/api/saved_views/*',
(req) => {
this.modifiedViews.push(req.body) // store this for later
req.reply({ result: 'OK' })
}
)
cy.intercept('GET', 'http://localhost:8000/api/saved_views/*', (req) => {
let response = { ...savedViewsJson }
if (this.modifiedViews.length) {
response.results = response.results.map((v) => {
if (this.modifiedViews.find((mv) => mv.id == v.id))
v = this.modifiedViews.find((mv) => mv.id == v.id)
return v
})
}
cy.intercept(
'GET',
'http://localhost:8000/api/saved_views/*',
(req) => {
let response = { ...savedViewsJson }
if (this.modifiedViews.length) {
response.results = response.results.map((v) => {
if (this.modifiedViews.find((mv) => mv.id == v.id))
v = this.modifiedViews.find((mv) => mv.id == v.id)
return v
})
}
req.reply(response)
}).as('savedViews')
})
cy.fixture('documents/documents.json').then((documentsJson) => {
cy.intercept('GET', 'http://localhost:8000/api/documents/1/', (req) => {
let response = { ...documentsJson }
response = response.results.find((d) => d.id == 1)
req.reply(response)
req.reply(response)
}
).as('savedViews')
})
})
cy.intercept('http://localhost:8000/api/documents/1/metadata/', {
fixture: 'documents/1/metadata.json',
})
cy.fixture('documents/documents.json').then((documentsJson) => {
cy.intercept('GET', 'http://localhost:8000/api/documents/1/', (req) => {
let response = { ...documentsJson }
response = response.results.find((d) => d.id == 1)
req.reply(response)
})
})
cy.intercept('http://localhost:8000/api/documents/1/suggestions/', {
fixture: 'documents/1/suggestions.json',
cy.intercept('http://localhost:8000/api/documents/1/metadata/', {
fixture: 'documents/1/metadata.json',
})
cy.intercept('http://localhost:8000/api/documents/1/suggestions/', {
fixture: 'documents/1/suggestions.json',
})
})
cy.viewport(1024, 1024)

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@ import { TagListComponent } from './components/manage/tag-list/tag-list.componen
import { NotFoundComponent } from './components/not-found/not-found.component'
import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
import { DirtyFormGuard } from './guards/dirty-form.guard'
import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component'
const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
@ -27,6 +28,7 @@ const routes: Routes = [
{ path: 'tags', component: TagListComponent },
{ path: 'documenttypes', component: DocumentTypeListComponent },
{ path: 'correspondents', component: CorrespondentListComponent },
{ path: 'storagepaths', component: StoragePathListComponent },
{ path: 'logs', component: LogsComponent },
{
path: 'settings',

View File

@ -1,4 +1,5 @@
import { SettingsService, SETTINGS_KEYS } from './services/settings.service'
import { SettingsService } from './services/settings.service'
import { SETTINGS_KEYS } from './data/paperless-uisettings'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { Router } from '@angular/router'
import { Subscription } from 'rxjs'

View File

@ -1,5 +1,5 @@
import { BrowserModule } from '@angular/platform-browser'
import { NgModule } from '@angular/core'
import { APP_INITIALIZER, NgModule } from '@angular/core'
import { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component'
import {
@ -87,6 +87,9 @@ import localeSr from '@angular/common/locales/sr'
import localeSv from '@angular/common/locales/sv'
import localeTr from '@angular/common/locales/tr'
import localeZh from '@angular/common/locales/zh'
import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component'
import { StoragePathEditDialogComponent } from './components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { SettingsService } from './services/settings.service'
registerLocaleData(localeBe)
registerLocaleData(localeCs)
@ -109,6 +112,12 @@ registerLocaleData(localeSv)
registerLocaleData(localeTr)
registerLocaleData(localeZh)
function initializeApp(settings: SettingsService) {
return () => {
return settings.initializeSettings()
}
}
@NgModule({
declarations: [
AppComponent,
@ -118,6 +127,7 @@ registerLocaleData(localeZh)
TagListComponent,
DocumentTypeListComponent,
CorrespondentListComponent,
StoragePathListComponent,
LogsComponent,
SettingsComponent,
NotFoundComponent,
@ -125,6 +135,7 @@ registerLocaleData(localeZh)
ConfirmDialogComponent,
TagEditDialogComponent,
DocumentTypeEditDialogComponent,
StoragePathEditDialogComponent,
TagComponent,
PageHeaderComponent,
AppFrameComponent,
@ -174,6 +185,12 @@ registerLocaleData(localeZh)
ColorSliderModule,
],
providers: [
{
provide: APP_INITIALIZER,
useFactory: initializeApp,
deps: [SettingsService],
multi: true,
},
DatePipe,
CookieService,
{

View File

@ -21,17 +21,17 @@
</div>
<ul ngbNav class="order-sm-3">
<li ngbDropdown class="nav-item dropdown">
<button class="btn text-light" id="userDropdown" ngbDropdownToggle>
<span *ngIf="displayName" class="navbar-text small me-2 text-light d-none d-sm-inline">
{{displayName}}
<button class="btn" id="userDropdown" ngbDropdownToggle>
<span class="small me-2 d-none d-sm-inline">
{{this.settingsService.displayName}}
</span>
<svg width="1.3em" height="1.3em" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#person-circle"/>
</svg>
</button>
<div ngbDropdownMenu class="dropdown-menu-end shadow me-2" aria-labelledby="userDropdown">
<div *ngIf="displayName" class="d-sm-none">
<p class="small mb-0 px-3 text-muted" i18n>Logged in as {{displayName}}</p>
<div class="d-sm-none">
<p class="small mb-0 px-3 text-muted" i18n>Logged in as {{this.settingsService.displayName}}</p>
<div class="dropdown-divider"></div>
</div>
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()">
@ -70,8 +70,9 @@
</li>
</ul>
<h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted" *ngIf='savedViewService.sidebarViews.length > 0'>
<h6 class="sidebar-heading px-3 mt-4 mb-1 text-muted" *ngIf='savedViewService.loading || savedViewService.sidebarViews.length > 0'>
<ng-container i18n>Saved views</ng-container>
<div *ngIf="savedViewService.loading" class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item w-100" *ngFor="let view of savedViewService.sidebarViews">
@ -133,6 +134,13 @@
</svg>&nbsp;<ng-container i18n>Document types</ng-container>
</a>
</li>
<li class="nav-item">
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#folder"/>
</svg>&nbsp;<ng-container i18n>Storage paths</ng-container>
</a>
</li>
<li class="nav-item">
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()">
<svg class="sidebaricon" fill="currentColor">
@ -187,7 +195,7 @@
<div class="me-3">{{ versionString }}</div>
<div *ngIf="appRemoteVersion" class="version-check">
<ng-template #updateAvailablePopContent>
<span class="small">Paperless-ngx v{{ appRemoteVersion.version }} <ng-container i18n>is available.</ng-container><br/><ng-container i18n>Click to view.</ng-container></span>
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is available.</ng-container><br/><ng-container i18n>Click to view.</ng-container></span>
</ng-template>
<ng-template #updateCheckingNotEnabledPopContent>
<span class="small"><ng-container i18n>Checking for updates is disabled.</ng-container><br/><ng-container i18n>Click for more information.</ng-container></span>

View File

@ -9,6 +9,11 @@
z-index: 995; /* Behind the navbar */
padding: 50px 0 0; /* Height of navbar */
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
.sidebar-heading .spinner-border {
width: 0.8em;
height: 0.8em;
}
}
@media (max-width: 767.98px) {
.sidebar {

View File

@ -22,6 +22,8 @@ import {
RemoteVersionService,
AppRemoteVersion,
} from 'src/app/services/rest/remote-version.service'
import { QueryParamsService } from 'src/app/services/query-params.service'
import { SettingsService } from 'src/app/services/settings.service'
@Component({
selector: 'app-app-frame',
@ -35,9 +37,9 @@ export class AppFrameComponent {
private openDocumentsService: OpenDocumentsService,
private searchService: SearchService,
public savedViewService: SavedViewService,
private list: DocumentListViewService,
private meta: Meta,
private remoteVersionService: RemoteVersionService
private remoteVersionService: RemoteVersionService,
private queryParamsService: QueryParamsService,
public settingsService: SettingsService
) {
this.remoteVersionService
.checkForUpdates()
@ -92,7 +94,7 @@ export class AppFrameComponent {
search() {
this.closeMenu()
this.list.quickFilter([
this.queryParamsService.navigateWithFilterRules([
{
rule_type: FILTER_FULLTEXT_QUERY,
value: (this.searchField.value as string).trim(),
@ -141,17 +143,4 @@ export class AppFrameComponent {
}
})
}
get displayName() {
// TODO: taken from dashboard component, is this the best way to pass around username?
let tagFullName = this.meta.getTag('name=full_name')
let tagUsername = this.meta.getTag('name=username')
if (tagFullName && tagFullName.content) {
return tagFullName.content
} else if (tagUsername && tagUsername.content) {
return tagUsername.content
} else {
return null
}
}
}

View File

@ -5,7 +5,7 @@
</div>
<div class="modal-body">
<p *ngIf="messageBold"><b>{{messageBold}}</b></p>
<p *ngIf="message">{{message}}</p>
<p class="mb-0" *ngIf="message" [innerHTML]="message | safeHtml"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" [disabled]="!buttonsEnabled" i18n>

View File

@ -0,0 +1,24 @@
<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">
<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://paperless-ngx.readthedocs.io/en/latest/administration.html#utilities-renamer">documentation</a>.</em>
</p>
<app-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></app-input-text>
<app-input-text i18n-title title="Path" formControlName="path" [error]="error?.path" [hint]="pathHint"></app-input-text>
<app-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></app-input-select>
<app-input-text *ngIf="patternRequired" i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></app-input-text>
<app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive"></app-input-check>
</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 { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { ToastService } from 'src/app/services/toast.service'
@Component({
selector: 'app-storage-path-edit-dialog',
templateUrl: './storage-path-edit-dialog.component.html',
styleUrls: ['./storage-path-edit-dialog.component.scss'],
})
export class StoragePathEditDialogComponent extends EditDialogComponent<PaperlessStoragePath> {
constructor(
service: StoragePathService,
activeModal: NgbActiveModal,
toastService: ToastService
) {
super(service, activeModal, toastService)
}
get pathHint() {
return (
$localize`e.g.` +
' <code>{created_year}-{title}</code> ' +
$localize`or use slashes to add directories e.g.` +
' <code>{created_year}/{correspondent}/{title}</code>. ' +
$localize`See <a target="_blank" href="https://paperless-ngx.readthedocs.io/en/latest/advanced_usage.html#file-name-handling">documentation</a> for full list.`
)
}
getCreateTitle() {
return $localize`Create new storage path`
}
getEditTitle() {
return $localize`Edit storage path`
}
getForm(): FormGroup {
return new FormGroup({
name: new FormControl(''),
path: new FormControl(''),
matching_algorithm: new FormControl(1),
match: new FormControl(''),
is_insensitive: new FormControl(true),
})
}
}

View File

@ -9,7 +9,8 @@
[items]="items"
[addTag]="allowCreateNew && addItemRef"
addTagText="Add item"
i18n-addTagText="Used for both types and correspondents"
i18n-addTagText="Used for both types, correspondents, storage paths"
[placeholder]="placeholder"
bindLabel="name"
bindValue="id"
(change)="onChange(value)"

View File

@ -41,6 +41,9 @@ export class SelectComponent extends AbstractInputComponent<number> {
@Input()
suggestions: number[]
@Input()
placeholder: string
@Output()
createNew = new EventEmitter<string>()

View File

@ -1,7 +1,7 @@
<div class="mb-3">
<label class="form-label" [for]="inputId">{{title}}</label>
<input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)">
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
<small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
<div class="invalid-feedback">
{{error}}
</div>

View File

@ -8,4 +8,4 @@
.toast:not(.show) {
display: block; // this corrects an ng-bootstrap bug that prevented animations
}
}

View File

@ -21,9 +21,14 @@
<div class='row'>
<div class="col-lg-8">
<app-welcome-widget *ngIf="savedViews.length == 0"></app-welcome-widget>
<ng-container *ngIf="savedViewService.loading">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</ng-container>
<ng-container *ngFor="let v of savedViews">
<app-welcome-widget *ngIf="!savedViewService.loading && savedViewService.dashboardViews.length == 0"></app-welcome-widget>
<ng-container *ngFor="let v of savedViewService.dashboardViews">
<app-saved-view-widget [savedView]="v"></app-saved-view-widget>
</ng-container>

View File

@ -1,43 +1,24 @@
import { Component, OnInit } from '@angular/core'
import { Meta } from '@angular/platform-browser'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss'],
})
export class DashboardComponent implements OnInit {
constructor(private savedViewService: SavedViewService, private meta: Meta) {}
get displayName() {
let tagFullName = this.meta.getTag('name=full_name')
let tagUsername = this.meta.getTag('name=username')
if (tagFullName && tagFullName.content) {
return tagFullName.content
} else if (tagUsername && tagUsername.content) {
return tagUsername.content
} else {
return null
}
}
export class DashboardComponent {
constructor(
public savedViewService: SavedViewService,
public settingsService: SettingsService
) {}
get subtitle() {
if (this.displayName) {
return $localize`Hello ${this.displayName}, welcome to Paperless-ngx!`
if (this.settingsService.displayName) {
return $localize`Hello ${this.settingsService.displayName}, welcome to Paperless-ngx!`
} else {
return $localize`Welcome to Paperless-ngx!`
}
}
savedViews: PaperlessSavedView[] = []
ngOnInit(): void {
this.savedViewService.listAll().subscribe((results) => {
this.savedViews = results.results.filter(
(savedView) => savedView.show_on_dashboard
)
})
}
}

View File

@ -1,4 +1,4 @@
<app-widget-frame [title]="savedView.name">
<app-widget-frame [title]="savedView.name" [loading]="loading">
<a class="btn-link" header-buttons [routerLink]="[]" (click)="showAll()" i18n>Show all</a>
@ -11,7 +11,7 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let doc of documents" [routerLink]="['/', 'documents', doc.id]">
<tr *ngFor="let doc of documents" (click)="openDocumentsService.openDocument(doc)">
<td>{{doc.created | customDate}}</td>
<td>{{doc.title | documentTitle}}<app-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t); $event.stopPropagation();"></app-tag></td>
</tr>

View File

@ -3,11 +3,12 @@ import { Router } from '@angular/router'
import { Subscription } from 'rxjs'
import { PaperlessDocument } from 'src/app/data/paperless-document'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { PaperlessTag } from 'src/app/data/paperless-tag'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import { QueryParamsService } from 'src/app/services/query-params.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
@Component({
selector: 'app-saved-view-widget',
@ -15,11 +16,14 @@ import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
styleUrls: ['./saved-view-widget.component.scss'],
})
export class SavedViewWidgetComponent implements OnInit, OnDestroy {
loading: boolean = true
constructor(
private documentService: DocumentService,
private router: Router,
private list: DocumentListViewService,
private consumerStatusService: ConsumerStatusService
private queryParamsService: QueryParamsService,
private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService
) {}
@Input()
@ -43,6 +47,7 @@ export class SavedViewWidgetComponent implements OnInit, OnDestroy {
}
reload() {
this.loading = true
this.documentService
.listFiltered(
1,
@ -52,6 +57,7 @@ export class SavedViewWidgetComponent implements OnInit, OnDestroy {
this.savedView.filter_rules
)
.subscribe((result) => {
this.loading = false
this.documents = result.results
})
}
@ -60,13 +66,14 @@ export class SavedViewWidgetComponent implements OnInit, OnDestroy {
if (this.savedView.show_in_sidebar) {
this.router.navigate(['view', this.savedView.id])
} else {
this.list.loadSavedView(this.savedView, true)
this.router.navigate(['documents'])
this.router.navigate(['documents'], {
queryParams: { view: this.savedView.id },
})
}
}
clickTag(tag: PaperlessTag) {
this.list.quickFilter([
this.queryParamsService.navigateWithFilterRules([
{ rule_type: FILTER_HAS_TAGS_ALL, value: tag.id.toString() },
])
}

View File

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

View File

@ -15,6 +15,8 @@ export interface Statistics {
styleUrls: ['./statistics-widget.component.scss'],
})
export class StatisticsWidgetComponent implements OnInit, OnDestroy {
loading: boolean = true
constructor(
private http: HttpClient,
private consumerStatusService: ConsumerStatusService
@ -29,7 +31,9 @@ export class StatisticsWidgetComponent implements OnInit, OnDestroy {
}
reload() {
this.loading = true
this.getStatistics().subscribe((statistics) => {
this.loading = false
this.statistics = statistics
})
}

View File

@ -2,6 +2,10 @@
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">{{title}}</h5>
<ng-container *ngIf="loading">
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</ng-container>
<ng-content select ="[header-buttons]"></ng-content>
</div>

View File

@ -11,5 +11,8 @@ export class WidgetFrameComponent implements OnInit {
@Input()
title: string
@Input()
loading: boolean = false
ngOnInit(): void {}
}

View File

@ -30,7 +30,7 @@
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="moreLike()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#three-dots" />
<use xlink:href="assets/bootstrap-icons.svg#diagram-3" />
</svg>&nbsp;<span class="d-none d-lg-inline" i18n>More like this</span>
</button>
@ -73,6 +73,8 @@
(createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents"></app-input-select>
<app-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true"
(createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types"></app-input-select>
<app-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true"
(createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default"></app-input-select>
<app-input-tags formControlName="tags" [suggestions]="suggestions?.tags"></app-input-tags>
</ng-template>

View File

@ -18,10 +18,7 @@ import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-
import { PDFDocumentProxy } from 'ng2-pdf-viewer'
import { ToastService } from 'src/app/services/toast.service'
import { TextComponent } from '../common/input/text/text.component'
import {
SettingsService,
SETTINGS_KEYS,
} from 'src/app/services/settings.service'
import { SettingsService } from 'src/app/services/settings.service'
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
import { Observable, Subject, BehaviorSubject } from 'rxjs'
import {
@ -35,6 +32,11 @@ import {
import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions'
import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type'
import { normalizeDateStr } from 'src/app/utils/date'
import { QueryParamsService } from 'src/app/services/query-params.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
@Component({
selector: 'app-document-detail',
@ -67,6 +69,7 @@ export class DocumentDetailComponent
correspondents: PaperlessCorrespondent[]
documentTypes: PaperlessDocumentType[]
storagePaths: PaperlessStoragePath[]
documentForm: FormGroup = new FormGroup({
title: new FormControl(''),
@ -74,6 +77,7 @@ export class DocumentDetailComponent
created: new FormControl(),
correspondent: new FormControl(),
document_type: new FormControl(),
storage_path: new FormControl(),
archive_serial_number: new FormControl(),
tags: new FormControl([]),
})
@ -84,6 +88,7 @@ export class DocumentDetailComponent
store: BehaviorSubject<any>
isDirty$: Observable<boolean>
unsubscribeNotifier: Subject<any> = new Subject()
docChangeNotifier: Subject<any> = new Subject()
requiresPassword: boolean = false
password: string
@ -114,19 +119,10 @@ export class DocumentDetailComponent
private documentListViewService: DocumentListViewService,
private documentTitlePipe: DocumentTitlePipe,
private toastService: ToastService,
private settings: SettingsService
) {
this.titleSubject
.pipe(
debounceTime(1000),
distinctUntilChanged(),
takeUntil(this.unsubscribeNotifier)
)
.subscribe((titleValue) => {
this.title = titleValue
this.documentForm.patchValue({ title: titleValue })
})
}
private settings: SettingsService,
private storagePathService: StoragePathService,
private queryParamsService: QueryParamsService
) {}
titleKeyUp(event) {
this.titleSubject.next(event.target?.value)
@ -173,15 +169,23 @@ export class DocumentDetailComponent
.listAll()
.pipe(first())
.subscribe((result) => (this.correspondents = result.results))
this.documentTypeService
.listAll()
.pipe(first())
.subscribe((result) => (this.documentTypes = result.results))
this.storagePathService
.listAll()
.pipe(first())
.subscribe((result) => (this.storagePaths = result.results))
this.route.paramMap
.pipe(
takeUntil(this.unsubscribeNotifier),
switchMap((paramMap) => {
const documentId = +paramMap.get('id')
this.docChangeNotifier.next(documentId)
return this.documentsService.get(documentId)
})
)
@ -202,10 +206,33 @@ export class DocumentDetailComponent
this.openDocumentService.getOpenDocument(this.documentId)
)
} else {
this.openDocumentService.openDocument(doc)
this.openDocumentService.openDocument(doc, false)
this.updateComponent(doc)
}
this.titleSubject
.pipe(
debounceTime(1000),
distinctUntilChanged(),
takeUntil(this.docChangeNotifier),
takeUntil(this.unsubscribeNotifier)
)
.subscribe({
next: (titleValue) => {
this.title = titleValue
this.documentForm.patchValue({ title: titleValue })
},
complete: () => {
// doc changed so we manually check dirty in case title was changed
if (
this.store.getValue().title !==
this.documentForm.get('title').value
) {
this.openDocumentService.setDirty(doc.id, true)
}
},
})
this.ogDate = new Date(normalizeDateStr(doc.created.toString()))
// Initialize dirtyCheck
@ -215,6 +242,7 @@ export class DocumentDetailComponent
created: this.ogDate.toISOString(),
correspondent: doc.correspondent,
document_type: doc.document_type,
storage_path: doc.storage_path,
archive_serial_number: doc.archive_serial_number,
tags: [...doc.tags],
})
@ -233,7 +261,6 @@ export class DocumentDetailComponent
return this.isDirty$.pipe(map((dirty) => ({ doc, dirty })))
})
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: ({ doc, dirty }) => {
this.openDocumentService.setDirty(doc.id, dirty)
@ -322,6 +349,27 @@ export class DocumentDetailComponent
})
}
createStoragePath(newName: string) {
var modal = this.modalService.open(StoragePathEditDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.dialogMode = 'create'
if (newName) modal.componentInstance.object = { name: newName }
modal.componentInstance.success
.pipe(
switchMap((newStoragePath) => {
return this.storagePathService
.listAll()
.pipe(map((storagePaths) => ({ newStoragePath, storagePaths })))
})
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newStoragePath, documentTypes: storagePaths }) => {
this.storagePaths = storagePaths.results
this.documentForm.get('storage_path').setValue(newStoragePath.id)
})
}
discard() {
this.documentsService
.get(this.documentId)
@ -446,7 +494,7 @@ export class DocumentDetailComponent
}
moreLike() {
this.documentListViewService.quickFilter([
this.queryParamsService.navigateWithFilterRules([
{
rule_type: FILTER_FULLTEXT_MORELIKE,
value: this.documentId.toString(),

View File

@ -53,6 +53,15 @@
[(selectionModel)]="documentTypeSelectionModel"
(apply)="setDocumentTypes($event)">
</app-filterable-dropdown>
<app-filterable-dropdown class="me-2 me-md-3" title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[items]="storagePaths"
[editing]="true"
[applyOnClose]="applyOnClose"
(open)="openStoragePathDropdown()"
[(selectionModel)]="storagePathsSelectionModel"
(apply)="setStoragePaths($event)">
</app-filterable-dropdown>
</div>
</div>
<div class="col-auto ms-auto mb-2 mb-xl-0 d-flex">

View File

@ -19,12 +19,12 @@ import {
} from '../../common/filterable-dropdown/filterable-dropdown.component'
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
import { MatchingModel } from 'src/app/data/matching-model'
import {
SettingsService,
SETTINGS_KEYS,
} from 'src/app/services/settings.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { saveAs } from 'file-saver'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
@Component({
selector: 'app-bulk-editor',
@ -35,10 +35,12 @@ export class BulkEditorComponent {
tags: PaperlessTag[]
correspondents: PaperlessCorrespondent[]
documentTypes: PaperlessDocumentType[]
storagePaths: PaperlessStoragePath[]
tagSelectionModel = new FilterableDropdownSelectionModel()
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
awaitingDownload: boolean
constructor(
@ -50,7 +52,8 @@ export class BulkEditorComponent {
private modalService: NgbModal,
private openDocumentService: OpenDocumentsService,
private settings: SettingsService,
private toastService: ToastService
private toastService: ToastService,
private storagePathService: StoragePathService
) {}
applyOnClose: boolean = this.settings.get(
@ -70,6 +73,9 @@ export class BulkEditorComponent {
this.documentTypeService
.listAll()
.subscribe((result) => (this.documentTypes = result.results))
this.storagePathService
.listAll()
.subscribe((result) => (this.storagePaths = result.results))
}
private executeBulkOperation(modal, method: string, args) {
@ -147,6 +153,17 @@ export class BulkEditorComponent {
})
}
openStoragePathDropdown() {
this.documentService
.getSelectionData(Array.from(this.list.selected))
.subscribe((s) => {
this.applySelectionData(
s.selected_storage_paths,
this.storagePathsSelectionModel
)
})
}
private _localizeList(items: MatchingModel[]) {
if (items.length == 0) {
return ''
@ -301,6 +318,42 @@ export class BulkEditorComponent {
}
}
setStoragePaths(changedDocumentPaths: ChangedItems) {
if (
changedDocumentPaths.itemsToAdd.length == 0 &&
changedDocumentPaths.itemsToRemove.length == 0
)
return
let storagePath =
changedDocumentPaths.itemsToAdd.length > 0
? changedDocumentPaths.itemsToAdd[0]
: null
if (this.showConfirmationDialogs) {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm storage path assignment`
if (storagePath) {
modal.componentInstance.message = $localize`This operation will assign the storage path "${storagePath.name}" to ${this.list.selected.size} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will remove the storage path from ${this.list.selected.size} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm`
modal.componentInstance.confirmClicked.subscribe(() => {
this.executeBulkOperation(modal, 'set_storage_path', {
storage_path: storagePath ? storagePath.id : null,
})
})
} else {
this.executeBulkOperation(null, 'set_storage_path', {
storage_path: storagePath ? storagePath.id : null,
})
}
}
applyDelete() {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',

View File

@ -33,56 +33,67 @@
<div class="d-flex flex-column flex-md-row align-items-md-center">
<div class="btn-group">
<a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/>
</svg>&nbsp;<span class="d-block d-md-inline" i18n>More like this</span>
<svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<use xlink:href="assets/bootstrap-icons.svg#diagram-3"/>
</svg>&nbsp;<span class="d-none d-md-inline" i18n>More like this</span>
</a>
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary">
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg>&nbsp;<span class="d-block d-md-inline" i18n>Edit</span>
<a (click)="openDocumentsService.openDocument(document)" class="btn btn-sm btn-outline-secondary">
<svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<use xlink:href="assets/bootstrap-icons.svg#pencil"/>
</svg>&nbsp;<span class="d-none d-md-inline" i18n>Edit</span>
</a>
<a class="btn btn-sm btn-outline-secondary" target="_blank" [href]="previewUrl"
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle"
autoClose="true" popoverClass="shadow" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye" viewBox="0 0 16 16">
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
</svg>&nbsp;<span class="d-block d-md-inline" i18n>View</span>
<svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<use xlink:href="assets/bootstrap-icons.svg#eye"/>
</svg>&nbsp;<span class="d-none d-md-inline" i18n>View</span>
</a>
<ng-template #previewContent>
<object [data]="previewUrl | safeUrl" class="preview" width="100%"></object>
</ng-template>
<a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-download" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path fill-rule="evenodd" d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
</svg>&nbsp;<span class="d-block d-md-inline" i18n>Download</span>
<svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<use xlink:href="assets/bootstrap-icons.svg#download"/>
</svg>&nbsp;<span class="d-none d-md-inline" i18n>Download</span>
</a>
</div>
<div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0">
<button *ngIf="document.document_type" type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2" title="Filter by document type"
<button *ngIf="document.document_type" type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2" title="Filter by document type" i18n-title
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
<svg class="metadata-icon me-2 text-muted bi bi-file-earmark" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/>
</svg>
<small>{{(document.document_type$ | async)?.name}}</small>
</button>
<button *ngIf="document.storage_path" type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2" title="Filter by storage path" i18n-title
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
<svg class="metadata-icon me-2 text-muted bi bi-folder" viewBox="0 0 16 16" fill="currentColor">
<path d="M0 2a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 1 12.5V5a1 1 0 0 1-1-1V2zm2 3v7.5A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5V5H2zm13-3H1v2h14V2zM5 7.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/>
</svg>
<small>{{(document.storage_path$ | async)?.name}}</small>
</button>
<div *ngIf="document.archive_serial_number" class="list-group-item me-2 bg-light text-dark p-1 border-0">
<svg class="metadata-icon me-2 text-muted bi bi-upc-scan" viewBox="0 0 16 16" fill="currentColor">
<path d="M1.5 1a.5.5 0 0 0-.5.5v3a.5.5 0 0 1-1 0v-3A1.5 1.5 0 0 1 1.5 0h3a.5.5 0 0 1 0 1h-3zM11 .5a.5.5 0 0 1 .5-.5h3A1.5 1.5 0 0 1 16 1.5v3a.5.5 0 0 1-1 0v-3a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 1-.5-.5zM.5 11a.5.5 0 0 1 .5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 1 0 1h-3A1.5 1.5 0 0 1 0 14.5v-3a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v3a1.5 1.5 0 0 1-1.5 1.5h-3a.5.5 0 0 1 0-1h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 1 .5-.5zM3 4.5a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7zm2 0a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7zm2 0a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7zm2 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-7zm3 0a.5.5 0 0 1 1 0v7a.5.5 0 0 1-1 0v-7z"/>
</svg>
<small>#{{document.archive_serial_number}}</small>
</div>
<div class="list-group-item bg-light text-dark p-1 border-0" ngbTooltip="Added:&nbsp;{{document.added | customDate:'shortDate'}} Created:&nbsp;{{document.created | customDate:'shortDate'}}">
<ng-template #dateTooltip>
<div class="d-flex flex-column">
<span i18n>Created: {{ document.created | customDate }}</span>
<span i18n>Added: {{ document.added | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate }}</span>
</div>
</ng-template>
<div class="list-group-item bg-light text-dark p-1 border-0" [ngbTooltip]="dateTooltip">
<svg class="metadata-icon me-2 text-muted bi bi-calendar-event" viewBox="0 0 16 16" fill="currentColor">
<path d="M11 6.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1z"/>
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
</svg>
<small>{{document.created | customDate:'mediumDate'}}</small>
</div>
<div *ngIf="document.__search_hit__?.score" class="list-group-item bg-light text-dark border-0 d-flex p-0 ps-4 search-score">
<small class="text-muted" i18n>Score:</small>
<ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>

View File

@ -6,16 +6,14 @@ import {
Output,
ViewChild,
} from '@angular/core'
import { DomSanitizer } from '@angular/platform-browser'
import { PaperlessDocument } from 'src/app/data/paperless-document'
import { DocumentService } from 'src/app/services/rest/document.service'
import {
SettingsService,
SETTINGS_KEYS,
} from 'src/app/services/settings.service'
import { SettingsService } from 'src/app/services/settings.service'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
@Component({
selector: 'app-document-card-large',
@ -28,8 +26,8 @@ import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type'
export class DocumentCardLargeComponent implements OnInit {
constructor(
private documentService: DocumentService,
private sanitizer: DomSanitizer,
private settingsService: SettingsService
private settingsService: SettingsService,
public openDocumentsService: OpenDocumentsService
) {}
@Input()
@ -54,6 +52,9 @@ export class DocumentCardLargeComponent implements OnInit {
@Output()
clickDocumentType = new EventEmitter<number>()
@Output()
clickStoragePath = new EventEmitter<number>()
@Output()
clickMoreLike = new EventEmitter()

View File

@ -30,22 +30,28 @@
</div>
<div class="card-footer pt-0 pb-2 px-2">
<div class="list-group list-group-flush border-0 pt-1 pb-2 card-info">
<button *ngIf="document.document_type" type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Filter by document type"
<button *ngIf="document.document_type" type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Filter by document type" i18n-title
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
<svg class="metadata-icon me-2 text-muted bi bi-file-earmark" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/>
</svg>
<small>{{(document.document_type$ | async)?.name}}</small>
</button>
<button *ngIf="document.storage_path" type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Filter by storage path" i18n-title
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
<svg class="metadata-icon me-2 text-muted bi bi-folder" viewBox="0 0 16 16" fill="currentColor">
<path d="M0 2a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 1 12.5V5a1 1 0 0 1-1-1V2zm2 3v7.5A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5V5H2zm13-3H1v2h14V2zM5 7.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/>
</svg>
<small>{{(document.storage_path$ | async)?.name}}</small>
</button>
<div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between">
<ng-template #dateTooltip>
<div class="d-flex flex-column">
<span i18n>Created: {{ document.created | customDate}}</span>
<span i18n>Added: {{ document.added | customDate}}</span>
<span i18n>Modified: {{ document.modified | customDate}}</span>
<span i18n>Created: {{ document.created | customDate }}</span>
<span i18n>Added: {{ document.added | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate }}</span>
</div>
</ng-template>
<div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip">
<svg class="metadata-icon me-2 text-muted bi bi-calendar-event" viewBox="0 0 16 16" fill="currentColor">
<path d="M11 6.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1z"/>
@ -63,7 +69,7 @@
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="btn-group w-100">
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" title="Edit" i18n-title>
<a (click)="openDocumentsService.openDocument(document)" class="btn btn-sm btn-outline-secondary" title="Edit" i18n-title>
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg>
@ -79,7 +85,7 @@
<ng-template #previewContent>
<object [data]="previewUrl | safeUrl" class="preview" width="100%"></object>
</ng-template>
<a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download" (click)="$event.stopPropagation()" i18n-title>
<a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download" i18n-title (click)="$event.stopPropagation()">
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-download" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path fill-rule="evenodd" d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>

View File

@ -9,11 +9,10 @@ import {
import { map } from 'rxjs/operators'
import { PaperlessDocument } from 'src/app/data/paperless-document'
import { DocumentService } from 'src/app/services/rest/document.service'
import {
SettingsService,
SETTINGS_KEYS,
} from 'src/app/services/settings.service'
import { SettingsService } from 'src/app/services/settings.service'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
@Component({
selector: 'app-document-card-small',
@ -26,7 +25,8 @@ import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
export class DocumentCardSmallComponent implements OnInit {
constructor(
private documentService: DocumentService,
private settingsService: SettingsService
private settingsService: SettingsService,
public openDocumentsService: OpenDocumentsService
) {}
@Input()
@ -47,6 +47,9 @@ export class DocumentCardSmallComponent implements OnInit {
@Output()
clickDocumentType = new EventEmitter<number>()
@Output()
clickStoragePath = new EventEmitter<number>()
moreTags: number = null
@ViewChild('popover') popover: NgbPopover

View File

@ -1,10 +1,11 @@
<app-page-header [title]="getTitle()">
<div ngbDropdown class="me-2 flex-fill d-flex">
<button class="btn btn-sm btn-outline-primary flex-fill" id="dropdownSelect" ngbDropdownToggle>
<div ngbDropdown class="me-2 d-flex">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#text-indent-left" />
</svg>&nbsp;<ng-container i18n>Select</ng-container>
</svg>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Select</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button>
@ -38,7 +39,7 @@
<div ngbDropdown class="btn-group ms-2 flex-fill">
<button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle i18n>Sort</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right">
<div class="w-100 d-flex btn-group-toggle pb-2 mb-1 border-bottom" ngbRadioGroup [(ngModel)]="list.sortReverse">
<div class="w-100 d-flex btn-group-toggle pb-2 mb-1 border-bottom" ngbRadioGroup [(ngModel)]="listSort">
<label ngbButtonLabel class="btn-outline-primary btn-sm mx-2 flex-fill">
<input ngbButton type="radio" class="btn btn-check btn-sm" [value]="false">
<svg class="toolbaricon" fill="currentColor">
@ -53,7 +54,7 @@
</label>
</div>
<div>
<button *ngFor="let f of getSortFields()" ngbDropdownItem (click)="list.sortField = f.field"
<button *ngFor="let f of getSortFields()" ngbDropdownItem (click)="setSortField(f.field)"
[class.active]="list.sortField == f.field">{{f.name}}
</button>
</div>
@ -64,7 +65,7 @@
<button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" ngbDropdownToggle i18n>Views</button>
<div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu>
<ng-container *ngIf="!list.activeSavedViewId">
<button ngbDropdownItem *ngFor="let view of savedViewService.allViews" (click)="loadViewConfig(view)">{{view.name}}</button>
<button ngbDropdownItem *ngFor="let view of savedViewService.allViews" (click)="loadViewConfig(view.id)">{{view.name}}</button>
<div class="dropdown-divider" *ngIf="savedViewService.allViews.length > 0"></div>
</ng-container>
@ -75,7 +76,7 @@
</app-page-header>
<div class="row sticky-top pt-4 pb-2 pb-lg-4 bg-body">
<div class="row sticky-top pt-3 pt-sm-4 pb-2 pb-lg-4 bg-body">
<app-filter-editor [hidden]="isBulkEditing" [(filterRules)]="list.filterRules" [unmodifiedFilterRules]="unmodifiedFilterRules" #filterEditor></app-filter-editor>
<app-bulk-editor [hidden]="!isBulkEditing"></app-bulk-editor>
</div>
@ -106,7 +107,7 @@
<ng-template #documentListNoError>
<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)" (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>
</div>
@ -137,6 +138,12 @@
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Document type</th>
<th class="d-none d-xl-table-cell"
sortable="storage_path__name"
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Storage path</th>
<th
sortable="created"
[currentSortField]="list.sortField"
@ -163,16 +170,21 @@
</td>
<td class="d-none d-md-table-cell">
<ng-container *ngIf="d.correspondent">
<a (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent">{{(d.correspondent$ | async)?.name}}</a>
<a (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent" i18n-title>{{(d.correspondent$ | async)?.name}}</a>
</ng-container>
</td>
<td>
<a routerLink="/documents/{{d.id}}" title="Edit document" 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" (click)="clickTag(t.id);$event.stopPropagation()"></app-tag>
<a (click)="openDocumentsService.openDocument(d)" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
<app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(t.id);$event.stopPropagation()"></app-tag>
</td>
<td class="d-none d-xl-table-cell">
<ng-container *ngIf="d.document_type">
<a (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a>
<a (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type" i18n-title>{{(d.document_type$ | async)?.name}}</a>
</ng-container>
</td>
<td class="d-none d-xl-table-cell">
<ng-container *ngIf="d.storage_path">
<a (click)="clickStoragePath(d.storage_path);$event.stopPropagation()" title="Filter by storage path" i18n-title>{{(d.storage_path$ | async)?.name}}</a>
</ng-container>
</td>
<td>
@ -186,7 +198,7 @@
</table>
<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)" (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 *ngIf="list.documents?.length > 15" class="mt-3">
<ng-container *ngTemplateOutlet="pagination"></ng-container>

View File

@ -9,20 +9,9 @@ import {
} from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import {
filter,
first,
map,
Subject,
Subscription,
switchMap,
takeUntil,
} from 'rxjs'
import { filter, first, map, Subject, switchMap, takeUntil } from 'rxjs'
import { FilterRule, isFullTextFilterRule } from 'src/app/data/filter-rule'
import {
FILTER_FULLTEXT_MORELIKE,
FILTER_RULE_TYPES,
} from 'src/app/data/filter-rule-type'
import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type'
import { PaperlessDocument } from 'src/app/data/paperless-document'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import {
@ -31,8 +20,9 @@ import {
} from 'src/app/directives/sortable.directive'
import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { QueryParamsService } from 'src/app/services/query-params.service'
import {
DocumentService,
DOCUMENT_SORT_FIELDS,
DOCUMENT_SORT_FIELDS_FULLTEXT,
} from 'src/app/services/rest/document.service'
@ -49,13 +39,14 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi
export class DocumentListComponent implements OnInit, OnDestroy, AfterViewInit {
constructor(
public list: DocumentListViewService,
private documentService: DocumentService,
public savedViewService: SavedViewService,
public route: ActivatedRoute,
private router: Router,
private toastService: ToastService,
private modalService: NgbModal,
private consumerStatusService: ConsumerStatusService
private consumerStatusService: ConsumerStatusService,
private queryParamsService: QueryParamsService,
public openDocumentsService: OpenDocumentsService
) {}
@ViewChild('filterEditor')
@ -83,8 +74,26 @@ export class DocumentListComponent implements OnInit, OnDestroy, AfterViewInit {
: DOCUMENT_SORT_FIELDS
}
set listSort(reverse: boolean) {
this.list.sortReverse = reverse
this.queryParamsService.sortField = this.list.sortField
this.queryParamsService.sortReverse = reverse
}
get listSort(): boolean {
return this.list.sortReverse
}
setSortField(field: string) {
this.list.sortField = field
this.queryParamsService.sortField = field
this.queryParamsService.sortReverse = this.listSort
}
onSort(event: SortEvent) {
this.list.setSort(event.column, event.reverse)
this.queryParamsService.sortField = event.column
this.queryParamsService.sortReverse = event.reverse
}
get isBulkEditing(): boolean {
@ -109,60 +118,39 @@ export class DocumentListComponent implements OnInit, OnDestroy, AfterViewInit {
this.route.paramMap
.pipe(
filter((params) => params.has('id')), // only on saved view
filter((params) => params.has('id')), // only on saved view e.g. /view/id
switchMap((params) => {
return this.savedViewService
.getCached(+params.get('id'))
.pipe(map((view) => ({ params, view })))
.pipe(map((view) => ({ view })))
})
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ view, params }) => {
.subscribe(({ view }) => {
if (!view) {
this.router.navigate(['404'])
return
}
this.list.activateSavedView(view)
this.list.reload()
this.queryParamsService.updateFromView(view)
this.unmodifiedFilterRules = view.filter_rules
})
const allFilterRuleQueryParams: string[] = FILTER_RULE_TYPES.map(
(rt) => rt.filtervar
)
this.route.queryParamMap
.pipe(
filter(() => !this.route.snapshot.paramMap.has('id')), // only when not on saved view
filter(() => !this.route.snapshot.paramMap.has('id')), // only when not on /view/id
takeUntil(this.unsubscribeNotifier)
)
.subscribe((queryParams) => {
// transform query params to filter rules
let filterRulesFromQueryParams: FilterRule[] = []
allFilterRuleQueryParams
.filter((frqp) => queryParams.has(frqp))
.forEach((filterQueryParamName) => {
const filterQueryParamValues: string[] = queryParams
.get(filterQueryParamName)
.split(',')
filterRulesFromQueryParams = filterRulesFromQueryParams.concat(
// map all values to filter rules
filterQueryParamValues.map((val) => {
return {
rule_type: FILTER_RULE_TYPES.find(
(rt) => rt.filtervar == filterQueryParamName
).id,
value: val,
}
})
)
})
this.list.activateSavedView(null)
this.list.filterRules = filterRulesFromQueryParams
this.list.reload()
this.unmodifiedFilterRules = []
if (queryParams.has('view')) {
// loading a saved view on /documents
this.loadViewConfig(parseInt(queryParams.get('view')))
} else {
this.list.activateSavedView(null)
this.queryParamsService.parseQueryParams(queryParams)
this.unmodifiedFilterRules = []
}
})
}
@ -171,17 +159,7 @@ export class DocumentListComponent implements OnInit, OnDestroy, AfterViewInit {
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (filterRules) => {
const params =
this.documentService.filterRulesToQueryParams(filterRules)
// if we were on a saved view we navigate 'away' to /documents
let base = []
if (this.route.snapshot.paramMap.has('id')) base = ['/documents']
this.router.navigate(base, {
relativeTo: this.route,
queryParams: params,
})
this.queryParamsService.updateFilterRules(filterRules)
},
})
}
@ -192,9 +170,15 @@ export class DocumentListComponent implements OnInit, OnDestroy, AfterViewInit {
this.unsubscribeNotifier.complete()
}
loadViewConfig(view: PaperlessSavedView) {
this.list.loadSavedView(view)
this.list.reload()
loadViewConfig(viewId: number) {
this.savedViewService
.getCached(viewId)
.pipe(first())
.subscribe((view) => {
this.list.loadSavedView(view)
this.list.reload()
this.queryParamsService.updateFromView(view)
})
}
saveViewConfig() {
@ -281,8 +265,15 @@ export class DocumentListComponent implements OnInit, OnDestroy, AfterViewInit {
})
}
clickStoragePath(storagePathID: number) {
this.list.selectNone()
setTimeout(() => {
this.filterEditor.addStoragePath(storagePathID)
})
}
clickMoreLike(documentID: number) {
this.list.quickFilter([
this.queryParamsService.navigateWithFilterRules([
{ rule_type: FILTER_FULLTEXT_MORELIKE, value: documentID.toString() },
])
}

View File

@ -1,57 +1,71 @@
<div class="row">
<div class="row flex-wrap">
<div class="col mb-2 mb-xl-0">
<div class="form-inline d-flex align-items-center">
<div class="input-group input-group-sm flex-fill w-auto">
<div class="input-group input-group-sm flex-fill w-auto flex-nowrap">
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{textFilterTargetName}}</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
<button *ngFor="let t of textFilterTargets" ngbDropdownItem [class.active]="textFilterTarget == t.id" (click)="changeTextFilterTarget(t.id)">{{t.name}}</button>
</div>
</div>
<input #textFilterInput class="form-control form-control-sm" type="text" [(ngModel)]="textFilter" (keyup.enter)="textFilterEnter()" [readonly]="textFilterTarget == 'fulltext-morelike'">
<select *ngIf="textFilterTarget == 'asn'" class="form-select flex-grow-0 w-auto" [(ngModel)]="textFilterModifier" (change)="textFilterModifierChange()">
<option *ngFor="let m of textFilterModifiers" ngbDropdownItem [value]="m.id">{{m.label}}</option>
</select>
<input #textFilterInput class="form-control form-control-sm" type="text" [disabled]="textFilterModifierIsNull" [(ngModel)]="textFilter" (keyup.enter)="textFilterEnter()" [readonly]="textFilterTarget == 'fulltext-morelike'">
</div>
</div>
</div>
<div class="w-100 d-xl-none"></div>
<div class="col col-xl-auto mb-2 mb-xl-0">
<div class="d-flex">
<app-filterable-dropdown class="me-2 flex-fill" title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags"
[(selectionModel)]="tagSelectionModel"
(selectionModelChange)="updateRules()"
[multiple]="true"
(open)="onTagsDropdownOpen()"
[allowSelectNone]="true"></app-filterable-dropdown>
<app-filterable-dropdown class="me-2 flex-fill" title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[items]="correspondents"
[(selectionModel)]="correspondentSelectionModel"
(selectionModelChange)="updateRules()"
(open)="onCorrespondentDropdownOpen()"
[allowSelectNone]="true"></app-filterable-dropdown>
<app-filterable-dropdown class="me-2 flex-fill" title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes"
[(selectionModel)]="documentTypeSelectionModel"
(open)="onDocumentTypeDropdownOpen()"
(selectionModelChange)="updateRules()"
[allowSelectNone]="true"></app-filterable-dropdown>
<app-date-dropdown class="me-2"
title="Created" i18n-title
(datesSet)="updateRules()"
[(dateBefore)]="dateCreatedBefore"
[(dateAfter)]="dateCreatedAfter"></app-date-dropdown>
<app-date-dropdown
[(dateBefore)]="dateAddedBefore"
[(dateAfter)]="dateAddedAfter"
title="Added" i18n-title
(datesSet)="updateRules()"></app-date-dropdown>
<div class="col col-xl-auto">
<div class="d-flex flex-wrap">
<div class="d-flex flex-wrap mb-2 mb-lg-0">
<app-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags"
[(selectionModel)]="tagSelectionModel"
(selectionModelChange)="updateRules()"
[multiple]="true"
(open)="onTagsDropdownOpen()"
[allowSelectNone]="true"></app-filterable-dropdown>
<app-filterable-dropdown class="flex-fill" title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[items]="correspondents"
[(selectionModel)]="correspondentSelectionModel"
(selectionModelChange)="updateRules()"
(open)="onCorrespondentDropdownOpen()"
[allowSelectNone]="true"></app-filterable-dropdown>
<app-filterable-dropdown class="flex-fill" title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes"
[(selectionModel)]="documentTypeSelectionModel"
(open)="onDocumentTypeDropdownOpen()"
(selectionModelChange)="updateRules()"
[allowSelectNone]="true"></app-filterable-dropdown>
<app-filterable-dropdown class="me-2 flex-fill" title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[items]="storagePaths"
[(selectionModel)]="storagePathSelectionModel"
(open)="onStoragePathDropdownOpen()"
(selectionModelChange)="updateRules()"
[allowSelectNone]="true"></app-filterable-dropdown>
</div>
<div class="d-flex flex-wrap">
<app-date-dropdown class="mb-2 mb-xl-0"
title="Created" i18n-title
(datesSet)="updateRules()"
[(dateBefore)]="dateCreatedBefore"
[(dateAfter)]="dateCreatedAfter"></app-date-dropdown>
<app-date-dropdown class="mb-2 mb-xl-0"
[(dateBefore)]="dateAddedBefore"
[(dateAfter)]="dateAddedAfter"
title="Added" i18n-title
(datesSet)="updateRules()"></app-date-dropdown>
</div>
</div>
</div>
<div class="w-100 d-xl-none"></div>
<div class="col col-xl-auto">
<button class="btn btn-link btn-sm px-0 mx-0 ms-xl-n3" [disabled]="!rulesModified" (click)="resetSelected()">
<div class="col col-xl-auto ps-0">
<button class="btn btn-link btn-sm px-0" [disabled]="!rulesModified" (click)="resetSelected()">
<svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-x me-1" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<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><ng-container i18n>Reset filters</ng-container>

View File

@ -14,6 +14,6 @@
border-bottom-right-radius: 0;
}
.me-2 {
margin-right: 0.7rem !important; // tweak to make room for badges
.d-flex.flex-wrap {
column-gap: 0.7rem;
}

View File

@ -33,11 +33,17 @@ import {
FILTER_DOES_NOT_HAVE_TAG,
FILTER_TITLE,
FILTER_TITLE_CONTENT,
FILTER_STORAGE_PATH,
FILTER_ASN_ISNULL,
FILTER_ASN_GT,
FILTER_ASN_LT,
} from 'src/app/data/filter-rule-type'
import { FilterableDropdownSelectionModel } from '../../common/filterable-dropdown/filterable-dropdown.component'
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
import { DocumentService } from 'src/app/services/rest/document.service'
import { PaperlessDocument } from 'src/app/data/paperless-document'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
const TEXT_FILTER_TARGET_TITLE = 'title'
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
@ -45,6 +51,12 @@ const TEXT_FILTER_TARGET_ASN = 'asn'
const TEXT_FILTER_TARGET_FULLTEXT_QUERY = 'fulltext-query'
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike'
const TEXT_FILTER_MODIFIER_EQUALS = 'equals'
const TEXT_FILTER_MODIFIER_NULL = 'is null'
const TEXT_FILTER_MODIFIER_NOTNULL = 'not null'
const TEXT_FILTER_MODIFIER_GT = 'greater'
const TEXT_FILTER_MODIFIER_LT = 'less'
@Component({
selector: 'app-filter-editor',
templateUrl: './filter-editor.component.html',
@ -98,7 +110,8 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
private documentTypeService: DocumentTypeService,
private tagService: TagService,
private correspondentService: CorrespondentService,
private documentService: DocumentService
private documentService: DocumentService,
private storagePathService: StoragePathService
) {}
@ViewChild('textFilterInput')
@ -107,6 +120,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
tags: PaperlessTag[] = []
correspondents: PaperlessCorrespondent[] = []
documentTypes: PaperlessDocumentType[] = []
storagePaths: PaperlessStoragePath[] = []
_textFilter = ''
_moreLikeId: number
@ -141,9 +155,43 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
?.name
}
public textFilterModifier: string
get textFilterModifiers() {
return [
{
id: TEXT_FILTER_MODIFIER_EQUALS,
label: $localize`equals`,
},
{
id: TEXT_FILTER_MODIFIER_NULL,
label: $localize`is empty`,
},
{
id: TEXT_FILTER_MODIFIER_NOTNULL,
label: $localize`is not empty`,
},
{
id: TEXT_FILTER_MODIFIER_GT,
label: $localize`greater than`,
},
{
id: TEXT_FILTER_MODIFIER_LT,
label: $localize`less than`,
},
]
}
get textFilterModifierIsNull(): boolean {
return [TEXT_FILTER_MODIFIER_NULL, TEXT_FILTER_MODIFIER_NOTNULL].includes(
this.textFilterModifier
)
}
tagSelectionModel = new FilterableDropdownSelectionModel()
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel()
dateCreatedBefore: string
dateCreatedAfter: string
@ -168,6 +216,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
this._filterRules = value
this.documentTypeSelectionModel.clear(false)
this.storagePathSelectionModel.clear(false)
this.tagSelectionModel.clear(false)
this.correspondentSelectionModel.clear(false)
this._textFilter = null
@ -176,6 +225,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
this.dateAddedAfter = null
this.dateCreatedBefore = null
this.dateCreatedAfter = null
this.textFilterModifier = TEXT_FILTER_MODIFIER_EQUALS
value.forEach((rule) => {
switch (rule.rule_type) {
@ -254,6 +304,27 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
false
)
break
case FILTER_STORAGE_PATH:
this.storagePathSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Selected,
false
)
break
case FILTER_ASN_ISNULL:
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
this.textFilterModifier = TEXT_FILTER_MODIFIER_NULL
break
case FILTER_ASN_GT:
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
this.textFilterModifier = TEXT_FILTER_MODIFIER_GT
this._textFilter = rule.value
break
case FILTER_ASN_LT:
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
this.textFilterModifier = TEXT_FILTER_MODIFIER_LT
this._textFilter = rule.value
break
}
})
this.checkIfRulesHaveChanged()
@ -273,8 +344,33 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) {
filterRules.push({ rule_type: FILTER_TITLE, value: this._textFilter })
}
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_ASN) {
filterRules.push({ rule_type: FILTER_ASN, value: this._textFilter })
if (this.textFilterTarget == TEXT_FILTER_TARGET_ASN) {
if (
this.textFilterModifier == TEXT_FILTER_MODIFIER_EQUALS &&
this._textFilter
) {
filterRules.push({ rule_type: FILTER_ASN, value: this._textFilter })
} else if (this.textFilterModifierIsNull) {
filterRules.push({
rule_type: FILTER_ASN_ISNULL,
value: (
this.textFilterModifier == TEXT_FILTER_MODIFIER_NULL
).toString(),
})
} else if (
[TEXT_FILTER_MODIFIER_GT, TEXT_FILTER_MODIFIER_LT].includes(
this.textFilterModifier
) &&
this._textFilter
) {
filterRules.push({
rule_type:
this.textFilterModifier == TEXT_FILTER_MODIFIER_GT
? FILTER_ASN_GT
: FILTER_ASN_LT,
value: this._textFilter,
})
}
}
if (
this._textFilter &&
@ -336,6 +432,12 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
value: documentType.id?.toString(),
})
})
this.storagePathSelectionModel.getSelectedItems().forEach((storagePath) => {
filterRules.push({
rule_type: FILTER_STORAGE_PATH,
value: storagePath.id?.toString(),
})
})
if (this.dateCreatedBefore) {
filterRules.push({
rule_type: FILTER_CREATED_BEFORE,
@ -398,7 +500,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
}
get textFilter() {
return this._textFilter
return this.textFilterModifierIsNull ? '' : this._textFilter
}
set textFilter(value) {
@ -418,6 +520,9 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
this.documentTypeService
.listAll()
.subscribe((result) => (this.documentTypes = result.results))
this.storagePathService
.listAll()
.subscribe((result) => (this.storagePaths = result.results))
this.textFilterDebounce = new Subject<string>()
@ -460,6 +565,13 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
)
}
addStoragePath(storagePathID: number) {
this.storagePathSelectionModel.set(
storagePathID,
ToggleableItemState.Selected
)
}
onTagsDropdownOpen() {
this.tagSelectionModel.apply()
}
@ -472,6 +584,10 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
this.documentTypeSelectionModel.apply()
}
onStoragePathDropdownOpen() {
this.storagePathSelectionModel.apply()
}
updateTextFilter(text) {
this._textFilter = text
this.documentService.searchQuery = text
@ -498,4 +614,18 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
this.textFilterInput.nativeElement.focus()
this.updateRules()
}
textFilterModifierChange() {
if (
this.textFilterModifierIsNull ||
([
TEXT_FILTER_MODIFIER_EQUALS,
TEXT_FILTER_MODIFIER_GT,
TEXT_FILTER_MODIFIER_LT,
].includes(this.textFilterModifier) &&
this._textFilter)
) {
this.updateRules()
}
}
}

View File

@ -3,7 +3,7 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { FILTER_CORRESPONDENT } from 'src/app/data/filter-rule-type'
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { QueryParamsService } from 'src/app/services/query-params.service'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { ToastService } from 'src/app/services/toast.service'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
@ -20,7 +20,7 @@ export class CorrespondentListComponent extends ManagementListComponent<Paperles
correspondentsService: CorrespondentService,
modalService: NgbModal,
toastService: ToastService,
list: DocumentListViewService,
queryParamsService: QueryParamsService,
private datePipe: CustomDatePipe
) {
super(
@ -28,13 +28,14 @@ export class CorrespondentListComponent extends ManagementListComponent<Paperles
modalService,
CorrespondentEditDialogComponent,
toastService,
list,
queryParamsService,
FILTER_CORRESPONDENT,
$localize`correspondent`,
$localize`correspondents`,
[
{
key: 'last_correspondence',
name: $localize`Last correspondence`,
name: $localize`Last used`,
valueFn: (c: PaperlessCorrespondent) => {
return this.datePipe.transform(c.last_correspondence)
},

View File

@ -2,7 +2,7 @@ import { Component } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { FILTER_DOCUMENT_TYPE } from 'src/app/data/filter-rule-type'
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { QueryParamsService } from 'src/app/services/query-params.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { ToastService } from 'src/app/services/toast.service'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
@ -18,16 +18,17 @@ export class DocumentTypeListComponent extends ManagementListComponent<Paperless
documentTypeService: DocumentTypeService,
modalService: NgbModal,
toastService: ToastService,
list: DocumentListViewService
queryParamsService: QueryParamsService
) {
super(
documentTypeService,
modalService,
DocumentTypeEditDialogComponent,
toastService,
list,
queryParamsService,
FILTER_DOCUMENT_TYPE,
$localize`document type`,
$localize`document types`,
[]
)
}

View File

@ -1,4 +1,4 @@
<app-page-header title="{{ typeName | titlecase }}s">
<app-page-header title="{{ typeNamePlural | titlecase }}">
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" i18n>Create</button>
</app-page-header>
@ -17,7 +17,7 @@
<thead>
<tr>
<th scope="col" sortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th>
<th scope="col" sortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
<th scope="col" class="d-none d-sm-table-cell" sortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
<th scope="col" sortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
<th scope="col" *ngFor="let column of extraColumns" sortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
<th scope="col" i18n>Actions</th>
@ -26,14 +26,28 @@
<tbody>
<tr *ngFor="let object of data">
<td scope="row">{{ object.name }}</td>
<td scope="row">{{ getMatching(object) }}</td>
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
<td scope="row">{{ object.document_count }}</td>
<td scope="row" *ngFor="let column of extraColumns">
<div *ngIf="column.rendersHtml; else colValue" [innerHtml]="column.valueFn.call(null, object) | safeHtml"></div>
<ng-template #colValue>{{ column.valueFn.call(null, object) }}</ng-template>
</td>
<td scope="row">
<div class="btn-group">
<div class="btn-group d-block d-sm-none">
<div ngbDropdown class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" ngbDropdownToggle>
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#three-dots-vertical" />
</svg>
</button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
<button (click)="filterDocuments(object)" ngbDropdownItem i18n>Filter Documents</button>
<button (click)="openEditDialog(object)" ngbDropdownItem i18n>Edit</button>
<button class="text-danger" (click)="openDeleteDialog(object)" ngbDropdownItem i18n>Delete</button>
</div>
</div>
</div>
<div class="btn-group d-none d-sm-block">
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-funnel" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/>
@ -57,6 +71,6 @@
</table>
<div class="d-flex">
<div i18n *ngIf="collectionSize > 0">{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeName}}s}}</div>
<div i18n *ngIf="collectionSize > 0">{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</div>
<ngb-pagination *ngIf="collectionSize > 20" class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" (pageChange)="reloadData()" aria-label="Default pagination"></ngb-pagination>
</div>

View File

@ -0,0 +1,4 @@
// hide caret on mobile dropdown
.d-block.d-sm-none .dropdown-toggle::after {
display: none;
}

View File

@ -18,7 +18,7 @@ import {
SortableDirective,
SortEvent,
} from 'src/app/directives/sortable.directive'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { QueryParamsService } from 'src/app/services/query-params.service'
import { AbstractNameFilterService } from 'src/app/services/rest/abstract-name-filter-service'
import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
@ -42,9 +42,10 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
private modalService: NgbModal,
private editDialogComponent: any,
private toastService: ToastService,
private list: DocumentListViewService,
private queryParamsService: QueryParamsService,
protected filterRuleType: number,
public typeName: string,
public typeNamePlural: string,
public extraColumns: ManagementListColumn[]
) {}
@ -140,7 +141,7 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
}
filterDocuments(object: ObjectWithId) {
this.list.quickFilter([
this.queryParamsService.navigateWithFilterRules([
{ rule_type: this.filterRuleType, value: object.id.toString() },
])
}

View File

@ -13,11 +13,11 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import {
LanguageOption,
SettingsService,
SETTINGS_KEYS,
} from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
import { Observable, Subscription, BehaviorSubject } from 'rxjs'
import { Observable, Subscription, BehaviorSubject, first } from 'rxjs'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
@Component({
selector: 'app-settings',
@ -227,10 +227,23 @@ export class SettingsComponent implements OnInit, OnDestroy, DirtyComponent {
this.settingsForm.value.notificationsConsumerSuppressOnDashboard
)
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
this.store.next(this.settingsForm.value)
this.documentListViewService.updatePageSize()
this.settings.updateAppearanceSettings()
this.toastService.showInfo($localize`Settings saved successfully.`)
this.settings
.storeSettings()
.pipe(first())
.subscribe({
next: () => {
this.store.next(this.settingsForm.value)
this.documentListViewService.updatePageSize()
this.settings.updateAppearanceSettings()
this.toastService.showInfo($localize`Settings saved successfully.`)
},
error: (error) => {
this.toastService.showError(
$localize`An error occurred while saving settings.`
)
console.log(error)
},
})
}
get displayLanguageOptions(): LanguageOption[] {

View File

@ -0,0 +1,48 @@
import { Component } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { FILTER_STORAGE_PATH } from 'src/app/data/filter-rule-type'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { QueryParamsService } from 'src/app/services/query-params.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { ToastService } from 'src/app/services/toast.service'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { ManagementListComponent } from '../management-list/management-list.component'
@Component({
selector: 'app-storage-path-list',
templateUrl: './../management-list/management-list.component.html',
styleUrls: ['./../management-list/management-list.component.scss'],
})
export class StoragePathListComponent extends ManagementListComponent<PaperlessStoragePath> {
constructor(
directoryService: StoragePathService,
modalService: NgbModal,
toastService: ToastService,
queryParamsService: QueryParamsService
) {
super(
directoryService,
modalService,
StoragePathEditDialogComponent,
toastService,
queryParamsService,
FILTER_STORAGE_PATH,
$localize`storage path`,
$localize`storage paths`,
[
{
key: 'path',
name: $localize`Path`,
valueFn: (c: PaperlessStoragePath) => {
return c.path
},
},
]
)
}
getDeleteMessage(object: PaperlessStoragePath) {
return $localize`Do you really want to delete the storage path "${object.name}"?`
}
}

View File

@ -2,7 +2,7 @@ import { Component } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import { PaperlessTag } from 'src/app/data/paperless-tag'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { QueryParamsService } from 'src/app/services/query-params.service'
import { TagService } from 'src/app/services/rest/tag.service'
import { ToastService } from 'src/app/services/toast.service'
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
@ -18,16 +18,17 @@ export class TagListComponent extends ManagementListComponent<PaperlessTag> {
tagService: TagService,
modalService: NgbModal,
toastService: ToastService,
list: DocumentListViewService
queryParamsService: QueryParamsService
) {
super(
tagService,
modalService,
TagEditDialogComponent,
toastService,
list,
queryParamsService,
FILTER_HAS_TAGS_ALL,
$localize`tag`,
$localize`tags`,
[
{
key: 'color',

View File

@ -20,11 +20,15 @@ export const FILTER_MODIFIED_AFTER = 16
export const FILTER_DOES_NOT_HAVE_TAG = 17
export const FILTER_ASN_ISNULL = 18
export const FILTER_ASN_GT = 19
export const FILTER_ASN_LT = 20
export const FILTER_TITLE_CONTENT = 19
export const FILTER_TITLE_CONTENT = 21
export const FILTER_FULLTEXT_QUERY = 20
export const FILTER_FULLTEXT_MORELIKE = 21
export const FILTER_FULLTEXT_QUERY = 22
export const FILTER_FULLTEXT_MORELIKE = 23
export const FILTER_STORAGE_PATH = 30
export const FILTER_RULE_TYPES: FilterRuleType[] = [
{
@ -41,14 +45,12 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
multi: false,
default: '',
},
{
id: FILTER_ASN,
filtervar: 'archive_serial_number',
datatype: 'number',
multi: false,
},
{
id: FILTER_CORRESPONDENT,
filtervar: 'correspondent__id',
@ -56,6 +58,13 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'correspondent',
multi: false,
},
{
id: FILTER_STORAGE_PATH,
filtervar: 'storage_path__id',
isnull_filtervar: 'storage_path__isnull',
datatype: 'storage_path',
multi: false,
},
{
id: FILTER_DOCUMENT_TYPE,
filtervar: 'document_type__id',
@ -63,7 +72,6 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'document_type',
multi: false,
},
{
id: FILTER_IS_IN_INBOX,
filtervar: 'is_in_inbox',
@ -96,7 +104,6 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
multi: false,
default: true,
},
{
id: FILTER_CREATED_BEFORE,
filtervar: 'created__date__lt',
@ -109,7 +116,6 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'date',
multi: false,
},
{
id: FILTER_CREATED_YEAR,
filtervar: 'created__year',
@ -141,7 +147,6 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'date',
multi: false,
},
{
id: FILTER_MODIFIED_BEFORE,
filtervar: 'modified__date__lt',
@ -160,21 +165,30 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'boolean',
multi: false,
},
{
id: FILTER_ASN_GT,
filtervar: 'archive_serial_number__gt',
datatype: 'number',
multi: false,
},
{
id: FILTER_ASN_LT,
filtervar: 'archive_serial_number__lt',
datatype: 'number',
multi: false,
},
{
id: FILTER_TITLE_CONTENT,
filtervar: 'title_content',
datatype: 'string',
multi: false,
},
{
id: FILTER_FULLTEXT_QUERY,
filtervar: 'query',
datatype: 'string',
multi: false,
},
{
id: FILTER_FULLTEXT_MORELIKE,
filtervar: 'more_like_id',

View File

@ -4,4 +4,6 @@ export interface PaperlessDocumentSuggestions {
correspondents?: number[]
document_types?: number[]
storage_paths?: number[]
}

View File

@ -3,6 +3,7 @@ import { ObjectWithId } from './object-with-id'
import { PaperlessTag } from './paperless-tag'
import { PaperlessDocumentType } from './paperless-document-type'
import { Observable } from 'rxjs'
import { PaperlessStoragePath } from './paperless-storage-path'
export interface SearchHit {
score?: number
@ -20,6 +21,10 @@ export interface PaperlessDocument extends ObjectWithId {
document_type?: number
storage_path$?: Observable<PaperlessStoragePath>
storage_path?: number
title?: string
content?: string

View File

@ -0,0 +1,5 @@
import { MatchingModel } from './matching-model'
export interface PaperlessStoragePath extends MatchingModel {
path?: string
}

View File

@ -0,0 +1,117 @@
export interface PaperlessUiSettings {
user_id: number
username: string
display_name: string
settings: Object
}
export interface PaperlessUiSetting {
key: string
type: string
default: any
}
export const SETTINGS_KEYS = {
LANGUAGE: 'language',
// maintain old general-settings: for backwards compatibility
BULK_EDIT_CONFIRMATION_DIALOGS:
'general-settings:bulk-edit:confirmation-dialogs',
BULK_EDIT_APPLY_ON_CLOSE: 'general-settings:bulk-edit:apply-on-close',
DOCUMENT_LIST_SIZE: 'general-settings:documentListSize',
DARK_MODE_USE_SYSTEM: 'general-settings:dark-mode:use-system',
DARK_MODE_ENABLED: 'general-settings:dark-mode:enabled',
DARK_MODE_THUMB_INVERTED: 'general-settings:dark-mode:thumb-inverted',
THEME_COLOR: 'general-settings:theme:color',
USE_NATIVE_PDF_VIEWER: 'general-settings:document-details:native-pdf-viewer',
DATE_LOCALE: 'general-settings:date-display:date-locale',
DATE_FORMAT: 'general-settings:date-display:date-format',
NOTIFICATIONS_CONSUMER_NEW_DOCUMENT:
'general-settings:notifications:consumer-new-documents',
NOTIFICATIONS_CONSUMER_SUCCESS:
'general-settings:notifications:consumer-success',
NOTIFICATIONS_CONSUMER_FAILED:
'general-settings:notifications:consumer-failed',
NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD:
'general-settings:notifications:consumer-suppress-on-dashboard',
}
export const SETTINGS: PaperlessUiSetting[] = [
{
key: SETTINGS_KEYS.LANGUAGE,
type: 'string',
default: '',
},
{
key: SETTINGS_KEYS.BULK_EDIT_CONFIRMATION_DIALOGS,
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE,
type: 'boolean',
default: false,
},
{
key: SETTINGS_KEYS.DOCUMENT_LIST_SIZE,
type: 'number',
default: 50,
},
{
key: SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.DARK_MODE_ENABLED,
type: 'boolean',
default: false,
},
{
key: SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED,
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.THEME_COLOR,
type: 'string',
default: '',
},
{
key: SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER,
type: 'boolean',
default: false,
},
{
key: SETTINGS_KEYS.DATE_LOCALE,
type: 'string',
default: '',
},
{
key: SETTINGS_KEYS.DATE_FORMAT,
type: 'string',
default: 'mediumDate',
},
{
key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS,
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED,
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD,
type: 'boolean',
default: true,
},
]

View File

@ -1,6 +1,7 @@
import { DatePipe } from '@angular/common'
import { Inject, LOCALE_ID, Pipe, PipeTransform } from '@angular/core'
import { SettingsService, SETTINGS_KEYS } from '../services/settings.service'
import { SETTINGS_KEYS } from '../data/paperless-uisettings'
import { SettingsService } from '../services/settings.service'
import { normalizeDateStr } from '../utils/date'
const FORMAT_TO_ISO_FORMAT = {

View File

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { ActivatedRoute, Params, Router } from '@angular/router'
import { Observable } from 'rxjs'
import {
cloneFilterRules,
@ -8,9 +8,10 @@ import {
} from '../data/filter-rule'
import { PaperlessDocument } from '../data/paperless-document'
import { PaperlessSavedView } from '../data/paperless-saved-view'
import { SETTINGS_KEYS } from '../data/paperless-uisettings'
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'
import { DocumentService, DOCUMENT_SORT_FIELDS } from './rest/document.service'
import { SettingsService, SETTINGS_KEYS } from './settings.service'
import { SettingsService } from './settings.service'
/**
* Captures the current state of the list view.
@ -220,6 +221,13 @@ export class DocumentListViewService {
return this.activeListViewState.sortReverse
}
get sortParams(): Params {
return {
sortField: this.sortField,
sortReverse: this.sortReverse,
}
}
get collectionSize(): number {
return this.activeListViewState.collectionSize
}
@ -265,14 +273,6 @@ export class DocumentListViewService {
}
}
quickFilter(filterRules: FilterRule[]) {
const params = this.documentService.filterRulesToQueryParams(filterRules)
this.router.navigate(['/documents'], {
relativeTo: this.route,
queryParams: params,
})
}
getLastPage(): number {
return Math.ceil(this.collectionSize / this.currentPageSize)
}
@ -434,9 +434,7 @@ export class DocumentListViewService {
constructor(
private documentService: DocumentService,
private settings: SettingsService,
private router: Router,
private route: ActivatedRoute
private settings: SettingsService
) {
let documentListViewConfigJson = localStorage.getItem(
DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG

View File

@ -6,6 +6,7 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { Observable, Subject, of } from 'rxjs'
import { first } from 'rxjs/operators'
import { Router } from '@angular/router'
@Injectable({
providedIn: 'root',
@ -15,7 +16,8 @@ export class OpenDocumentsService {
constructor(
private documentService: DocumentService,
private modalService: NgbModal
private modalService: NgbModal,
private router: Router
) {
if (sessionStorage.getItem(OPEN_DOCUMENT_SERVICE.DOCUMENTS)) {
try {
@ -55,13 +57,38 @@ export class OpenDocumentsService {
return this.openDocuments.find((d) => d.id == id)
}
openDocument(doc: PaperlessDocument) {
openDocument(
doc: PaperlessDocument,
navigate: boolean = true
): Observable<boolean> {
if (this.openDocuments.find((d) => d.id == doc.id) == null) {
this.openDocuments.unshift(doc)
if (this.openDocuments.length > this.MAX_OPEN_DOCUMENTS) {
this.openDocuments.pop()
if (this.openDocuments.length == this.MAX_OPEN_DOCUMENTS) {
// at max, ensure changes arent lost
const docToRemove = this.openDocuments[this.MAX_OPEN_DOCUMENTS - 1]
const closeObservable = this.closeDocument(docToRemove)
closeObservable.pipe(first()).subscribe((closed) => {
if (closed) this.finishOpenDocument(doc, navigate)
})
return closeObservable
} else {
// not at max
this.finishOpenDocument(doc, navigate)
}
this.save()
} else {
// doc is open, just maybe navigate
if (navigate) {
this.router.navigate(['documents', doc.id])
}
}
return of(true)
}
private finishOpenDocument(doc: PaperlessDocument, navigate: boolean) {
this.openDocuments.unshift(doc)
this.dirtyDocuments.delete(doc.id)
this.save()
if (navigate) {
this.router.navigate(['documents', doc.id])
}
}
@ -82,7 +109,11 @@ export class OpenDocumentsService {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Unsaved Changes`
modal.componentInstance.messageBold = $localize`You have unsaved changes.`
modal.componentInstance.messageBold =
$localize`You have unsaved changes to the document` +
' "' +
doc.title +
'".'
modal.componentInstance.message = $localize`Are you sure you want to close this document?`
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Close document`

View File

@ -0,0 +1,163 @@
import { Injectable } from '@angular/core'
import { ParamMap, Params, Router } from '@angular/router'
import { FilterRule } from '../data/filter-rule'
import { FilterRuleType, FILTER_RULE_TYPES } from '../data/filter-rule-type'
import { PaperlessSavedView } from '../data/paperless-saved-view'
import { DocumentListViewService } from './document-list-view.service'
const SORT_FIELD_PARAMETER = 'sort'
const SORT_REVERSE_PARAMETER = 'reverse'
@Injectable({
providedIn: 'root',
})
export class QueryParamsService {
constructor(private router: Router, private list: DocumentListViewService) {}
private filterParams: Params = {}
private sortParams: Params = {}
updateFilterRules(
filterRules: FilterRule[],
updateQueryParams: boolean = true
) {
this.filterParams = filterRulesToQueryParams(filterRules)
if (updateQueryParams) this.updateQueryParams()
}
set sortField(field: string) {
this.sortParams[SORT_FIELD_PARAMETER] = field
this.updateQueryParams()
}
set sortReverse(reverse: boolean) {
if (!reverse) this.sortParams[SORT_REVERSE_PARAMETER] = undefined
else this.sortParams[SORT_REVERSE_PARAMETER] = reverse
this.updateQueryParams()
}
get params(): Params {
return {
...this.sortParams,
...this.filterParams,
}
}
private updateQueryParams() {
// if we were on a saved view we navigate 'away' to /documents
let base = []
if (this.router.routerState.snapshot.url.includes('/view/'))
base = ['/documents']
this.router.navigate(base, {
queryParams: this.params,
})
}
public parseQueryParams(queryParams: ParamMap) {
let filterRules = filterRulesFromQueryParams(queryParams)
if (
filterRules.length ||
queryParams.has(SORT_FIELD_PARAMETER) ||
queryParams.has(SORT_REVERSE_PARAMETER)
) {
this.list.filterRules = filterRules
this.list.sortField = queryParams.get(SORT_FIELD_PARAMETER)
this.list.sortReverse =
queryParams.has(SORT_REVERSE_PARAMETER) ||
(!queryParams.has(SORT_FIELD_PARAMETER) &&
!queryParams.has(SORT_REVERSE_PARAMETER))
this.list.reload()
} else if (
filterRules.length == 0 &&
!queryParams.has(SORT_FIELD_PARAMETER)
) {
// this is navigating to /documents so we need to update the params from the list
this.updateFilterRules(this.list.filterRules, false)
this.sortParams[SORT_FIELD_PARAMETER] = this.list.sortField
this.sortParams[SORT_REVERSE_PARAMETER] = this.list.sortReverse
this.router.navigate([], {
queryParams: this.params,
replaceUrl: true,
})
}
}
updateFromView(view: PaperlessSavedView) {
if (!this.router.routerState.snapshot.url.includes('/view/')) {
// navigation for /documents?view=
this.router.navigate([], {
queryParams: { view: view.id },
})
}
// make sure params are up-to-date
this.updateFilterRules(view.filter_rules, false)
this.sortParams[SORT_FIELD_PARAMETER] = this.list.sortField
this.sortParams[SORT_REVERSE_PARAMETER] = this.list.sortReverse
}
navigateWithFilterRules(filterRules: FilterRule[]) {
this.updateFilterRules(filterRules)
this.router.navigate(['/documents'], {
queryParams: this.params,
})
}
}
export function filterRulesToQueryParams(filterRules: FilterRule[]): Object {
if (filterRules) {
let params = {}
for (let rule of filterRules) {
let ruleType = FILTER_RULE_TYPES.find((t) => t.id == rule.rule_type)
if (ruleType.multi) {
params[ruleType.filtervar] = params[ruleType.filtervar]
? params[ruleType.filtervar] + ',' + rule.value
: rule.value
} else if (ruleType.isnull_filtervar && rule.value == null) {
params[ruleType.isnull_filtervar] = true
} else {
params[ruleType.filtervar] = rule.value
}
}
return params
} else {
return null
}
}
export function filterRulesFromQueryParams(queryParams: ParamMap) {
const allFilterRuleQueryParams: string[] = FILTER_RULE_TYPES.map(
(rt) => rt.filtervar
)
.concat(FILTER_RULE_TYPES.map((rt) => rt.isnull_filtervar))
.filter((rt) => rt !== undefined)
// transform query params to filter rules
let filterRulesFromQueryParams: FilterRule[] = []
allFilterRuleQueryParams
.filter((frqp) => queryParams.has(frqp))
.forEach((filterQueryParamName) => {
const rule_type: FilterRuleType = FILTER_RULE_TYPES.find(
(rt) =>
rt.filtervar == filterQueryParamName ||
rt.isnull_filtervar == filterQueryParamName
)
const isNullRuleType = rule_type.isnull_filtervar == filterQueryParamName
const valueURIComponent: string = queryParams.get(filterQueryParamName)
const filterQueryParamValues: string[] = rule_type.multi
? valueURIComponent.split(',')
: [valueURIComponent]
filterRulesFromQueryParams = filterRulesFromQueryParams.concat(
// map all values to filter rules
filterQueryParamValues.map((val) => {
return {
rule_type: rule_type.id,
value: isNullRuleType ? null : val,
}
})
)
})
return filterRulesFromQueryParams
}

View File

@ -10,8 +10,9 @@ import { map } from 'rxjs/operators'
import { CorrespondentService } from './correspondent.service'
import { DocumentTypeService } from './document-type.service'
import { TagService } from './tag.service'
import { FILTER_RULE_TYPES } from 'src/app/data/filter-rule-type'
import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions'
import { filterRulesToQueryParams } from '../query-params.service'
import { StoragePathService } from './storage-path.service'
export const DOCUMENT_SORT_FIELDS = [
{ field: 'archive_serial_number', name: $localize`ASN` },
@ -37,6 +38,7 @@ export interface SelectionDataItem {
}
export interface SelectionData {
selected_storage_paths: SelectionDataItem[]
selected_correspondents: SelectionDataItem[]
selected_tags: SelectionDataItem[]
selected_document_types: SelectionDataItem[]
@ -52,32 +54,12 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument>
http: HttpClient,
private correspondentService: CorrespondentService,
private documentTypeService: DocumentTypeService,
private tagService: TagService
private tagService: TagService,
private storagePathService: StoragePathService
) {
super(http, 'documents')
}
public filterRulesToQueryParams(filterRules: FilterRule[]): Object {
if (filterRules) {
let params = {}
for (let rule of filterRules) {
let ruleType = FILTER_RULE_TYPES.find((t) => t.id == rule.rule_type)
if (ruleType.multi) {
params[ruleType.filtervar] = params[ruleType.filtervar]
? params[ruleType.filtervar] + ',' + rule.value
: rule.value
} else if (ruleType.isnull_filtervar && rule.value == null) {
params[ruleType.isnull_filtervar] = true
} else {
params[ruleType.filtervar] = rule.value
}
}
return params
} else {
return null
}
}
addObservablesToDocument(doc: PaperlessDocument) {
if (doc.correspondent) {
doc.correspondent$ = this.correspondentService.getCached(
@ -90,6 +72,9 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument>
if (doc.tags) {
doc.tags$ = this.tagService.getCachedMany(doc.tags)
}
if (doc.storage_path) {
doc.storage_path$ = this.storagePathService.getCached(doc.storage_path)
}
return doc
}
@ -106,7 +91,7 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument>
pageSize,
sortField,
sortReverse,
Object.assign(extraParams, this.filterRulesToQueryParams(filterRules))
Object.assign(extraParams, filterRulesToQueryParams(filterRules))
).pipe(
map((results) => {
results.results.forEach((doc) => this.addObservablesToDocument(doc))

View File

@ -9,13 +9,19 @@ import { AbstractPaperlessService } from './abstract-paperless-service'
providedIn: 'root',
})
export class SavedViewService extends AbstractPaperlessService<PaperlessSavedView> {
loading: boolean
constructor(http: HttpClient) {
super(http, 'saved_views')
this.reload()
}
private reload() {
this.listAll().subscribe((r) => (this.savedViews = r.results))
this.loading = true
this.listAll().subscribe((r) => {
this.savedViews = r.results
this.loading = false
})
}
private savedViews: PaperlessSavedView[] = []

View File

@ -0,0 +1,13 @@
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { AbstractNameFilterService } from './abstract-name-filter-service'
@Injectable({
providedIn: 'root',
})
export class StoragePathService extends AbstractNameFilterService<PaperlessStoragePath> {
constructor(http: HttpClient) {
super(http, 'storage_paths')
}
}

View File

@ -1,4 +1,5 @@
import { DOCUMENT } from '@angular/common'
import { HttpClient } from '@angular/common/http'
import {
Inject,
Injectable,
@ -9,17 +10,19 @@ import {
} from '@angular/core'
import { Meta } from '@angular/platform-browser'
import { CookieService } from 'ngx-cookie-service'
import { first, Observable, tap } from 'rxjs'
import {
BRIGHTNESS,
estimateBrightnessForColor,
hexToHsl,
} from 'src/app/utils/color'
export interface PaperlessSettings {
key: string
type: string
default: any
}
import { environment } from 'src/environments/environment'
import {
PaperlessUiSettings,
SETTINGS,
SETTINGS_KEYS,
} from '../data/paperless-uisettings'
import { ToastService } from './toast.service'
export interface LanguageOption {
code: string
@ -32,89 +35,42 @@ export interface LanguageOption {
dateInputFormat?: string
}
export const SETTINGS_KEYS = {
BULK_EDIT_CONFIRMATION_DIALOGS:
'general-settings:bulk-edit:confirmation-dialogs',
BULK_EDIT_APPLY_ON_CLOSE: 'general-settings:bulk-edit:apply-on-close',
DOCUMENT_LIST_SIZE: 'general-settings:documentListSize',
DARK_MODE_USE_SYSTEM: 'general-settings:dark-mode:use-system',
DARK_MODE_ENABLED: 'general-settings:dark-mode:enabled',
DARK_MODE_THUMB_INVERTED: 'general-settings:dark-mode:thumb-inverted',
THEME_COLOR: 'general-settings:theme:color',
USE_NATIVE_PDF_VIEWER: 'general-settings:document-details:native-pdf-viewer',
DATE_LOCALE: 'general-settings:date-display:date-locale',
DATE_FORMAT: 'general-settings:date-display:date-format',
NOTIFICATIONS_CONSUMER_NEW_DOCUMENT:
'general-settings:notifications:consumer-new-documents',
NOTIFICATIONS_CONSUMER_SUCCESS:
'general-settings:notifications:consumer-success',
NOTIFICATIONS_CONSUMER_FAILED:
'general-settings:notifications:consumer-failed',
NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD:
'general-settings:notifications:consumer-suppress-on-dashboard',
}
const SETTINGS: PaperlessSettings[] = [
{
key: SETTINGS_KEYS.BULK_EDIT_CONFIRMATION_DIALOGS,
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE,
type: 'boolean',
default: false,
},
{ key: SETTINGS_KEYS.DOCUMENT_LIST_SIZE, type: 'number', default: 50 },
{ key: SETTINGS_KEYS.DARK_MODE_USE_SYSTEM, type: 'boolean', default: true },
{ key: SETTINGS_KEYS.DARK_MODE_ENABLED, type: 'boolean', default: false },
{
key: SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED,
type: 'boolean',
default: true,
},
{ key: SETTINGS_KEYS.THEME_COLOR, type: 'string', default: '' },
{ key: SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, type: 'boolean', default: false },
{ key: SETTINGS_KEYS.DATE_LOCALE, type: 'string', default: '' },
{ key: SETTINGS_KEYS.DATE_FORMAT, type: 'string', default: 'mediumDate' },
{
key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS,
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED,
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD,
type: 'boolean',
default: true,
},
]
@Injectable({
providedIn: 'root',
})
export class SettingsService {
private renderer: Renderer2
protected baseUrl: string = environment.apiBaseUrl + 'ui_settings/'
private settings: Object = {}
public displayName: string
constructor(
private rendererFactory: RendererFactory2,
rendererFactory: RendererFactory2,
@Inject(DOCUMENT) private document,
private cookieService: CookieService,
private meta: Meta,
@Inject(LOCALE_ID) private localeId: string
@Inject(LOCALE_ID) private localeId: string,
protected http: HttpClient,
private toastService: ToastService
) {
this.renderer = rendererFactory.createRenderer(null, null)
}
this.updateAppearanceSettings()
// this is called by the app initializer in app.module
public initializeSettings(): Observable<PaperlessUiSettings> {
return this.http.get<PaperlessUiSettings>(this.baseUrl).pipe(
first(),
tap((uisettings) => {
Object.assign(this.settings, uisettings.settings)
this.maybeMigrateSettings()
// to update lang cookie
if (this.settings['language']?.length)
this.setLanguage(this.settings['language'])
this.displayName = uisettings.display_name.trim()
})
)
}
public updateAppearanceSettings(
@ -333,11 +289,13 @@ export class SettingsService {
}
getLanguage(): string {
return this.cookieService.get(this.getLanguageCookieName())
return this.get(SETTINGS_KEYS.LANGUAGE)
}
setLanguage(language: string) {
if (language) {
this.set(SETTINGS_KEYS.LANGUAGE, language)
if (language?.length) {
// for Django
this.cookieService.set(this.getLanguageCookieName(), language)
} else {
this.cookieService.delete(this.getLanguageCookieName())
@ -362,7 +320,16 @@ export class SettingsService {
return null
}
let value = localStorage.getItem(key)
let value = null
// parse key:key:key into nested object
const keys = key.replace('general-settings:', '').split(':')
let settingObj = this.settings
keys.forEach((keyPart, index) => {
keyPart = keyPart.replace(/-/g, '_')
if (!settingObj.hasOwnProperty(keyPart)) return
if (index == keys.length - 1) value = settingObj[keyPart]
else settingObj = settingObj[keyPart]
})
if (value != null) {
switch (setting.type) {
@ -381,10 +348,57 @@ export class SettingsService {
}
set(key: string, value: any) {
localStorage.setItem(key, value.toString())
// parse key:key:key into nested object
let settingObj = this.settings
const keys = key.replace('general-settings:', '').split(':')
keys.forEach((keyPart, index) => {
keyPart = keyPart.replace(/-/g, '_')
if (!settingObj.hasOwnProperty(keyPart)) settingObj[keyPart] = {}
if (index == keys.length - 1) settingObj[keyPart] = value
else settingObj = settingObj[keyPart]
})
}
unset(key: string) {
localStorage.removeItem(key)
storeSettings(): Observable<any> {
return this.http.post(this.baseUrl, { settings: this.settings })
}
maybeMigrateSettings() {
if (
!this.settings.hasOwnProperty('documentListSize') &&
localStorage.getItem(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)
) {
// lets migrate
const successMessage = $localize`Successfully completed one-time migratration of settings to the database!`
const errorMessage = $localize`Unable to migrate settings to the database, please try saving manually.`
try {
for (const setting in SETTINGS_KEYS) {
const key = SETTINGS_KEYS[setting]
const value = localStorage.getItem(key)
this.set(key, value)
}
this.set(
SETTINGS_KEYS.LANGUAGE,
this.cookieService.get(this.getLanguageCookieName())
)
} catch (error) {
this.toastService.showError(errorMessage)
console.log(error)
}
this.storeSettings()
.pipe(first())
.subscribe({
next: () => {
this.updateAppearanceSettings()
this.toastService.showInfo(successMessage)
},
error: (e) => {
this.toastService.showError(errorMessage)
console.log(e)
},
})
}
}
}

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