Merge pull request #1240 from paperless-ngx/beta

[Beta] Paperless-ngx v1.8.0 Release Candidate 1
This commit is contained in:
shamoon 2022-07-28 15:17:30 -07:00 committed by GitHub
commit 5fe435048b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
277 changed files with 56739 additions and 28967 deletions

254
.github/scripts/cleanup-tags.py vendored Normal file
View File

@ -0,0 +1,254 @@
import logging
import os
from argparse import ArgumentParser
from typing import Final
from typing import List
from urllib.parse import quote
import requests
from common import get_log_level
logger = logging.getLogger("cleanup-tags")
class GithubContainerRegistry:
def __init__(
self,
session: requests.Session,
token: str,
owner_or_org: str,
):
self._session: requests.Session = session
self._token = token
self._owner_or_org = owner_or_org
# https://docs.github.com/en/rest/branches/branches
self._BRANCHES_ENDPOINT = "https://api.github.com/repos/{OWNER}/{REPO}/branches"
if self._owner_or_org == "paperless-ngx":
# https://docs.github.com/en/rest/packages#get-all-package-versions-for-a-package-owned-by-an-organization
self._PACKAGES_VERSIONS_ENDPOINT = "https://api.github.com/orgs/{ORG}/packages/{PACKAGE_TYPE}/{PACKAGE_NAME}/versions"
# https://docs.github.com/en/rest/packages#delete-package-version-for-an-organization
self._PACKAGE_VERSION_DELETE_ENDPOINT = "https://api.github.com/orgs/{ORG}/packages/{PACKAGE_TYPE}/{PACKAGE_NAME}/versions/{PACKAGE_VERSION_ID}"
else:
# https://docs.github.com/en/rest/packages#get-all-package-versions-for-a-package-owned-by-the-authenticated-user
self._PACKAGES_VERSIONS_ENDPOINT = "https://api.github.com/user/packages/{PACKAGE_TYPE}/{PACKAGE_NAME}/versions"
# https://docs.github.com/en/rest/packages#delete-a-package-version-for-the-authenticated-user
self._PACKAGE_VERSION_DELETE_ENDPOINT = "https://api.github.com/user/packages/{PACKAGE_TYPE}/{PACKAGE_NAME}/versions/{PACKAGE_VERSION_ID}"
def __enter__(self):
self._session.headers.update(
{
"Accept": "application/vnd.github.v3+json",
"Authorization": f"token {self._token}",
},
)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if "Accept" in self._session.headers:
del self._session.headers["Accept"]
if "Authorization" in self._session.headers:
del self._session.headers["Authorization"]
def _read_all_pages(self, endpoint):
internal_data = []
while True:
resp = self._session.get(endpoint)
if resp.status_code == 200:
internal_data += resp.json()
if "next" in resp.links:
endpoint = resp.links["next"]["url"]
else:
logger.debug("Exiting pagination loop")
break
else:
logger.warning(f"Request to {endpoint} return HTTP {resp.status_code}")
break
return internal_data
def get_branches(self, repo: str):
endpoint = self._BRANCHES_ENDPOINT.format(OWNER=self._owner_or_org, REPO=repo)
internal_data = self._read_all_pages(endpoint)
return internal_data
def filter_branches_by_name_pattern(self, branch_data, pattern: str):
matches = {}
for branch in branch_data:
if branch["name"].startswith(pattern):
matches[branch["name"]] = branch
return matches
def get_package_versions(
self,
package_name: str,
package_type: str = "container",
) -> List:
package_name = quote(package_name, safe="")
endpoint = self._PACKAGES_VERSIONS_ENDPOINT.format(
ORG=self._owner_or_org,
PACKAGE_TYPE=package_type,
PACKAGE_NAME=package_name,
)
internal_data = self._read_all_pages(endpoint)
return internal_data
def filter_packages_by_tag_pattern(self, package_data, pattern: str):
matches = {}
for package in package_data:
if "metadata" in package and "container" in package["metadata"]:
container_metadata = package["metadata"]["container"]
if "tags" in container_metadata:
container_tags = container_metadata["tags"]
for tag in container_tags:
if tag.startswith(pattern):
matches[tag] = package
break
return matches
def filter_packages_untagged(self, package_data):
matches = {}
for package in package_data:
if "metadata" in package and "container" in package["metadata"]:
container_metadata = package["metadata"]["container"]
if "tags" in container_metadata:
container_tags = container_metadata["tags"]
if not len(container_tags):
matches[package["name"]] = package
return matches
def delete_package_version(self, package_name, package_data):
package_name = quote(package_name, safe="")
endpoint = self._PACKAGE_VERSION_DELETE_ENDPOINT.format(
ORG=self._owner_or_org,
PACKAGE_TYPE=package_data["metadata"]["package_type"],
PACKAGE_NAME=package_name,
PACKAGE_VERSION_ID=package_data["id"],
)
resp = self._session.delete(endpoint)
if resp.status_code != 204:
logger.warning(
f"Request to delete {endpoint} returned HTTP {resp.status_code}",
)
def _main():
parser = ArgumentParser(
description="Using the GitHub API locate and optionally delete container"
" tags which no longer have an associated feature branch",
)
parser.add_argument(
"--delete",
action="store_true",
default=False,
help="If provided, actually delete the container tags",
)
# TODO There's a lot of untagged images, do those need to stay for anything?
parser.add_argument(
"--untagged",
action="store_true",
default=False,
help="If provided, delete untagged containers as well",
)
parser.add_argument(
"--loglevel",
default="info",
help="Configures the logging level",
)
args = parser.parse_args()
logging.basicConfig(
level=get_log_level(args),
datefmt="%Y-%m-%d %H:%M:%S",
format="%(asctime)s %(levelname)-8s %(message)s",
)
repo_owner: Final[str] = os.environ["GITHUB_REPOSITORY_OWNER"]
repo: Final[str] = os.environ["GITHUB_REPOSITORY"]
gh_token: Final[str] = os.environ["GITHUB_TOKEN"]
with requests.session() as sess:
with GithubContainerRegistry(sess, gh_token, repo_owner) as gh_api:
all_branches = gh_api.get_branches("paperless-ngx")
logger.info(f"Located {len(all_branches)} branches of {repo_owner}/{repo} ")
feature_branches = gh_api.filter_branches_by_name_pattern(
all_branches,
"feature-",
)
logger.info(f"Located {len(feature_branches)} feature branches")
for package_name in ["paperless-ngx", "paperless-ngx/builder/cache/app"]:
all_package_versions = gh_api.get_package_versions(package_name)
logger.info(
f"Located {len(all_package_versions)} versions of package {package_name}",
)
packages_tagged_feature = gh_api.filter_packages_by_tag_pattern(
all_package_versions,
"feature-",
)
logger.info(
f'Located {len(packages_tagged_feature)} versions of package {package_name} tagged "feature-"',
)
untagged_packages = gh_api.filter_packages_untagged(
all_package_versions,
)
logger.info(
f"Located {len(untagged_packages)} untagged versions of package {package_name}",
)
to_delete = list(
set(packages_tagged_feature.keys()) - set(feature_branches.keys()),
)
logger.info(
f"Located {len(to_delete)} versions of package {package_name} to delete",
)
for tag_to_delete in to_delete:
package_version_info = packages_tagged_feature[tag_to_delete]
if args.delete:
logger.info(
f"Deleting {tag_to_delete} (id {package_version_info['id']})",
)
gh_api.delete_package_version(
package_name,
package_version_info,
)
else:
logger.info(
f"Would delete {tag_to_delete} (id {package_version_info['id']})",
)
if args.untagged:
logger.info(f"Deleting untagged packages of {package_name}")
for to_delete_name in untagged_packages:
to_delete_version = untagged_packages[to_delete_name]
logger.info(f"Deleting id {to_delete_version['id']}")
if args.delete:
gh_api.delete_package_version(
package_name,
to_delete_version,
)
else:
logger.info("Leaving untagged images untouched")
if __name__ == "__main__":
_main()

View File

@ -1,4 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import logging
from argparse import ArgumentError
def get_image_tag( def get_image_tag(
@ -9,7 +11,7 @@ def get_image_tag(
""" """
Returns a string representing the normal image for a given package Returns a string representing the normal image for a given package
""" """
return f"ghcr.io/{repo_name}/builder/{pkg_name}:{pkg_version}" return f"ghcr.io/{repo_name.lower()}/builder/{pkg_name}:{pkg_version}"
def get_cache_image_tag( def get_cache_image_tag(
@ -24,4 +26,19 @@ def get_cache_image_tag(
Registry type caching is utilized for the builder images, to allow fast Registry type caching is utilized for the builder images, to allow fast
rebuilds, generally almost instant for the same version rebuilds, generally almost instant for the same version
""" """
return f"ghcr.io/{repo_name}/builder/cache/{pkg_name}:{pkg_version}" return f"ghcr.io/{repo_name.lower()}/builder/cache/{pkg_name}:{pkg_version}"
def get_log_level(args) -> int:
levels = {
"critical": logging.CRITICAL,
"error": logging.ERROR,
"warn": logging.WARNING,
"warning": logging.WARNING,
"info": logging.INFO,
"debug": logging.DEBUG,
}
level = levels.get(args.loglevel.lower())
if level is None:
level = logging.INFO
return level

View File

@ -50,7 +50,6 @@ def _main():
# Default output values # Default output values
version = None version = None
git_tag = None
extra_config = {} extra_config = {}
if args.package in pipfile_data["default"]: if args.package in pipfile_data["default"]:
@ -59,12 +58,6 @@ def _main():
pkg_version = pkg_data["version"].split("==")[-1] pkg_version = pkg_data["version"].split("==")[-1]
version = pkg_version version = pkg_version
# Based on the package, generate the expected Git tag name
if args.package == "pikepdf":
git_tag = f"v{pkg_version}"
elif args.package == "psycopg2":
git_tag = pkg_version.replace(".", "_")
# Any extra/special values needed # Any extra/special values needed
if args.package == "pikepdf": if args.package == "pikepdf":
extra_config["qpdf_version"] = build_json["qpdf"]["version"] extra_config["qpdf_version"] = build_json["qpdf"]["version"]
@ -72,8 +65,6 @@ def _main():
elif args.package in build_json: elif args.package in build_json:
version = build_json[args.package]["version"] version = build_json[args.package]["version"]
if "git_tag" in build_json[args.package]:
git_tag = build_json[args.package]["git_tag"]
else: else:
raise NotImplementedError(args.package) raise NotImplementedError(args.package)
@ -81,7 +72,6 @@ def _main():
output = { output = {
"name": args.package, "name": args.package,
"version": version, "version": version,
"git_tag": git_tag,
"image_tag": get_image_tag(repo_name, args.package, version), "image_tag": get_image_tag(repo_name, args.package, version),
"cache_tag": get_cache_image_tag( "cache_tag": get_cache_image_tag(
repo_name, repo_name,

View File

@ -26,7 +26,7 @@ jobs:
run: pipx install pipenv run: pipx install pipenv
- -
name: Set up Python name: Set up Python
uses: actions/setup-python@v3 uses: actions/setup-python@v4
with: with:
python-version: 3.9 python-version: 3.9
cache: "pipenv" cache: "pipenv"
@ -57,17 +57,29 @@ jobs:
name: Prepare Docker Pipeline Data name: Prepare Docker Pipeline Data
if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v')) if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v'))
runs-on: ubuntu-20.04 runs-on: ubuntu-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: needs:
- documentation - documentation
- ci-backend - ci-backend
- ci-frontend - ci-frontend
steps: steps:
-
name: Set ghcr repository name
id: set-ghcr-repository
run: |
ghcr_name=$(echo "${GITHUB_REPOSITORY}" | awk '{ print tolower($0) }')
echo ::set-output name=repository::${ghcr_name}
- -
name: Checkout name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- -
name: Set up Python name: Set up Python
uses: actions/setup-python@v3 uses: actions/setup-python@v4
with: with:
python-version: "3.9" python-version: "3.9"
- -
@ -109,6 +121,8 @@ jobs:
outputs: outputs:
ghcr-repository: ${{ steps.set-ghcr-repository.outputs.repository }}
qpdf-json: ${{ steps.qpdf-setup.outputs.qpdf-json }} qpdf-json: ${{ steps.qpdf-setup.outputs.qpdf-json }}
pikepdf-json: ${{ steps.pikepdf-setup.outputs.pikepdf-json }} pikepdf-json: ${{ steps.pikepdf-setup.outputs.pikepdf-json }}
@ -117,55 +131,6 @@ jobs:
jbig2enc-json: ${{ steps.jbig2enc-setup.outputs.jbig2enc-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 }}
# build and push image to docker hub. # build and push image to docker hub.
build-docker-image: build-docker-image:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
@ -174,29 +139,31 @@ jobs:
cancel-in-progress: true cancel-in-progress: true
needs: needs:
- prepare-docker-build - prepare-docker-build
- build-psycopg2-wheel
- build-jbig2enc
- build-qpdf-debs
- build-pikepdf-wheel
steps: steps:
- -
name: Check pushing to Docker Hub name: Check pushing to Docker Hub
id: docker-hub id: docker-hub
# Only push to Dockerhub from the main repo # Only push to Dockerhub from the main repo AND the ref is either:
# main
# dev
# beta
# a tag
# Otherwise forks would require a Docker Hub account and secrets setup # Otherwise forks would require a Docker Hub account and secrets setup
run: | run: |
if [[ ${{ github.repository }} == "paperless-ngx/paperless-ngx" ]] ; then if [[ ${{ needs.prepare-docker-build.outputs.ghcr-repository }} == "paperless-ngx/paperless-ngx" && ( ${{ github.ref_name }} == "main" || ${{ github.ref_name }} == "dev" || ${{ github.ref_name }} == "beta" || ${{ startsWith(github.ref, 'refs/tags/v') }} == "true" ) ]] ; then
echo "Enabling DockerHub image push"
echo ::set-output name=enable::"true" echo ::set-output name=enable::"true"
else else
echo "Not pushing to DockerHub"
echo ::set-output name=enable::"false" echo ::set-output name=enable::"false"
fi fi
- -
name: Gather Docker metadata name: Gather Docker metadata
id: docker-meta id: docker-meta
uses: docker/metadata-action@v3 uses: docker/metadata-action@v4
with: with:
images: | images: |
ghcr.io/${{ github.repository }} ghcr.io/${{ needs.prepare-docker-build.outputs.ghcr-repository }}
name=paperlessngx/paperless-ngx,enable=${{ steps.docker-hub.outputs.enable }} name=paperlessngx/paperless-ngx,enable=${{ steps.docker-hub.outputs.enable }}
tags: | tags: |
# Tag branches with branch name # Tag branches with branch name
@ -210,20 +177,20 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v2
- -
name: Set up QEMU name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v2
- -
name: Login to Github Container Registry name: Login to Github Container Registry
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- -
name: Login to Docker Hub name: Login to Docker Hub
uses: docker/login-action@v1 uses: docker/login-action@v2
# Don't attempt to login is not pushing to Docker Hub # Don't attempt to login is not pushing to Docker Hub
if: steps.docker-hub.outputs.enable == 'true' if: steps.docker-hub.outputs.enable == 'true'
with: with:
@ -231,7 +198,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- -
name: Build and push name: Build and push
uses: docker/build-push-action@v2 uses: docker/build-push-action@v3
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@ -247,11 +214,11 @@ jobs:
# Get cache layers from this branch, then dev, then main # Get cache layers from this branch, then dev, then main
# This allows new branches to get at least some cache benefits, generally from dev # This allows new branches to get at least some cache benefits, generally from dev
cache-from: | cache-from: |
type=registry,ref=ghcr.io/${{ github.repository }}/builder/cache/app:${{ github.ref_name }} type=registry,ref=ghcr.io/${{ needs.prepare-docker-build.outputs.ghcr-repository }}/builder/cache/app:${{ github.ref_name }}
type=registry,ref=ghcr.io/${{ github.repository }}/builder/cache/app:dev type=registry,ref=ghcr.io/${{ needs.prepare-docker-build.outputs.ghcr-repository }}/builder/cache/app:dev
type=registry,ref=ghcr.io/${{ github.repository }}/builder/cache/app:main type=registry,ref=ghcr.io/${{ needs.prepare-docker-build.outputs.ghcr-repository }}/builder/cache/app:main
cache-to: | cache-to: |
type=registry,mode=max,ref=ghcr.io/${{ github.repository }}/builder/cache/app:${{ github.ref_name }} type=registry,mode=max,ref=ghcr.io/${{ needs.prepare-docker-build.outputs.ghcr-repository }}/builder/cache/app:${{ github.ref_name }}
- -
name: Inspect image name: Inspect image
run: | run: |
@ -278,7 +245,7 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- -
name: Set up Python name: Set up Python
uses: actions/setup-python@v3 uses: actions/setup-python@v4
with: with:
python-version: 3.9 python-version: 3.9
- -
@ -338,6 +305,10 @@ jobs:
publish-release: publish-release:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
outputs:
prerelease: ${{ steps.get_version.outputs.prerelease }}
changelog: ${{ steps.create-release.outputs.body }}
version: ${{ steps.get_version.outputs.version }}
needs: needs:
- build-release - build-release
if: github.ref_type == 'tag' && (startsWith(github.ref_name, 'v') || contains(github.ref_name, '-beta.rc')) if: github.ref_type == 'tag' && (startsWith(github.ref_name, 'v') || contains(github.ref_name, '-beta.rc'))
@ -381,6 +352,13 @@ jobs:
asset_path: ./paperless-ngx.tar.xz asset_path: ./paperless-ngx.tar.xz
asset_name: paperless-ngx-${{ steps.get_version.outputs.version }}.tar.xz asset_name: paperless-ngx-${{ steps.get_version.outputs.version }}.tar.xz
asset_content_type: application/x-xz asset_content_type: application/x-xz
append-changelog:
runs-on: ubuntu-20.04
needs:
- publish-release
if: needs.publish-release.outputs.prerelease == 'false'
steps:
- -
name: Checkout name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
@ -391,11 +369,33 @@ jobs:
id: append-Changelog id: append-Changelog
working-directory: docs working-directory: docs
run: | run: |
echo -e "# Changelog\n\n${{ steps.create-release.outputs.body }}\n" > changelog-new.md git branch ${{ needs.publish-release.outputs.version }}-changelog
git checkout ${{ needs.publish-release.outputs.version }}-changelog
echo -e "# Changelog\n\n${{ needs.publish-release.outputs.changelog }}\n" > changelog-new.md
CURRENT_CHANGELOG=`tail --lines +2 changelog.md` CURRENT_CHANGELOG=`tail --lines +2 changelog.md`
echo -e "$CURRENT_CHANGELOG" >> changelog-new.md echo -e "$CURRENT_CHANGELOG" >> changelog-new.md
mv changelog-new.md changelog.md mv changelog-new.md changelog.md
git config --global user.name "github-actions" git config --global user.name "github-actions"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "Changelog ${{ steps.get_version.outputs.version }} - GHA" git commit -am "Changelog ${{ steps.get_version.outputs.version }} - GHA"
git push origin HEAD:main git push origin ${{ needs.publish-release.outputs.version }}-changelog
-
name: Create Pull Request
uses: actions/github-script@v6
with:
script: |
const { repo, owner } = context.repo;
const result = await github.rest.pulls.create({
title: '[Documentation] Add ${{ needs.publish-release.outputs.version }} changelog',
owner,
repo,
head: '${{ needs.publish-release.outputs.version }}-changelog',
base: 'main',
body: 'This PR is auto-generated by CI.'
});
github.rest.issues.addLabels({
owner,
repo,
issue_number: result.data.number,
labels: ['documentation']
});

48
.github/workflows/cleanup-tags.yml vendored Normal file
View File

@ -0,0 +1,48 @@
name: Cleanup Image Tags
on:
schedule:
- cron: '0 0 * * SAT'
delete:
pull_request:
types:
- closed
push:
paths:
- ".github/workflows/cleanup-tags.yml"
- ".github/scripts/cleanup-tags.py"
- ".github/scripts/common.py"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
jobs:
cleanup:
name: Cleanup Image Tags
runs-on: ubuntu-20.04
permissions:
packages: write
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Login to Github Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Set up Python
uses: actions/setup-python@v3
with:
python-version: "3.9"
-
name: Install requests
run: |
python -m pip install requests
-
name: Cleanup feature tags
run: |
python ${GITHUB_WORKSPACE}/.github/scripts/cleanup-tags.py --loglevel info --delete

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

@ -0,0 +1,147 @@
# 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: Set ghcr repository name
id: set-ghcr-repository
run: |
ghcr_name=$(echo "${GITHUB_REPOSITORY}" | awk '{ print tolower($0) }')
echo ::set-output name=repository::${ghcr_name}
-
name: Checkout
uses: actions/checkout@v3
-
name: Set up Python
uses: actions/setup-python@v4
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:
ghcr-repository: ${{ steps.set-ghcr-repository.outputs.repository }}
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_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=${{ needs.prepare-docker-build.outputs.ghcr-repository }}
QPDF_VERSION=${{ fromJSON(needs.prepare-docker-build.outputs.qpdf-json).version }}
PIKEPDF_VERSION=${{ fromJSON(needs.prepare-docker-build.outputs.pikepdf-json).version }}

View File

@ -65,7 +65,7 @@ jobs:
run: pipx install pipenv run: pipx install pipenv
- -
name: Set up Python name: Set up Python
uses: actions/setup-python@v3 uses: actions/setup-python@v4
with: with:
python-version: "${{ matrix.python-version }}" python-version: "${{ matrix.python-version }}"
cache: "pipenv" cache: "pipenv"
@ -74,7 +74,7 @@ jobs:
name: Install system dependencies name: Install system dependencies
run: | run: |
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends unpaper tesseract-ocr imagemagick ghostscript optipng libzbar0 poppler-utils sudo apt-get install -qq --no-install-recommends unpaper tesseract-ocr imagemagick ghostscript libzbar0 poppler-utils
- -
name: Install Python dependencies name: Install Python dependencies
run: | run: |
@ -87,7 +87,7 @@ jobs:
- -
name: Get changed files name: Get changed files
id: changed-files-specific id: changed-files-specific
uses: tj-actions/changed-files@v19 uses: tj-actions/changed-files@v23.1
with: with:
files: | files: |
src/** src/**
@ -106,3 +106,24 @@ jobs:
run: | run: |
cd src/ cd src/
pipenv run coveralls --service=github pipenv run coveralls --service=github
dockerfile-lint:
name: "Lint ${{ matrix.dockerfile }}"
runs-on: ubuntu-20.04
strategy:
matrix:
dockerfile:
- Dockerfile
- docker-builders/Dockerfile.qpdf
- docker-builders/Dockerfile.jbig2enc
- docker-builders/Dockerfile.psycopg2
- docker-builders/Dockerfile.pikepdf
fail-fast: false
steps:
-
name: Checkout
uses: actions/checkout@v3
-
uses: hadolint/hadolint-action@v2.1.0
with:
dockerfile: ${{ matrix.dockerfile }}

View File

@ -28,20 +28,20 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- -
name: Login to Github Container Registry name: Login to Github Container Registry
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v2
- -
name: Set up QEMU name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v2
- -
name: Build ${{ fromJSON(inputs.build-json).name }} name: Build ${{ fromJSON(inputs.build-json).name }}
uses: docker/build-push-action@v2 uses: docker/build-push-action@v3
with: with:
context: . context: .
file: ${{ inputs.dockerfile }} file: ${{ inputs.dockerfile }}

8
.gitignore vendored
View File

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

8
.hadolint.yml Normal file
View File

@ -0,0 +1,8 @@
failure-threshold: warning
ignored:
# https://github.com/hadolint/hadolint/wiki/DL3008
- DL3008
# https://github.com/hadolint/hadolint/wiki/DL3013
- DL3013
# https://github.com/hadolint/hadolint/wiki/DL3003
- DL3003

View File

@ -5,7 +5,7 @@
repos: repos:
# General hooks # General hooks
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.2.0 rev: v4.3.0
hooks: hooks:
- id: check-docstring-first - id: check-docstring-first
- id: check-json - id: check-json
@ -27,7 +27,7 @@ repos:
- id: check-case-conflict - id: check-case-conflict
- id: detect-private-key - id: detect-private-key
- repo: https://github.com/pre-commit/mirrors-prettier - repo: https://github.com/pre-commit/mirrors-prettier
rev: "v2.6.2" rev: "v2.7.1"
hooks: hooks:
- id: prettier - id: prettier
types_or: types_or:
@ -37,7 +37,7 @@ repos:
exclude: "(^Pipfile\\.lock$)" exclude: "(^Pipfile\\.lock$)"
# Python hooks # Python hooks
- repo: https://github.com/asottile/reorder_python_imports - repo: https://github.com/asottile/reorder_python_imports
rev: v3.1.0 rev: v3.8.1
hooks: hooks:
- id: reorder-python-imports - id: reorder-python-imports
exclude: "(migrations)" exclude: "(migrations)"
@ -59,11 +59,11 @@ repos:
args: args:
- "--config=./src/setup.cfg" - "--config=./src/setup.cfg"
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 22.3.0 rev: 22.6.0
hooks: hooks:
- id: black - id: black
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v2.32.1 rev: v2.37.1
hooks: hooks:
- id: pyupgrade - id: pyupgrade
exclude: "(migrations)" exclude: "(migrations)"
@ -74,13 +74,6 @@ repos:
rev: v2.10.0 rev: v2.10.0
hooks: hooks:
- id: hadolint - id: hadolint
args:
- --ignore
- DL3008 # https://github.com/hadolint/hadolint/wiki/DL3008 (should probably do this at some point)
- --ignore
- DL3013 # https://github.com/hadolint/hadolint/wiki/DL3013 (should probably do this too at some point)
- --ignore
- DL3003 # https://github.com/hadolint/hadolint/wiki/DL3003 (seems excessive to use WORKDIR so much)
# Shell script hooks # Shell script hooks
- repo: https://github.com/lovesegfault/beautysh - repo: https://github.com/lovesegfault/beautysh
rev: v6.2.1 rev: v6.2.1

View File

@ -26,7 +26,7 @@ COPY ./src-ui /src/src-ui
WORKDIR /src/src-ui WORKDIR /src/src-ui
RUN set -eux \ RUN set -eux \
&& npm update npm -g \ && npm update npm -g \
&& npm ci --no-optional && npm ci --omit=optional
RUN set -eux \ RUN set -eux \
&& ./node_modules/.bin/ng build --configuration production && ./node_modules/.bin/ng build --configuration production
@ -77,15 +77,12 @@ ARG RUNTIME_PACKAGES="\
libraqm0 \ libraqm0 \
libgnutls30 \ libgnutls30 \
libjpeg62-turbo \ libjpeg62-turbo \
optipng \
python3 \ python3 \
python3-pip \ python3-pip \
python3-setuptools \ python3-setuptools \
postgresql-client \ postgresql-client \
# For Numpy # For Numpy
libatlas3-base \ libatlas3-base \
# thumbnail size reduction
pngquant \
# OCRmyPDF dependencies # OCRmyPDF dependencies
tesseract-ocr \ tesseract-ocr \
tesseract-ocr-eng \ tesseract-ocr-eng \
@ -93,6 +90,10 @@ ARG RUNTIME_PACKAGES="\
tesseract-ocr-fra \ tesseract-ocr-fra \
tesseract-ocr-ita \ tesseract-ocr-ita \
tesseract-ocr-spa \ tesseract-ocr-spa \
# Suggested for OCRmyPDF
pngquant \
# Suggested for pikepdf
jbig2dec \
tzdata \ tzdata \
unpaper \ unpaper \
# Mime type detection # Mime type detection
@ -122,20 +123,33 @@ COPY gunicorn.conf.py .
# These change sometimes, but rarely # These change sometimes, but rarely
WORKDIR /usr/src/paperless/src/docker/ WORKDIR /usr/src/paperless/src/docker/
RUN --mount=type=bind,readwrite,source=docker,target=./ \ COPY [ \
set -eux \ "docker/imagemagick-policy.xml", \
"docker/supervisord.conf", \
"docker/docker-entrypoint.sh", \
"docker/docker-prepare.sh", \
"docker/paperless_cmd.sh", \
"docker/wait-for-redis.py", \
"docker/management_script.sh", \
"docker/install_management_commands.sh", \
"/usr/src/paperless/src/docker/" \
]
RUN set -eux \
&& echo "Configuring ImageMagick" \ && echo "Configuring ImageMagick" \
&& cp imagemagick-policy.xml /etc/ImageMagick-6/policy.xml \ && mv imagemagick-policy.xml /etc/ImageMagick-6/policy.xml \
&& echo "Configuring supervisord" \ && echo "Configuring supervisord" \
&& mkdir /var/log/supervisord /var/run/supervisord \ && mkdir /var/log/supervisord /var/run/supervisord \
&& cp supervisord.conf /etc/supervisord.conf \ && mv supervisord.conf /etc/supervisord.conf \
&& echo "Setting up Docker scripts" \ && echo "Setting up Docker scripts" \
&& cp docker-entrypoint.sh /sbin/docker-entrypoint.sh \ && mv docker-entrypoint.sh /sbin/docker-entrypoint.sh \
&& chmod 755 /sbin/docker-entrypoint.sh \ && chmod 755 /sbin/docker-entrypoint.sh \
&& cp docker-prepare.sh /sbin/docker-prepare.sh \ && mv docker-prepare.sh /sbin/docker-prepare.sh \
&& chmod 755 /sbin/docker-prepare.sh \ && chmod 755 /sbin/docker-prepare.sh \
&& cp wait-for-redis.py /sbin/wait-for-redis.py \ && mv wait-for-redis.py /sbin/wait-for-redis.py \
&& chmod 755 /sbin/wait-for-redis.py \ && chmod 755 /sbin/wait-for-redis.py \
&& mv paperless_cmd.sh /usr/local/bin/paperless_cmd.sh \
&& chmod 755 /usr/local/bin/paperless_cmd.sh \
&& echo "Installing managment commands" \ && echo "Installing managment commands" \
&& chmod +x install_management_commands.sh \ && chmod +x install_management_commands.sh \
&& ./install_management_commands.sh && ./install_management_commands.sh
@ -151,15 +165,15 @@ RUN --mount=type=bind,from=qpdf-builder,target=/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/libqpdf28_*.deb \
&& apt-get install --yes --no-install-recommends /qpdf/usr/src/qpdf/qpdf_*.deb \ && apt-get install --yes --no-install-recommends /qpdf/usr/src/qpdf/qpdf_*.deb \
&& echo "Installing pikepdf and dependencies" \ && 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/wheels/pyparsing*.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/wheels/packaging*.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/wheels/lxml*.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/wheels/Pillow*.whl \
&& python3 -m pip install --no-cache-dir /pikepdf/usr/src/pikepdf/wheels/pikepdf*.whl \ && python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/pikepdf*.whl \
&& python -m pip list \ && python3 -m pip list \
&& echo "Installing psycopg2" \ && echo "Installing psycopg2" \
&& python3 -m pip install --no-cache-dir /psycopg2/usr/src/psycopg2/wheels/psycopg2*.whl \ && python3 -m pip install --no-cache-dir /psycopg2/usr/src/wheels/psycopg2*.whl \
&& python -m pip list && python3 -m pip list
# Python dependencies # Python dependencies
# Change pretty frequently # Change pretty frequently
@ -169,6 +183,7 @@ COPY requirements.txt ../
# dependencies # dependencies
ARG BUILD_PACKAGES="\ ARG BUILD_PACKAGES="\
build-essential \ build-essential \
git \
python3-dev" python3-dev"
RUN set -eux \ RUN set -eux \
@ -213,4 +228,4 @@ ENTRYPOINT ["/sbin/docker-entrypoint.sh"]
EXPOSE 8000 EXPOSE 8000
CMD ["/usr/local/bin/supervisord", "-c", "/etc/supervisord.conf"] CMD ["/usr/local/bin/paperless_cmd.sh"]

20
Pipfile
View File

@ -13,29 +13,26 @@ dateparser = "~=1.1"
django = "~=4.0" django = "~=4.0"
django-cors-headers = "*" django-cors-headers = "*"
django-extensions = "*" django-extensions = "*"
django-filter = "~=21.1" django-filter = "~=22.1"
django-q = "~=1.3" django-q = {editable = true, ref = "paperless-main", git = "https://github.com/paperless-ngx/django-q.git"}
djangorestframework = "~=3.13" djangorestframework = "~=3.13"
filelock = "*" filelock = "*"
fuzzywuzzy = {extras = ["speedup"], version = "*"} fuzzywuzzy = {extras = ["speedup"], version = "*"}
gunicorn = "*" gunicorn = "*"
imap-tools = "~=0.54.0" imap-tools = "*"
langdetect = "*" langdetect = "*"
pathvalidate = "*" pathvalidate = "*"
pillow = "~=9.1" pillow = "~=9.2"
# Any version update to pikepdf requires a base image update
pikepdf = "~=5.1" pikepdf = "~=5.1"
python-gnupg = "*" python-gnupg = "*"
python-dotenv = "*" python-dotenv = "*"
python-dateutil = "*" python-dateutil = "*"
python-magic = "*" python-magic = "*"
# Any version update to psycopg2 requires a base image update
psycopg2 = "*" psycopg2 = "*"
redis = "*" redis = "*"
# Pinned because aarch64 wheels and updates cause warnings when loading the classifier model. scikit-learn="~=1.1"
scikit-learn="==1.0.2" whitenoise = "~=6.2.0"
whitenoise = "~=6.0.0" watchdog = "~=2.1.9"
watchdog = "~=2.1.0"
whoosh="~=2.7.4" whoosh="~=2.7.4"
inotifyrecursive = "~=0.3" inotifyrecursive = "~=0.3"
ocrmypdf = "~=13.4" ocrmypdf = "~=13.4"
@ -64,9 +61,10 @@ pytest-django = "*"
pytest-env = "*" pytest-env = "*"
pytest-sugar = "*" pytest-sugar = "*"
pytest-xdist = "*" pytest-xdist = "*"
sphinx = "~=4.5.0" sphinx = "~=5.0.2"
sphinx_rtd_theme = "*" sphinx_rtd_theme = "*"
tox = "*" tox = "*"
black = "*" black = "*"
pre-commit = "*" pre-commit = "*"
sphinx-autobuild = "*"
myst-parser = "*" myst-parser = "*"

1437
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -34,6 +34,7 @@ branch_name=$(git rev-parse --abbrev-ref HEAD)
export DOCKER_BUILDKIT=1 export DOCKER_BUILDKIT=1
docker build --file "$1" \ docker build --file "$1" \
--progress=plain \
--cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/app:"${branch_name}" \ --cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/app:"${branch_name}" \
--cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/app:dev \ --cache-from ghcr.io/paperless-ngx/paperless-ngx/builder/cache/app:dev \
--build-arg JBIG2ENC_VERSION="${jbig2enc_version}" \ --build-arg JBIG2ENC_VERSION="${jbig2enc_version}" \

View File

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

View File

@ -2,8 +2,7 @@
# Inputs: # Inputs:
# - REPO - Docker repository to pull qpdf from # - REPO - Docker repository to pull qpdf from
# - QPDF_VERSION - The image qpdf version to copy .deb files from # - QPDF_VERSION - The image qpdf version to copy .deb files from
# - PIKEPDF_GIT_TAG - The Git tag to clone and build from # - PIKEPDF_VERSION - Version of pikepdf to build wheel for
# - PIKEPDF_VERSION - Used to force the built pikepdf version to match
# Default to pulling from the main repo registry when manually building # Default to pulling from the main repo registry when manually building
ARG REPO="paperless-ngx/paperless-ngx" ARG REPO="paperless-ngx/paperless-ngx"
@ -23,7 +22,6 @@ ARG BUILD_PACKAGES="\
build-essential \ build-essential \
python3-dev \ python3-dev \
python3-pip \ python3-pip \
git \
# qpdf requirement - https://github.com/qpdf/qpdf#crypto-providers # qpdf requirement - https://github.com/qpdf/qpdf#crypto-providers
libgnutls28-dev \ libgnutls28-dev \
# lxml requrements - https://lxml.de/installation.html # lxml requrements - https://lxml.de/installation.html
@ -72,21 +70,19 @@ RUN set -eux \
# For better caching, seperate the basic installs from # For better caching, seperate the basic installs from
# the building # the building
ARG PIKEPDF_GIT_TAG
ARG PIKEPDF_VERSION ARG PIKEPDF_VERSION
RUN set -eux \ RUN set -eux \
&& echo "building pikepdf wheel" \ && echo "Building pikepdf wheel ${PIKEPDF_VERSION}" \
# Note the v in the tag name here
&& git clone --quiet --depth 1 --branch "${PIKEPDF_GIT_TAG}" https://github.com/pikepdf/pikepdf.git \
&& cd pikepdf \
# pikepdf seems to specifciy either a next version when built OR
# a post release tag.
# In either case, this won't match what we want from requirements.txt
# Directly modify the setup.py to set the version we just checked out of Git
&& sed -i "s/use_scm_version=True/version=\"${PIKEPDF_VERSION}\"/g" setup.py \
# https://github.com/pikepdf/pikepdf/issues/323
&& rm pyproject.toml \
&& mkdir wheels \ && mkdir wheels \
&& python3 -m pip wheel . --wheel-dir wheels \ && python3 -m pip wheel \
# Build the package at the required version
pikepdf==${PIKEPDF_VERSION} \
# Output the *.whl into this directory
--wheel-dir wheels \
# Do not use a binary packge for the package being built
--no-binary=pikepdf \
# Do use binary packages for dependencies
--prefer-binary \
--no-cache-dir \
&& ls -ahl wheels && ls -ahl wheels

View File

@ -1,7 +1,6 @@
# This Dockerfile builds the psycopg2 wheel # This Dockerfile builds the psycopg2 wheel
# Inputs: # Inputs:
# - PSYCOPG2_GIT_TAG - The Git tag to clone and build from # - PSYCOPG2_VERSION - Version to build
# - PSYCOPG2_VERSION - Unused, kept for future possible usage
FROM python:3.9-slim-bullseye as main FROM python:3.9-slim-bullseye as main
@ -11,7 +10,6 @@ ARG DEBIAN_FRONTEND=noninteractive
ARG BUILD_PACKAGES="\ ARG BUILD_PACKAGES="\
build-essential \ build-essential \
git \
python3-dev \ python3-dev \
python3-pip \ python3-pip \
# https://www.psycopg.org/docs/install.html#prerequisites # https://www.psycopg.org/docs/install.html#prerequisites
@ -32,14 +30,20 @@ RUN set -eux \
# For better caching, seperate the basic installs from # For better caching, seperate the basic installs from
# the building # the building
ARG PSYCOPG2_GIT_TAG
ARG PSYCOPG2_VERSION ARG PSYCOPG2_VERSION
RUN set -eux \ RUN set -eux \
&& echo "Building psycopg2 wheel" \ && echo "Building psycopg2 wheel ${PSYCOPG2_VERSION}" \
&& cd /usr/src \ && cd /usr/src \
&& git clone --quiet --depth 1 --branch ${PSYCOPG2_GIT_TAG} https://github.com/psycopg/psycopg2.git \
&& cd psycopg2 \
&& mkdir wheels \ && mkdir wheels \
&& python3 -m pip wheel . --wheel-dir wheels \ && python3 -m pip wheel \
# Build the package at the required version
psycopg2==${PSYCOPG2_VERSION} \
# Output the *.whl into this directory
--wheel-dir wheels \
# Do not use a binary packge for the package being built
--no-binary=psycopg2 \
# Do use binary packages for dependencies
--prefer-binary \
--no-cache-dir \
&& ls -ahl wheels/ && ls -ahl wheels/

View File

@ -31,13 +31,13 @@
version: "3.4" version: "3.4"
services: services:
broker: broker:
image: redis:6.0 image: docker.io/library/redis:6.0
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- redisdata:/data - redisdata:/data
db: db:
image: postgres:13 image: docker.io/library/postgres:13
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data

View File

@ -33,13 +33,13 @@
version: "3.4" version: "3.4"
services: services:
broker: broker:
image: redis:6.0 image: docker.io/library/redis:6.0
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- redisdata:/data - redisdata:/data
db: db:
image: postgres:13 image: docker.io/library/postgres:13
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
@ -77,7 +77,7 @@ services:
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: gotenberg/gotenberg:7.4 image: docker.io/gotenberg/gotenberg:7.4
restart: unless-stopped restart: unless-stopped
command: command:
- "gotenberg" - "gotenberg"

View File

@ -29,13 +29,13 @@
version: "3.4" version: "3.4"
services: services:
broker: broker:
image: redis:6.0 image: docker.io/library/redis:6.0
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- redisdata:/data - redisdata:/data
db: db:
image: postgres:13 image: docker.io/library/postgres:13
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data

View File

@ -33,7 +33,7 @@
version: "3.4" version: "3.4"
services: services:
broker: broker:
image: redis:6.0 image: docker.io/library/redis:6.0
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- redisdata:/data - redisdata:/data
@ -65,7 +65,7 @@ services:
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: gotenberg/gotenberg:7.4 image: docker.io/gotenberg/gotenberg:7.4
restart: unless-stopped restart: unless-stopped
command: command:
- "gotenberg" - "gotenberg"

View File

@ -26,7 +26,7 @@
version: "3.4" version: "3.4"
services: services:
broker: broker:
image: redis:6.0 image: docker.io/library/redis:6.0
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- redisdata:/data - redisdata:/data

View File

@ -2,6 +2,37 @@
set -e set -e
# Adapted from:
# https://github.com/docker-library/postgres/blob/master/docker-entrypoint.sh
# usage: file_env VAR
# ie: file_env 'XYZ_DB_PASSWORD' will allow for "$XYZ_DB_PASSWORD_FILE" to
# fill in the value of "$XYZ_DB_PASSWORD" from a file, especially for Docker's
# secrets feature
file_env() {
local var="$1"
local fileVar="${var}_FILE"
# Basic validation
if [ "${!var:-}" ] && [ "${!fileVar:-}" ]; then
echo >&2 "error: both $var and $fileVar are set (but are exclusive)"
exit 1
fi
# Only export var if the _FILE exists
if [ "${!fileVar:-}" ]; then
# And the file exists
if [[ -f ${!fileVar} ]]; then
echo "Setting ${var} from file"
val="$(< "${!fileVar}")"
export "$var"="$val"
else
echo "File ${!fileVar} doesn't exist"
exit 1
fi
fi
}
# Source: https://github.com/sameersbn/docker-gitlab/ # Source: https://github.com/sameersbn/docker-gitlab/
map_uidgid() { map_uidgid() {
USERMAP_ORIG_UID=$(id -u paperless) USERMAP_ORIG_UID=$(id -u paperless)
@ -15,26 +46,56 @@ map_uidgid() {
fi fi
} }
map_folders() {
# Export these so they can be used in docker-prepare.sh
export DATA_DIR="${PAPERLESS_DATA_DIR:-/usr/src/paperless/data}"
export MEDIA_ROOT_DIR="${PAPERLESS_MEDIA_ROOT:-/usr/src/paperless/media}"
}
initialize() { initialize() {
# Setup environment from secrets before anything else
for env_var in \
PAPERLESS_DBUSER \
PAPERLESS_DBPASS \
PAPERLESS_SECRET_KEY \
PAPERLESS_AUTO_LOGIN_USERNAME \
PAPERLESS_ADMIN_USER \
PAPERLESS_ADMIN_MAIL \
PAPERLESS_ADMIN_PASSWORD; do
# Check for a version of this var with _FILE appended
# and convert the contents to the env var value
file_env ${env_var}
done
# Change the user and group IDs if needed
map_uidgid map_uidgid
for dir in export data data/index media media/documents media/documents/originals media/documents/thumbnails; do # Check for overrides of certain folders
if [[ ! -d "../$dir" ]]; then map_folders
echo "Creating directory ../$dir"
mkdir ../$dir local export_dir="/usr/src/paperless/export"
for dir in "${export_dir}" "${DATA_DIR}" "${DATA_DIR}/index" "${MEDIA_ROOT_DIR}" "${MEDIA_ROOT_DIR}/documents" "${MEDIA_ROOT_DIR}/documents/originals" "${MEDIA_ROOT_DIR}/documents/thumbnails"; do
if [[ ! -d "${dir}" ]]; then
echo "Creating directory ${dir}"
mkdir "${dir}"
fi fi
done done
echo "Creating directory /tmp/paperless" local tmp_dir="/tmp/paperless"
mkdir -p /tmp/paperless echo "Creating directory ${tmp_dir}"
mkdir -p "${tmp_dir}"
set +e set +e
echo "Adjusting permissions of paperless files. This may take a while." echo "Adjusting permissions of paperless files. This may take a while."
chown -R paperless:paperless /tmp/paperless chown -R paperless:paperless ${tmp_dir}
find .. -not \( -user paperless -and -group paperless \) -exec chown paperless:paperless {} + for dir in "${export_dir}" "${DATA_DIR}" "${MEDIA_ROOT_DIR}"; do
find "${dir}" -not \( -user paperless -and -group paperless \) -exec chown paperless:paperless {} +
done
set -e set -e
gosu paperless /sbin/docker-prepare.sh ${gosu_cmd[@]} /sbin/docker-prepare.sh
} }
install_languages() { install_languages() {
@ -76,6 +137,11 @@ install_languages() {
echo "Paperless-ngx docker container starting..." echo "Paperless-ngx docker container starting..."
gosu_cmd=(gosu paperless)
if [ $(id -u) == $(id -u paperless) ]; then
gosu_cmd=()
fi
# Install additional languages if specified # Install additional languages if specified
if [[ -n "$PAPERLESS_OCR_LANGUAGES" ]]; then if [[ -n "$PAPERLESS_OCR_LANGUAGES" ]]; then
install_languages "$PAPERLESS_OCR_LANGUAGES" install_languages "$PAPERLESS_OCR_LANGUAGES"
@ -85,7 +151,7 @@ initialize
if [[ "$1" != "/"* ]]; then if [[ "$1" != "/"* ]]; then
echo Executing management command "$@" echo Executing management command "$@"
exec gosu paperless python3 manage.py "$@" exec ${gosu_cmd[@]} python3 manage.py "$@"
else else
echo Executing "$@" echo Executing "$@"
exec "$@" exec "$@"

View File

@ -3,16 +3,17 @@
set -e set -e
wait_for_postgres() { wait_for_postgres() {
attempt_num=1 local attempt_num=1
max_attempts=5 local max_attempts=5
echo "Waiting for PostgreSQL to start..." echo "Waiting for PostgreSQL to start..."
host="${PAPERLESS_DBHOST:=localhost}" local host="${PAPERLESS_DBHOST:-localhost}"
port="${PAPERLESS_DBPORT:=5432}" local port="${PAPERLESS_DBPORT:-5432}"
# Disable warning, host and port can't have spaces
while [ ! "$(pg_isready -h $host -p $port)" ]; do # shellcheck disable=SC2086
while [ ! "$(pg_isready -h ${host} -p ${port})" ]; do
if [ $attempt_num -eq $max_attempts ]; then if [ $attempt_num -eq $max_attempts ]; then
echo "Unable to connect to database." echo "Unable to connect to database."
@ -43,17 +44,18 @@ migrations() {
flock 200 flock 200
echo "Apply database migrations..." echo "Apply database migrations..."
python3 manage.py migrate python3 manage.py migrate
) 200>/usr/src/paperless/data/migration_lock ) 200>"${DATA_DIR}/migration_lock"
} }
search_index() { search_index() {
index_version=1
index_version_file=/usr/src/paperless/data/.index_version
if [[ (! -f "$index_version_file") || $(<$index_version_file) != "$index_version" ]]; then local index_version=1
local index_version_file=${DATA_DIR}/.index_version
if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then
echo "Search index out of date. Updating..." 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 echo ${index_version} | tee "${index_version_file}" >/dev/null
fi fi
} }

View File

@ -2,7 +2,18 @@
set -eu set -eu
for command in document_archiver document_exporter document_importer mail_fetcher document_create_classifier document_index document_renamer document_retagger document_thumbnails document_sanity_checker manage_superuser; for command in decrypt_documents \
document_archiver \
document_exporter \
document_importer \
mail_fetcher \
document_create_classifier \
document_index \
document_renamer \
document_retagger \
document_thumbnails \
document_sanity_checker \
manage_superuser;
do do
echo "installing $command..." echo "installing $command..."
sed "s/management_command/$command/g" management_script.sh > /usr/local/bin/$command sed "s/management_command/$command/g" management_script.sh > /usr/local/bin/$command

15
docker/paperless_cmd.sh Executable file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env bash
rootless_args=()
if [ $(id -u) == $(id -u paperless) ]; then
rootless_args=(
--user
paperless
--logfile
supervisord.log
--pidfile
supervisord.pid
)
fi
/usr/local/bin/supervisord -c /etc/supervisord.conf ${rootless_args[@]}

View File

@ -26,9 +26,11 @@ if __name__ == "__main__":
try: try:
client.ping() client.ping()
break break
except Exception: except Exception as e:
print( print(
f"Redis ping #{attempt} failed, waiting {RETRY_SLEEP_SECONDS}s", f"Redis ping #{attempt} failed.\n"
f"Error: {str(e)}.\n"
f"Waiting {RETRY_SLEEP_SECONDS}s",
flush=True, flush=True,
) )
time.sleep(RETRY_SLEEP_SECONDS) time.sleep(RETRY_SLEEP_SECONDS)

View File

@ -1,17 +0,0 @@
FROM python:3.5.1
# Install Sphinx and Pygments
RUN pip install --no-cache-dir Sphinx Pygments \
# Setup directories, copy data
&& mkdir /build
COPY . /build
WORKDIR /build/docs
# Build documentation
RUN make html
# Start webserver
WORKDIR /build/docs/_build/html
EXPOSE 8000/tcp
CMD ["python3", "-m", "http.server"]

View File

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

View File

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

View File

@ -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`` ``export`` folder in your paperless source directory. Specify ``../export``
as the ``source``. 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: .. _utilities-retagger:

View File

@ -7,12 +7,12 @@ easier.
.. _advanced-matching: .. _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 Paperless will compare the matching algorithms defined by every tag, correspondent,
correspondent already set in your database to see if they apply to the text in document type, and storage path in your database to see if they apply to the text
a document. In other words, if you defined a tag called ``Home Utility`` 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 that had a ``match`` property of ``bc hydro`` and a ``matching_algorithm`` of
``literal``, Paperless will automatically tag your newly-consumed document with ``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 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 document with different algorithms, and as such, some experimentation may be
necessary to get things right. necessary to get things right.
In order to have a tag, correspondent, or type assigned automatically to newly In order to have a tag, correspondent, document type, or storage path assigned
consumed documents, assign a match and matching algorithm using the web automatically to newly consumed documents, assign a match and matching algorithm
interface. These settings define when to assign correspondents, tags, and types using the web interface. These settings define when to assign tags, correspondents,
to documents. document types, and storage paths to documents.
The following algorithms are available: 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. * **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 * **Regular expression:** Parses the match as a regular expression and tries to
find a match within the document. 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 * **Auto:** Tries to automatically match new documents. This does not require you
to set a match. See the notes below. 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 will match documents that contain either "Bank of America" or "BofA", but will
not match documents containing "Bank of South America". not match documents containing "Bank of South America".
Then just save your tag/correspondent and run another document through the Then just save your tag, correspondent, document type, or storage path and run
consumer. Once complete, you should see the newly-created document, another document through the consumer. Once complete, you should see the
automatically tagged with the appropriate data. newly-created document, automatically tagged with the appropriate data.
.. _advanced-automatic_matching: .. _advanced-automatic_matching:
@ -58,9 +58,9 @@ Automatic matching
================== ==================
Paperless-ngx comes with a new matching algorithm called *Auto*. This matching Paperless-ngx comes with a new matching algorithm called *Auto*. This matching
algorithm tries to assign tags, correspondents, and document types to your algorithm tries to assign tags, correspondents, document types, and storage paths
documents based on how you have already assigned these on existing documents. It to your documents based on how you have already assigned these on existing documents.
uses a neural network under the hood. It uses a neural network under the hood.
If, for example, all your bank statements of your account 123 at the Bank of 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 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 that the neural network only learns from documents which you have correctly
tagged before. tagged before.
* The matching algorithm can only work if there is a correlation between the * The matching algorithm can only work if there is a correlation between the
tag, correspondent, or document type and the document itself. Your bank tag, correspondent, document type, or storage path and the document itself.
statements usually contain your bank account number and the name of the bank, Your bank statements usually contain your bank account number and the name
so this works reasonably well, However, tags such as "TODO" cannot be of the bank, so this works reasonably well, However, tags such as "TODO"
automatically assigned. cannot be automatically assigned.
* The matching algorithm needs a reasonable number of documents to identify when * 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 to assign tags, correspondents, storage paths, and types. If one out of a
has the correspondent "Very obscure web shop I bought something five years thousand documents has the correspondent "Very obscure web shop I bought
ago", it will probably not assign this correspondent automatically if you buy something five years ago", it will probably not assign this correspondent
something from them again. The more documents, the better. 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 * 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 not to assign a certain tag, correspondent, document type, or storage path. This will
case as you start filling up paperless with documents. Example: If all your usually be the case as you start filling up paperless with documents.
documents are either from "Webshop" and "Bank", paperless will assign one of Example: If all your documents are either from "Webshop" and "Bank", paperless
these correspondents to ANY new document, if both are set to automatic matching. will assign one of these correspondents to ANY new document, if both are set
to automatic matching.
Hooking into the consumption process Hooking into the consumption process
#################################### ####################################
@ -120,10 +121,10 @@ Pre-consumption script
====================== ======================
Executed after the consumer sees a new document in the consumption folder, but Executed after the consumer sees a new document in the consumption folder, but
before any processing of the document is performed. This script receives exactly before any processing of the document is performed. This script can access the
one argument: following relevant environment variables set:
* Document file name * ``DOCUMENT_SOURCE_PATH``
A simple but common example for this would be creating a simple script like A simple but common example for this would be creating a simple script like
this: this:
@ -133,7 +134,7 @@ this:
.. code:: bash .. code:: bash
#!/usr/bin/env bash #!/usr/bin/env bash
pdf2pdfocr.py -i ${1} pdf2pdfocr.py -i ${DOCUMENT_SOURCE_PATH}
``/etc/paperless.conf`` ``/etc/paperless.conf``
@ -156,16 +157,20 @@ Post-consumption script
======================= =======================
Executed after the consumer has successfully processed a document and has moved it Executed after the consumer has successfully processed a document and has moved it
into paperless. It receives the following arguments: into paperless. It receives the following environment variables:
* Document id * ``DOCUMENT_ID``
* Generated file name * ``DOCUMENT_FILE_NAME``
* Source path * ``DOCUMENT_CREATED``
* Thumbnail path * ``DOCUMENT_MODIFIED``
* Download URL * ``DOCUMENT_ADDED``
* Thumbnail URL * ``DOCUMENT_SOURCE_PATH``
* Correspondent * ``DOCUMENT_ARCHIVE_PATH``
* Tags * ``DOCUMENT_THUMBNAIL_PATH``
* ``DOCUMENT_DOWNLOAD_URL``
* ``DOCUMENT_THUMBNAIL_URL``
* ``DOCUMENT_CORRESPONDENT``
* ``DOCUMENT_TAGS``
The script can be in any language, but for a simple shell script The script can be in any language, but for a simple shell script
example, you can take a look at `post-consumption-example.sh`_ in this project. example, you can take a look at `post-consumption-example.sh`_ in this project.
@ -268,6 +273,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 append ``_01``, ``_02``, etc to the filename. This happens if all the placeholders in a filename
evaluate to the same value. 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:: .. hint::
Paperless checks the filename of a document whenever it is saved. Therefore, Paperless checks the filename of a document whenever it is saved. Therefore,
@ -290,3 +306,59 @@ evaluate to the same value.
However, keep in mind that inside docker, if files get stored outside of the 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. 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

@ -31,7 +31,8 @@ The objects served by the document endpoint contain the following fields:
* ``tags``: List of IDs of tags assigned to this document, or empty list. * ``tags``: List of IDs of tags assigned to this document, or empty list.
* ``document_type``: Document type of this document, or null. * ``document_type``: Document type of this document, or null.
* ``correspondent``: Correspondent of this document or null. * ``correspondent``: Correspondent of this document or null.
* ``created``: The date at which this document was created. * ``created``: The date time at which this document was created.
* ``created_date``: The date (YYYY-MM-DD) at which this document was created. Optional. If also passed with created, this is ignored.
* ``modified``: The date at which this document was last edited in paperless. Read-only. * ``modified``: The date at which this document was last edited in paperless. Read-only.
* ``added``: The date at which this document was added to paperless. Read-only. * ``added``: The date at which this document was added to paperless. Read-only.
* ``archive_serial_number``: The identifier of this document in a physical document archive. * ``archive_serial_number``: The identifier of this document in a physical document archive.
@ -240,11 +241,13 @@ be instructed to consume the document from there.
The endpoint supports the following optional form fields: The endpoint supports the following optional form fields:
* ``title``: Specify a title that the consumer should use for the document. * ``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. * ``correspondent``: Specify the ID of a correspondent that the consumer should use for the document.
* ``document_type``: Similar to correspondent. * ``document_type``: Similar to correspondent.
* ``tags``: Similar to correspondent. Specify this multiple times to have multiple tags added * ``tags``: Similar to correspondent. Specify this multiple times to have multiple tags added
to the document. to the document.
The endpoint will immediately return "OK" if the document consumption process The endpoint will immediately return "OK" if the document consumption process
was started successfully. No additional status information about the consumption was started successfully. No additional status information about the consumption
process itself is available, since that happens in a different process. process itself is available, since that happens in a different process.

View File

@ -31,7 +31,7 @@ PAPERLESS_REDIS=<url>
PAPERLESS_DBHOST=<hostname> PAPERLESS_DBHOST=<hostname>
By default, sqlite is used as the database backend. This can be changed here. By default, sqlite is used as the database backend. This can be changed here.
Set PAPERLESS_DBHOST and PostgreSQL will be used instead of mysql. Set PAPERLESS_DBHOST and PostgreSQL will be used instead of sqlite.
PAPERLESS_DBPORT=<port> PAPERLESS_DBPORT=<port>
Adjust port if necessary. Adjust port if necessary.
@ -60,6 +60,13 @@ PAPERLESS_DBSSLMODE=<mode>
Default is ``prefer``. Default is ``prefer``.
PAPERLESS_DB_TIMEOUT=<float>
Amount of time for a database connection to wait for the database to unlock.
Mostly applicable for an sqlite based installation, consider changing to postgresql
if you need to increase this.
Defaults to unset, keeping the Django defaults.
Paths and folders Paths and folders
################# #################
@ -111,6 +118,14 @@ PAPERLESS_FILENAME_FORMAT=<format>
Default is none, which disables this feature. 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> PAPERLESS_LOGGING_DIR=<path>
This is where paperless will store log files. This is where paperless will store log files.
@ -416,14 +431,23 @@ PAPERLESS_OCR_IMAGE_DPI=<num>
the produced PDF documents are A4 sized. the produced PDF documents are A4 sized.
PAPERLESS_OCR_MAX_IMAGE_PIXELS=<num> PAPERLESS_OCR_MAX_IMAGE_PIXELS=<num>
Paperless will not OCR images that have more pixels than this limit. Paperless will raise a warning when OCRing images which are over this limit and
This is intended to prevent decompression bombs from overloading paperless. will not OCR images which are more than twice this limit. Note this does not
Increasing this limit is desired if you face a DecompressionBombError despite prevent the document from being consumed, but could result in missing text content.
the concerning file not being malicious; this could e.g. be caused by invalidly
recognized metadata. If unset, will default to the value determined by
If you have enough resources or if you are certain that your uploaded files `Pillow <https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.MAX_IMAGE_PIXELS>`_.
are not malicious you can increase this value to your needs.
The default value is 256000000, an image with more pixels than that would not be parsed. .. note::
Increasing this limit could cause Paperless to consume additional resources
when consuming a file. Be sure you have sufficient system resources.
.. caution::
The limit is intended to prevent malicious files from consuming system resources
and causing crashes and other errors. Only increase this value if you are certain
your documents are not malicious and you need the text which was not OCRed
PAPERLESS_OCR_USER_ARGS=<json> PAPERLESS_OCR_USER_ARGS=<json>
OCRmyPDF offers many more options. Use this parameter to specify any OCRmyPDF offers many more options. Use this parameter to specify any
@ -519,6 +543,8 @@ PAPERLESS_TASK_WORKERS=<num>
maintain the automatic matching algorithm, check emails, consume documents, maintain the automatic matching algorithm, check emails, consume documents,
etc. This variable specifies how many things it will do in parallel. etc. This variable specifies how many things it will do in parallel.
Defaults to 1
PAPERLESS_THREADS_PER_WORKER=<num> PAPERLESS_THREADS_PER_WORKER=<num>
Furthermore, paperless uses multiple threads when consuming documents to Furthermore, paperless uses multiple threads when consuming documents to
@ -590,6 +616,28 @@ PAPERLESS_CONSUMER_POLLING=<num>
Defaults to 0, which disables polling and uses filesystem notifications. 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> PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>
When the consumer detects a duplicate document, it will not touch the When the consumer detects a duplicate document, it will not touch the
@ -650,7 +698,6 @@ PAPERLESS_CONSUMER_BARCODE_STRING=PATCHT
Defaults to "PATCHT" Defaults to "PATCHT"
PAPERLESS_CONVERT_MEMORY_LIMIT=<num> PAPERLESS_CONVERT_MEMORY_LIMIT=<num>
On smaller systems, or even in the case of Very Large Documents, the consumer 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 may explode, complaining about how it's "unable to extend pixel cache". In
@ -674,13 +721,6 @@ PAPERLESS_CONVERT_TMPDIR=<path>
Default is none, which disables the temporary directory. Default is none, which disables the temporary directory.
PAPERLESS_OPTIMIZE_THUMBNAILS=<bool>
Use optipng to optimize thumbnails. This usually reduces the size of
thumbnails by about 20%, but uses considerable compute time during
consumption.
Defaults to true.
PAPERLESS_POST_CONSUME_SCRIPT=<filename> PAPERLESS_POST_CONSUME_SCRIPT=<filename>
After a document is consumed, Paperless can trigger an arbitrary script if After a document is consumed, Paperless can trigger an arbitrary script if
you like. This script will be passed a number of arguments for you to work you like. This script will be passed a number of arguments for you to work
@ -696,6 +736,9 @@ PAPERLESS_FILENAME_DATE_ORDER=<format>
The filename will be checked first, and if nothing is found, the document The filename will be checked first, and if nothing is found, the document
text will be checked as normal. 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. Defaults to none, which disables this feature.
PAPERLESS_THUMBNAIL_FONT_NAME=<filename> PAPERLESS_THUMBNAIL_FONT_NAME=<filename>
@ -713,10 +756,7 @@ PAPERLESS_IGNORE_DATES=<string>
this process. This is useful for special dates (like date of birth) that appear 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. 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 The date is parsed using the order specified in PAPERLESS_DATE_ORDER
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"
Defaults to an empty string to not ignore any dates. Defaults to an empty string to not ignore any dates.
@ -751,9 +791,6 @@ PAPERLESS_CONVERT_BINARY=<path>
PAPERLESS_GS_BINARY=<path> PAPERLESS_GS_BINARY=<path>
Defaults to "/usr/bin/gs". Defaults to "/usr/bin/gs".
PAPERLESS_OPTIPNG_BINARY=<path>
Defaults to "/usr/bin/optipng".
.. _configuration-docker: .. _configuration-docker:
@ -769,9 +806,7 @@ PAPERLESS_WEBSERVER_WORKERS=<num>
also loads the entire application into memory separately, so increasing this value also loads the entire application into memory separately, so increasing this value
will increase RAM usage. will increase RAM usage.
Consider configuring this to 1 on low power devices with limited amount of RAM. Defaults to 1.
Defaults to 2.
PAPERLESS_PORT=<port> PAPERLESS_PORT=<port>
The port number the webserver will listen on inside the container. There are The port number the webserver will listen on inside the container. There are

View File

@ -88,7 +88,7 @@ Physical scanners
.. [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. .. [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 .. [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>` configuration values for your scanner. The scanner timeout is 3 minutes, so ``180`` is a good starting point. :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 Mobile phone software
===================== =====================

View File

@ -184,6 +184,25 @@ Install Paperless from Docker Hub
port 8000. Modifying the part before the colon will map requests on another port 8000. Modifying the part before the colon will map requests on another
port to the webserver running on the default port. port to the webserver running on the default port.
**Rootless**
If you want to run Paperless as a rootless container, you will need to do the
following in your ``docker-compose.yml``:
- set the ``user`` running the container to map to the ``paperless`` user in the
container.
This value (``user_id`` below), should be the same id that ``USERMAP_UID`` and
``USERMAP_GID`` are set to in the next step.
See ``USERMAP_UID`` and ``USERMAP_GID`` :ref:`here <configuration-docker>`.
Your entry for Paperless should contain something like:
.. code::
webserver:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
user: <user_id>
5. Modify ``docker-compose.env``, following the comments in the file. The 5. Modify ``docker-compose.env``, following the comments in the file. The
most important change is to set ``USERMAP_UID`` and ``USERMAP_GID`` most important change is to set ``USERMAP_UID`` and ``USERMAP_GID``
to the uid and gid of your user on the host system. Use ``id -u`` and to the uid and gid of your user on the host system. Use ``id -u`` and
@ -200,6 +219,19 @@ Install Paperless from Docker Hub
You can copy any setting from the file ``paperless.conf.example`` and paste it here. You can copy any setting from the file ``paperless.conf.example`` and paste it here.
Have a look at :ref:`configuration` to see what's available. Have a look at :ref:`configuration` to see what's available.
.. note::
You can utilize Docker secrets for some configuration settings by
appending `_FILE` to some configuration values. This is supported currently
only by:
* PAPERLESS_DBUSER
* PAPERLESS_DBPASS
* PAPERLESS_SECRET_KEY
* PAPERLESS_AUTO_LOGIN_USERNAME
* PAPERLESS_ADMIN_USER
* PAPERLESS_ADMIN_MAIL
* PAPERLESS_ADMIN_PASSWORD
.. caution:: .. caution::
Some file systems such as NFS network shares don't support file system Some file systems such as NFS network shares don't support file system
@ -286,7 +318,6 @@ writing. Windows is not and will never be supported.
* ``fonts-liberation`` for generating thumbnails for plain text files * ``fonts-liberation`` for generating thumbnails for plain text files
* ``imagemagick`` >= 6 for PDF conversion * ``imagemagick`` >= 6 for PDF conversion
* ``optipng`` for optimizing thumbnails
* ``gnupg`` for handling encrypted documents * ``gnupg`` for handling encrypted documents
* ``libpq-dev`` for PostgreSQL * ``libpq-dev`` for PostgreSQL
* ``libmagic-dev`` for mime type detection * ``libmagic-dev`` for mime type detection
@ -298,7 +329,7 @@ writing. Windows is not and will never be supported.
.. code:: .. code::
python3 python3-pip python3-dev imagemagick fonts-liberation optipng gnupg libpq-dev libmagic-dev mime-support libzbar0 poppler-utils python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev libmagic-dev mime-support libzbar0 poppler-utils
These dependencies are required for OCRmyPDF, which is used for text recognition. These dependencies are required for OCRmyPDF, which is used for text recognition.
@ -308,7 +339,7 @@ writing. Windows is not and will never be supported.
* ``qpdf`` * ``qpdf``
* ``liblept5`` * ``liblept5``
* ``libxml2`` * ``libxml2``
* ``pngquant`` * ``pngquant`` (suggested for certain PDF image optimizations)
* ``zlib1g`` * ``zlib1g``
* ``tesseract-ocr`` >= 4.0.0 for OCR * ``tesseract-ocr`` >= 4.0.0 for OCR
* ``tesseract-ocr`` language packs (``tesseract-ocr-eng``, ``tesseract-ocr-deu``, etc) * ``tesseract-ocr`` language packs (``tesseract-ocr-eng``, ``tesseract-ocr-deu``, etc)
@ -332,6 +363,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 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. 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>`_. 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. 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``. Extract the archive to a place from where you wish to execute it, such as ``/opt/paperless``.
@ -724,8 +761,6 @@ configuring some options in paperless can help improve performance immensely:
* If you want to perform OCR on the device, consider using ``PAPERLESS_OCR_CLEAN=none``. * If you want to perform OCR on the device, consider using ``PAPERLESS_OCR_CLEAN=none``.
This will speed up OCR times and use less memory at the expense of slightly worse This will speed up OCR times and use less memory at the expense of slightly worse
OCR results. OCR results.
* Set ``PAPERLESS_OPTIMIZE_THUMBNAILS`` to 'false' if you want faster consumption
times. Thumbnails will be about 20% larger.
* If using docker, consider setting ``PAPERLESS_WEBSERVER_WORKERS`` to * If using docker, consider setting ``PAPERLESS_WEBSERVER_WORKERS`` to
1. This will save some memory. 1. This will save some memory.

View File

@ -235,3 +235,85 @@ 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 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 open the affected documents in paperless for editing. Paperless will continue to work, and will simply not
show the invalid metadata. 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.
Log reports "Creating PaperlessTask failed".
#########################################################
You might find messages like these in your log files:
.. code::
[ERROR] [paperless.management.consumer] Creating PaperlessTask failed: db locked
You are likely using an sqlite based installation, with an increased number of workers and are running into sqlite's concurrency limitations.
Uploading or consuming multiple files at once results in many workers attempting to access the database simultaneously.
Consider changing to the PostgreSQL database if you will be processing many documents at once often. Otherwise,
try tweaking the ``PAPERLESS_DB_TIMEOUT`` setting to allow more time for the database to unlock. This may have
minor performance implications.

View File

@ -161,6 +161,9 @@ These are as follows:
will not consume flagged mails. will not consume flagged mails.
* **Move to folder:** Moves consumed mails out of the way so that paperless wont * **Move to folder:** Moves consumed mails out of the way so that paperless wont
consume them again. consume them again.
* **Add custom Tag:** Adds a custom tag to mails with consumed documents (the IMAP
standard calls these "keywords"). Paperless will not consume mails already tagged.
Not all mail servers support this feature!
.. caution:: .. caution::

View File

@ -1,7 +1,7 @@
import os 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)) workers = int(os.getenv("PAPERLESS_WEBSERVER_WORKERS", 1))
worker_class = "paperless.workers.ConfigurableWorker" worker_class = "paperless.workers.ConfigurableWorker"
timeout = 120 timeout = 120

View File

@ -23,6 +23,7 @@
#PAPERLESS_MEDIA_ROOT=../media #PAPERLESS_MEDIA_ROOT=../media
#PAPERLESS_STATICDIR=../static #PAPERLESS_STATICDIR=../static
#PAPERLESS_FILENAME_FORMAT= #PAPERLESS_FILENAME_FORMAT=
#PAPERLESS_FILENAME_FORMAT_REMOVE_NONE=
# Security and hosting # Security and hosting
@ -64,7 +65,6 @@
#PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS=false #PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS=false
#PAPERLESS_CONSUMER_ENABLE_BARCODES=false #PAPERLESS_CONSUMER_ENABLE_BARCODES=false
#PAPERLESS_CONSUMER_ENABLE_BARCODES=PATCHT #PAPERLESS_CONSUMER_ENABLE_BARCODES=PATCHT
#PAPERLESS_OPTIMIZE_THUMBNAILS=true
#PAPERLESS_PRE_CONSUME_SCRIPT=/path/to/an/arbitrary/script.sh #PAPERLESS_PRE_CONSUME_SCRIPT=/path/to/an/arbitrary/script.sh
#PAPERLESS_POST_CONSUME_SCRIPT=/path/to/an/arbitrary/script.sh #PAPERLESS_POST_CONSUME_SCRIPT=/path/to/an/arbitrary/script.sh
#PAPERLESS_FILENAME_DATE_ORDER=YMD #PAPERLESS_FILENAME_DATE_ORDER=YMD
@ -83,4 +83,3 @@
#PAPERLESS_CONVERT_BINARY=/usr/bin/convert #PAPERLESS_CONVERT_BINARY=/usr/bin/convert
#PAPERLESS_GS_BINARY=/usr/bin/gs #PAPERLESS_GS_BINARY=/usr/bin/gs
#PAPERLESS_OPTIPNG_BINARY=/usr/bin/optipng

View File

@ -1,43 +1,37 @@
#
# These requirements were autogenerated by pipenv
# To regenerate from the project's Pipfile, run:
#
# pipenv lock --requirements
#
-i https://pypi.python.org/simple -i https://pypi.python.org/simple
--extra-index-url https://www.piwheels.org/simple --extra-index-url https://www.piwheels.org/simple
aioredis==1.3.1 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' 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' 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' 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.6.1; python_version >= '3.7'
automat==20.2.0 automat==20.2.0
backports.zoneinfo==0.2.1; python_version < '3.9' backports.zoneinfo==0.2.1; python_version < '3.9'
blessed==1.19.1; python_version >= '2.7' blessed==1.19.1; python_version >= '2.7'
certifi==2021.10.8 certifi==2022.6.15; python_version >= '3.6'
cffi==1.15.0 cffi==1.15.1
channels-redis==3.4.0 channels==3.0.5
channels==3.0.4 channels-redis==3.4.1
chardet==4.0.0; python_version >= '3.1' charset-normalizer==2.1.0; python_version >= '3.6'
charset-normalizer==2.0.12; python_version >= '3'
click==8.1.3; python_version >= '3.7' 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' 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 concurrent-log-handler==0.9.20
constantly==15.1.0 constantly==15.1.0
cryptography==37.0.1; python_version >= '3.6' cryptography==37.0.4; python_version >= '3.6'
daphne==3.0.2; python_version >= '3.6' daphne==3.0.2; python_version >= '3.6'
dateparser==1.1.1 dateparser==1.1.1
django-cors-headers==3.11.0 deprecated==1.2.13; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
django-extensions==3.1.5 deprecation==2.1.0
django-filter==21.1 django==4.0.6
django-picklefield==3.0.1; python_version >= '3' django-cors-headers==3.13.0
django-q==1.3.9 django-extensions==3.2.0
django==4.0.4 django-filter==22.1
django-picklefield==3.1; python_version >= '3'
-e git+https://github.com/paperless-ngx/django-q.git@bf20d57f859a7d872d5979cd8879fac9c9df981c#egg=django-q
djangorestframework==3.13.1 djangorestframework==3.13.1
filelock==3.6.0 filelock==3.7.1
fuzzywuzzy[speedup]==0.18.0 fuzzywuzzy[speedup]==0.18.0
gunicorn==20.1.0 gunicorn==20.1.0
h11==0.13.0; python_version >= '3.6' h11==0.13.0; python_version >= '3.6'
@ -45,50 +39,50 @@ hiredis==2.0.0; python_version >= '3.6'
httptools==0.4.0 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' 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 hyperlink==21.0.0
idna==3.3; python_version >= '3' idna==3.3; python_version >= '3.5'
imap-tools==0.54.0 imap-tools==0.56.0
img2pdf==0.4.4 img2pdf==0.4.4
importlib-resources==5.7.1; python_version < '3.9' importlib-resources==5.8.0; python_version < '3.9'
incremental==21.3.0 incremental==21.3.0
inotify-simple==1.3.5; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' inotify-simple==1.3.5; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
inotifyrecursive==0.3.5 inotifyrecursive==0.3.5
joblib==1.1.0; python_version >= '3.6' joblib==1.1.0; python_version >= '3.6'
langdetect==1.0.9 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' lxml==4.9.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
msgpack==1.0.3 msgpack==1.0.4
numpy==1.22.3; python_version >= '3.8' numpy==1.23.1; python_version >= '3.8'
ocrmypdf==13.4.3 ocrmypdf==13.6.1
packaging==21.3; python_version >= '3.6' packaging==21.3; python_version >= '3.6'
pathvalidate==2.5.0 pathvalidate==2.5.0
pdf2image==1.16.0 pdf2image==1.16.0
pdfminer.six==20220319 pdfminer.six==20220524
pikepdf==5.1.2 pikepdf==5.4.0
pillow==9.1.0 pillow==9.2.0
pluggy==1.0.0; python_version >= '3.6' pluggy==1.0.0; python_version >= '3.6'
portalocker==2.4.0; python_version >= '3' portalocker==2.5.1; python_version >= '3'
psycopg2==2.9.3 psycopg2==2.9.3
pyasn1-modules==0.2.8
pyasn1==0.4.8 pyasn1==0.4.8
pyasn1-modules==0.2.8
pycparser==2.21 pycparser==2.21
pyopenssl==22.0.0 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-dateutil==2.8.2
python-dotenv==0.20.0 python-dotenv==0.20.0
python-gnupg==0.4.8 python-gnupg==0.4.9
python-levenshtein==0.12.2 python-levenshtein==0.12.2
python-magic==0.4.25 python-magic==0.4.27
pytz-deprecation-shim==0.1.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
pytz==2022.1 pytz==2022.1
pytz-deprecation-shim==0.1.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
pyyaml==6.0 pyyaml==6.0
pyzbar==0.1.9 pyzbar==0.1.9
redis==3.5.3 redis==4.3.4
regex==2022.3.2; python_version >= '3.6' regex==2022.3.2; python_version >= '3.6'
reportlab==3.6.9; python_version >= '3.7' and python_version < '4' reportlab==3.6.11; python_version >= '3.7' and python_version < '4'
requests==2.27.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' requests==2.28.1; python_version >= '3.7' and python_version < '4'
scikit-learn==1.0.2 scikit-learn==1.1.1
scipy==1.8.0; python_version < '3.11' and python_version >= '3.8' scipy==1.8.1; python_version < '3.11' and python_version >= '3.8'
service-identity==21.1.0 service-identity==21.1.0
setuptools==62.1.0; python_version >= '3.7' setuptools==63.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' 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' sniffio==1.2.0; python_version >= '3.5'
sqlparse==0.4.2; python_version >= '3.5' sqlparse==0.4.2; python_version >= '3.5'
@ -97,17 +91,18 @@ tika==1.24
tqdm==4.64.0 tqdm==4.64.0
twisted[tls]==22.4.0; python_full_version >= '3.6.7' twisted[tls]==22.4.0; python_full_version >= '3.6.7'
txaio==22.2.1; python_version >= '3.6' txaio==22.2.1; python_version >= '3.6'
typing-extensions==4.2.0; python_version >= '3.7' typing-extensions==4.3.0; python_version >= '3.7'
tzdata==2022.1; python_version >= '3.6' tzdata==2022.1; python_version >= '3.6'
tzlocal==4.2; python_version >= '3.6' 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' urllib3==1.26.10; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' and python_version < '4'
uvicorn[standard]==0.17.6 uvicorn[standard]==0.18.2
uvloop==0.16.0 uvloop==0.16.0
watchdog==2.1.7 watchdog==2.1.9
watchgod==0.8.2 watchfiles==0.16.0
wcwidth==0.2.5 wcwidth==0.2.5
websockets==10.3 websockets==10.3
whitenoise==6.0.0 whitenoise==6.2.0
whoosh==2.7.4 whoosh==2.7.4
zipp==3.8.0; python_version < '3.9' wrapt==1.14.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
zipp==3.8.1; python_version < '3.9'
zope.interface==5.4.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' zope.interface==5.4.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'

View File

@ -1,21 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
DOCUMENT_ID=${1}
DOCUMENT_FILE_NAME=${2}
DOCUMENT_SOURCE_PATH=${3}
DOCUMENT_THUMBNAIL_PATH=${4}
DOCUMENT_DOWNLOAD_URL=${5}
DOCUMENT_THUMBNAIL_URL=${6}
DOCUMENT_CORRESPONDENT=${7}
DOCUMENT_TAGS=${8}
echo " echo "
A document with an id of ${DOCUMENT_ID} was just consumed. I know the A document with an id of ${DOCUMENT_ID} was just consumed. I know the
following additional information about it: following additional information about it:
* Generated File Name: ${DOCUMENT_FILE_NAME} * Generated File Name: ${DOCUMENT_FILE_NAME}
* Archive Path: ${DOCUMENT_ARCHIVE_PATH}
* Source Path: ${DOCUMENT_SOURCE_PATH} * Source Path: ${DOCUMENT_SOURCE_PATH}
* Created: ${DOCUMENT_CREATED}
* Added: ${DOCUMENT_ADDED}
* Modified: ${DOCUMENT_MODIFIED}
* Thumbnail Path: ${DOCUMENT_THUMBNAIL_PATH} * Thumbnail Path: ${DOCUMENT_THUMBNAIL_PATH}
* Download URL: ${DOCUMENT_DOWNLOAD_URL} * Download URL: ${DOCUMENT_DOWNLOAD_URL}
* Thumbnail URL: ${DOCUMENT_THUMBNAIL_URL} * Thumbnail URL: ${DOCUMENT_THUMBNAIL_URL}

13
src-ui/cypress.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from 'cypress'
export default defineConfig({
videosFolder: 'cypress/videos',
screenshotsFolder: 'cypress/screenshots',
fixturesFolder: 'cypress/fixtures',
e2e: {
setupNodeEvents(on, config) {
return require('./cypress/plugins/index.ts')(on, config)
},
baseUrl: 'http://localhost:4200',
},
})

View File

@ -1,9 +0,0 @@
{
"integrationFolder": "cypress/integration",
"supportFile": "cypress/support/index.ts",
"videosFolder": "cypress/videos",
"screenshotsFolder": "cypress/screenshots",
"pluginsFile": "cypress/plugins/index.ts",
"fixturesFolder": "cypress/fixtures",
"baseUrl": "http://localhost:4200"
}

View File

@ -1,5 +1,7 @@
describe('document-detail', () => { describe('document-detail', () => {
beforeEach(() => { beforeEach(() => {
// also uses global fixtures from cypress/support/e2e.ts
this.modifiedDocuments = [] this.modifiedDocuments = []
cy.fixture('documents/documents.json').then((documentsJson) => { cy.fixture('documents/documents.json').then((documentsJson) => {
@ -15,30 +17,6 @@ describe('document-detail', () => {
req.reply({ result: 'OK' }) req.reply({ result: 'OK' })
}).as('saveDoc') }).as('saveDoc')
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.intercept('http://localhost:8000/api/saved_views/*', {
fixture: 'saved_views/savedviews.json',
})
cy.intercept('http://localhost:8000/api/tags/*', {
fixture: 'tags/tags.json',
})
cy.intercept('http://localhost:8000/api/correspondents/*', {
fixture: 'correspondents/correspondents.json',
})
cy.intercept('http://localhost:8000/api/document_types/*', {
fixture: 'document_types/doctypes.json',
})
cy.viewport(1024, 1024) cy.viewport(1024, 1024)
cy.visit('/documents/1/') cy.visit('/documents/1/')
}) })

View File

@ -1,8 +1,9 @@
describe('documents-list', () => { describe('documents-list', () => {
beforeEach(() => { beforeEach(() => {
// also uses global fixtures from cypress/support/e2e.ts
this.bulkEdits = {} this.bulkEdits = {}
// mock API methods
cy.fixture('documents/documents.json').then((documentsJson) => { cy.fixture('documents/documents.json').then((documentsJson) => {
// bulk edit // bulk edit
cy.intercept( cy.intercept(
@ -53,40 +54,25 @@ describe('documents-list', () => {
}) })
}) })
cy.intercept('http://localhost:8000/api/documents/1/thumb/', { cy.viewport(1280, 1024)
fixture: 'documents/lorem-ipsum.png',
})
cy.intercept('http://localhost:8000/api/tags/*', {
fixture: 'tags/tags.json',
})
cy.intercept('http://localhost:8000/api/correspondents/*', {
fixture: 'correspondents/correspondents.json',
})
cy.intercept('http://localhost:8000/api/document_types/*', {
fixture: 'document_types/doctypes.json',
})
cy.visit('/documents') cy.visit('/documents')
}) })
it('should show a list of documents rendered as cards with thumbnails', () => { it('should show a list of documents rendered as cards with thumbnails', () => {
cy.contains('3 documents') cy.contains('3 documents')
cy.contains('lorem-ipsum') cy.contains('lorem ipsum')
cy.get('app-document-card-small:first-of-type img') cy.get('app-document-card-small:first-of-type img')
.invoke('attr', 'src') .invoke('attr', 'src')
.should('eq', 'http://localhost:8000/api/documents/1/thumb/') .should('eq', 'http://localhost:8000/api/documents/1/thumb/')
}) })
it('should change to table "details" view', () => { it('should change to table "details" view', () => {
cy.get('div.btn-group-toggle input[value="details"]').parent().click() cy.get('div.btn-group input[value="details"]').next().click()
cy.get('table') cy.get('table')
}) })
it('should change to large cards view', () => { it('should change to large cards view', () => {
cy.get('div.btn-group-toggle input[value="largeCards"]').parent().click() cy.get('div.btn-group input[value="largeCards"]').next().click()
cy.get('app-document-card-large') cy.get('app-document-card-large')
}) })

View File

@ -0,0 +1,331 @@
import { PaperlessDocument } from 'src/app/data/paperless-document'
describe('documents query params', () => {
beforeEach(() => {
// also uses global fixtures from cypress/support/e2e.ts
cy.fixture('documents/documents.json').then((documentsJson) => {
// mock api filtering
cy.intercept('GET', 'http://localhost:8000/api/documents/*', (req) => {
let response = { ...documentsJson }
if (req.query.hasOwnProperty('ordering')) {
const sort_field = req.query['ordering'].toString().replace('-', '')
const reverse = req.query['ordering'].toString().indexOf('-') !== -1
response.results = (
documentsJson.results as Array<PaperlessDocument>
).sort((docA, docB) => {
let result = 0
switch (sort_field) {
case 'created':
case 'added':
result =
new Date(docA[sort_field]) < new Date(docB[sort_field])
? -1
: 1
break
case 'archive_serial_number':
result = docA[sort_field] < docB[sort_field] ? -1 : 1
break
}
if (reverse) result = -result
return result
})
}
if (req.query.hasOwnProperty('tags__id__in')) {
const tag_ids: Array<number> = req.query['tags__id__in']
.toString()
.split(',')
.map((v) => +v)
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter(
(d) =>
d.tags.length > 0 &&
d.tags.filter((t) => tag_ids.includes(t)).length > 0
)
response.count = response.results.length
} else if (req.query.hasOwnProperty('tags__id__none')) {
const tag_ids: Array<number> = req.query['tags__id__none']
.toString()
.split(',')
.map((v) => +v)
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter((d) => d.tags.filter((t) => tag_ids.includes(t)).length == 0)
response.count = response.results.length
} else if (
req.query.hasOwnProperty('is_tagged') &&
req.query['is_tagged'] == '0'
) {
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter((d) => d.tags.length == 0)
response.count = response.results.length
}
if (req.query.hasOwnProperty('document_type__id')) {
const doctype_id = +req.query['document_type__id']
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter((d) => d.document_type == doctype_id)
response.count = response.results.length
} else if (
req.query.hasOwnProperty('document_type__isnull') &&
req.query['document_type__isnull'] == '1'
) {
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter((d) => d.document_type == undefined)
response.count = response.results.length
}
if (req.query.hasOwnProperty('correspondent__id')) {
const correspondent_id = +req.query['correspondent__id']
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter((d) => d.correspondent == correspondent_id)
response.count = response.results.length
} else if (
req.query.hasOwnProperty('correspondent__isnull') &&
req.query['correspondent__isnull'] == '1'
) {
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter((d) => d.correspondent == undefined)
response.count = response.results.length
}
if (req.query.hasOwnProperty('storage_path__id')) {
const storage_path_id = +req.query['storage_path__id']
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter((d) => d.storage_path == storage_path_id)
response.count = response.results.length
} else if (
req.query.hasOwnProperty('storage_path__isnull') &&
req.query['storage_path__isnull'] == '1'
) {
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter((d) => d.storage_path == undefined)
response.count = response.results.length
}
if (req.query.hasOwnProperty('created__date__gt')) {
const date = new Date(req.query['created__date__gt'])
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter((d) => new Date(d.created) > date)
response.count = response.results.length
} else if (req.query.hasOwnProperty('created__date__lt')) {
const date = new Date(req.query['created__date__lt'])
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter((d) => new Date(d.created) < date)
response.count = response.results.length
}
if (req.query.hasOwnProperty('added__date__gt')) {
const date = new Date(req.query['added__date__gt'])
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter((d) => new Date(d.added) > date)
response.count = response.results.length
} else if (req.query.hasOwnProperty('added__date__lt')) {
const date = new Date(req.query['added__date__lt'])
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter((d) => new Date(d.added) < date)
response.count = response.results.length
}
if (req.query.hasOwnProperty('title_content')) {
const title_content_regexp = new RegExp(
req.query['title_content'].toString(),
'i'
)
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter(
(d) =>
title_content_regexp.test(d.title) ||
title_content_regexp.test(d.content)
)
response.count = response.results.length
}
if (req.query.hasOwnProperty('archive_serial_number')) {
const asn = +req.query['archive_serial_number']
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter((d) => d.archive_serial_number == asn)
response.count = response.results.length
} else if (req.query.hasOwnProperty('archive_serial_number__isnull')) {
const isnull = req.query['storage_path__isnull'] == '1'
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter((d) =>
isnull
? d.archive_serial_number == undefined
: d.archive_serial_number != undefined
)
response.count = response.results.length
} else if (req.query.hasOwnProperty('archive_serial_number__gt')) {
const asn = +req.query['archive_serial_number__gt']
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter(
(d) => d.archive_serial_number > 0 && d.archive_serial_number > asn
)
response.count = response.results.length
} else if (req.query.hasOwnProperty('archive_serial_number__lt')) {
const asn = +req.query['archive_serial_number__lt']
response.results = (
documentsJson.results as Array<PaperlessDocument>
).filter(
(d) => d.archive_serial_number > 0 && d.archive_serial_number < asn
)
response.count = response.results.length
}
req.reply(response)
})
})
})
it('should show a list of documents sorted by created', () => {
cy.visit('/documents?sort=created')
cy.get('app-document-card-small').first().contains('No latin title')
})
it('should show a list of documents reverse sorted by created', () => {
cy.visit('/documents?sort=created&reverse=true')
cy.get('app-document-card-small').first().contains('sit amet')
})
it('should show a list of documents sorted by added', () => {
cy.visit('/documents?sort=added')
cy.get('app-document-card-small').first().contains('No latin title')
})
it('should show a list of documents reverse sorted by added', () => {
cy.visit('/documents?sort=added&reverse=true')
cy.get('app-document-card-small').first().contains('sit amet')
})
it('should show a list of documents filtered by any tags', () => {
cy.visit('/documents?sort=created&reverse=true&tags__id__in=2,4,5')
cy.contains('3 documents')
})
it('should show a list of documents filtered by excluded tags', () => {
cy.visit('/documents?sort=created&reverse=true&tags__id__none=2,4')
cy.contains('One document')
})
it('should show a list of documents filtered by no tags', () => {
cy.visit('/documents?sort=created&reverse=true&is_tagged=0')
cy.contains('One document')
})
it('should show a list of documents filtered by document type', () => {
cy.visit('/documents?sort=created&reverse=true&document_type__id=1')
cy.contains('3 documents')
})
it('should show a list of documents filtered by no document type', () => {
cy.visit('/documents?sort=created&reverse=true&document_type__isnull=1')
cy.contains('One document')
})
it('should show a list of documents filtered by correspondent', () => {
cy.visit('/documents?sort=created&reverse=true&correspondent__id=9')
cy.contains('2 documents')
})
it('should show a list of documents filtered by no correspondent', () => {
cy.visit('/documents?sort=created&reverse=true&correspondent__isnull=1')
cy.contains('2 documents')
})
it('should show a list of documents filtered by storage path', () => {
cy.visit('/documents?sort=created&reverse=true&storage_path__id=2')
cy.contains('One document')
})
it('should show a list of documents filtered by no storage path', () => {
cy.visit('/documents?sort=created&reverse=true&storage_path__isnull=1')
cy.contains('3 documents')
})
it('should show a list of documents filtered by title or content', () => {
cy.visit('/documents?sort=created&reverse=true&title_content=lorem')
cy.contains('2 documents')
})
it('should show a list of documents filtered by asn', () => {
cy.visit('/documents?sort=created&reverse=true&archive_serial_number=12345')
cy.contains('One document')
})
it('should show a list of documents filtered by empty asn', () => {
cy.visit(
'/documents?sort=created&reverse=true&archive_serial_number__isnull=1'
)
cy.contains('2 documents')
})
it('should show a list of documents filtered by non-empty asn', () => {
cy.visit(
'/documents?sort=created&reverse=true&archive_serial_number__isnull=0'
)
cy.contains('2 documents')
})
it('should show a list of documents filtered by asn greater than', () => {
cy.visit(
'/documents?sort=created&reverse=true&archive_serial_number__gt=12346'
)
cy.contains('One document')
})
it('should show a list of documents filtered by asn less than', () => {
cy.visit(
'/documents?sort=created&reverse=true&archive_serial_number__lt=12346'
)
cy.contains('One document')
})
it('should show a list of documents filtered by created date greater than', () => {
cy.visit(
'/documents?sort=created&reverse=true&created__date__gt=2022-03-23'
)
cy.contains('3 documents')
})
it('should show a list of documents filtered by created date less than', () => {
cy.visit(
'/documents?sort=created&reverse=true&created__date__lt=2022-03-23'
)
cy.contains('One document')
})
it('should show a list of documents filtered by added date greater than', () => {
cy.visit('/documents?sort=created&reverse=true&added__date__gt=2022-03-24')
cy.contains('2 documents')
})
it('should show a list of documents filtered by added date less than', () => {
cy.visit('/documents?sort=created&reverse=true&added__date__lt=2022-03-24')
cy.contains('2 documents')
})
it('should show a list of documents filtered by multiple filters', () => {
cy.visit(
'/documents?sort=created&reverse=true&document_type__id=1&correspondent__id=9&tags__id__in=4,5'
)
cy.contains('2 documents')
})
})

View File

@ -1,12 +1,5 @@
describe('manage', () => { describe('manage', () => {
beforeEach(() => { // also uses global fixtures from cypress/support/e2e.ts
cy.intercept('http://localhost:8000/api/correspondents/*', {
fixture: 'correspondents/correspondents.json',
})
cy.intercept('http://localhost:8000/api/tags/*', {
fixture: 'tags/tags.json',
})
})
it('should show a list of correspondents with bottom pagination as well', () => { it('should show a list of correspondents with bottom pagination as well', () => {
cy.visit('/correspondents') cy.visit('/correspondents')

View File

@ -1,47 +1,49 @@
describe('settings', () => { describe('settings', () => {
beforeEach(() => { beforeEach(() => {
// also uses global fixtures from cypress/support/e2e.ts
this.modifiedViews = [] this.modifiedViews = []
// mock API methods // mock API methods
cy.fixture('saved_views/savedviews.json').then((savedViewsJson) => { cy.intercept('http://localhost:8000/api/ui_settings/', {
// saved views PATCH fixture: 'ui_settings/settings.json',
cy.intercept( }).then(() => {
'PATCH', cy.fixture('saved_views/savedviews.json').then((savedViewsJson) => {
'http://localhost:8000/api/saved_views/*', // saved views PATCH
(req) => { cy.intercept(
this.modifiedViews.push(req.body) // store this for later 'PATCH',
req.reply({ result: 'OK' }) '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) => { cy.intercept(
let response = { ...savedViewsJson } 'GET',
if (this.modifiedViews.length) { 'http://localhost:8000/api/saved_views/*',
response.results = response.results.map((v) => { (req) => {
if (this.modifiedViews.find((mv) => mv.id == v.id)) let response = { ...savedViewsJson }
v = this.modifiedViews.find((mv) => mv.id == v.id) if (this.modifiedViews.length) {
return v 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) req.reply(response)
}).as('savedViews') }
}) ).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)
}) })
})
cy.intercept('http://localhost:8000/api/documents/1/metadata/', { cy.fixture('documents/documents.json').then((documentsJson) => {
fixture: 'documents/1/metadata.json', cy.intercept('GET', 'http://localhost:8000/api/documents/1/', (req) => {
}) let response = { ...documentsJson }
response = response.results.find((d) => d.id == 1)
cy.intercept('http://localhost:8000/api/documents/1/suggestions/', { req.reply(response)
fixture: 'documents/1/suggestions.json', })
})
}) })
cy.viewport(1024, 1024) cy.viewport(1024, 1024)

View File

@ -0,0 +1,60 @@
describe('tasks', () => {
beforeEach(() => {
this.dismissedTasks = new Set<number>()
cy.fixture('tasks/tasks.json').then((tasksViewsJson) => {
// acknowledge tasks POST
cy.intercept(
'POST',
'http://localhost:8000/api/acknowledge_tasks/',
(req) => {
req.body['tasks'].forEach((t) => this.dismissedTasks.add(t)) // store this for later
req.reply({ result: 'OK' })
}
)
cy.intercept('GET', 'http://localhost:8000/api/tasks/', (req) => {
let response = [...tasksViewsJson]
if (this.dismissedTasks.size) {
response = response.filter((t) => {
return !this.dismissedTasks.has(t.id)
})
}
req.reply(response)
}).as('tasks')
})
cy.visit('/tasks')
cy.wait('@tasks')
})
it('should show a list of dismissable tasks in tabs', () => {
cy.get('tbody').find('tr:visible').its('length').should('eq', 10) // double because collapsible result tr
cy.wait(500) // stabilizes the test, for some reason...
cy.get('tbody')
.find('button:visible')
.contains('Dismiss')
.first()
.click()
.wait('@tasks')
.wait(2000)
.then(() => {
cy.get('tbody').find('tr:visible').its('length').should('eq', 8) // double because collapsible result tr
})
})
it('should allow toggling all tasks in list and warn on dismiss', () => {
cy.get('thead').find('input[type="checkbox"]').first().click()
cy.get('body').find('button').contains('Dismiss selected').first().click()
cy.contains('Confirm')
cy.get('.modal')
.contains('button', 'Dismiss')
.click()
.wait('@tasks')
.wait(2000)
.then(() => {
cy.get('tbody').find('tr:visible').should('not.exist')
})
})
})

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"version":"v1.7.1","update_available":false,"feature_is_set":true}

View File

@ -0,0 +1,17 @@
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 2,
"slug": "year-title",
"name": "Year - Title",
"path": "{created_year}/{title}",
"match": "",
"matching_algorithm": 6,
"is_insensitive": true,
"document_count": 1
}
]
}

View File

@ -1 +1,103 @@
{"count":8,"next":null,"previous":null,"results":[{"id":4,"slug":"another-sample-tag","name":"Another Sample Tag","color":"#a6cee3","text_color":"#000000","match":"","matching_algorithm":6,"is_insensitive":true,"is_inbox_tag":false,"document_count":3},{"id":7,"slug":"newone","name":"NewOne","color":"#9e4ad1","text_color":"#ffffff","match":"","matching_algorithm":1,"is_insensitive":true,"is_inbox_tag":false,"document_count":2},{"id":6,"slug":"partial-tag","name":"Partial Tag","color":"#72dba7","text_color":"#000000","match":"","matching_algorithm":1,"is_insensitive":true,"is_inbox_tag":false,"document_count":1},{"id":2,"slug":"tag-2","name":"Tag 2","color":"#612db7","text_color":"#ffffff","match":"","matching_algorithm":1,"is_insensitive":true,"is_inbox_tag":false,"document_count":3},{"id":3,"slug":"tag-3","name":"Tag 3","color":"#b2df8a","text_color":"#000000","match":"","matching_algorithm":1,"is_insensitive":true,"is_inbox_tag":false,"document_count":4},{"id":5,"slug":"tagwithpartial","name":"TagWithPartial","color":"#3b2db4","text_color":"#ffffff","match":"","matching_algorithm":6,"is_insensitive":true,"is_inbox_tag":false,"document_count":2},{"id":8,"slug":"test-another","name":"Test Another","color":"#3ccea5","text_color":"#000000","match":"","matching_algorithm":4,"is_insensitive":true,"is_inbox_tag":false,"document_count":0},{"id":1,"slug":"test-tag","name":"Test Tag","color":"#fb9a99","text_color":"#000000","match":"","matching_algorithm":1,"is_insensitive":true,"is_inbox_tag":false,"document_count":4}]} {
"count": 8,
"next": null,
"previous": null,
"results": [
{
"id": 4,
"slug": "another-sample-tag",
"name": "Another Sample Tag",
"color": "#a6cee3",
"text_color": "#000000",
"match": "",
"matching_algorithm": 6,
"is_insensitive": true,
"is_inbox_tag": false,
"document_count": 3
},
{
"id": 7,
"slug": "newone",
"name": "NewOne",
"color": "#9e4ad1",
"text_color": "#ffffff",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"is_inbox_tag": false,
"document_count": 2
},
{
"id": 6,
"slug": "partial-tag",
"name": "Partial Tag",
"color": "#72dba7",
"text_color": "#000000",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"is_inbox_tag": false,
"document_count": 1
},
{
"id": 2,
"slug": "tag-2",
"name": "Tag 2",
"color": "#612db7",
"text_color": "#ffffff",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"is_inbox_tag": false,
"document_count": 3
},
{
"id": 3,
"slug": "tag-3",
"name": "Tag 3",
"color": "#b2df8a",
"text_color": "#000000",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"is_inbox_tag": false,
"document_count": 4
},
{
"id": 5,
"slug": "tagwithpartial",
"name": "TagWithPartial",
"color": "#3b2db4",
"text_color": "#ffffff",
"match": "",
"matching_algorithm": 6,
"is_insensitive": true,
"is_inbox_tag": false,
"document_count": 2
},
{
"id": 8,
"slug": "test-another",
"name": "Test Another",
"color": "#3ccea5",
"text_color": "#000000",
"match": "",
"matching_algorithm": 4,
"is_insensitive": true,
"is_inbox_tag": false,
"document_count": 0
},
{
"id": 1,
"slug": "test-tag",
"name": "Test Tag",
"color": "#fb9a99",
"text_color": "#000000",
"match": "",
"matching_algorithm": 1,
"is_insensitive": true,
"is_inbox_tag": false,
"document_count": 4
}
]
}

File diff suppressed because one or more lines are too long

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

@ -0,0 +1,43 @@
// mock API methods
beforeEach(() => {
cy.intercept('http://localhost:8000/api/ui_settings/', {
fixture: 'ui_settings/settings.json',
})
cy.intercept('http://localhost:8000/api/remote_version/', {
fixture: 'remote_version/remote_version.json',
})
cy.intercept('http://localhost:8000/api/saved_views/*', {
fixture: 'saved_views/savedviews.json',
})
cy.intercept('http://localhost:8000/api/tags/*', {
fixture: 'tags/tags.json',
})
cy.intercept('http://localhost:8000/api/correspondents/*', {
fixture: 'correspondents/correspondents.json',
})
cy.intercept('http://localhost:8000/api/document_types/*', {
fixture: 'document_types/doctypes.json',
})
cy.intercept('http://localhost:8000/api/storage_paths/*', {
fixture: 'storage_paths/storage_paths.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.intercept('http://localhost:8000/api/documents/1/thumb/', {
fixture: 'documents/lorem-ipsum.png',
})
})

View File

@ -1,17 +0,0 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// When a command from ./commands is ready to use, import with `import './commands'` syntax
// import './commands';

File diff suppressed because it is too large Load Diff

17345
src-ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,48 +13,48 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/common": "~13.3.5", "@angular/common": "~14.0.4",
"@angular/compiler": "~13.3.5", "@angular/compiler": "~14.0.4",
"@angular/core": "~13.3.5", "@angular/core": "~14.0.4",
"@angular/forms": "~13.3.5", "@angular/forms": "~14.0.4",
"@angular/localize": "~13.3.5", "@angular/localize": "~14.0.4",
"@angular/platform-browser": "~13.3.5", "@angular/platform-browser": "~14.0.4",
"@angular/platform-browser-dynamic": "~13.3.5", "@angular/platform-browser-dynamic": "~14.0.4",
"@angular/router": "~13.3.5", "@angular/router": "~14.0.4",
"@ng-bootstrap/ng-bootstrap": "^12.1.1", "@ng-bootstrap/ng-bootstrap": "^13.0.0-beta.1",
"@ng-select/ng-select": "^8.1.1", "@ng-select/ng-select": "^9.0.2",
"@ngneat/dirty-check-forms": "^3.0.2", "@ngneat/dirty-check-forms": "^3.0.2",
"@popperjs/core": "^2.11.4", "@popperjs/core": "^2.11.5",
"bootstrap": "^5.1.3", "bootstrap": "^5.1.3",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"ng2-pdf-viewer": "^9.0.0", "ng2-pdf-viewer": "^9.0.0",
"ngx-color": "^7.3.3", "ngx-color": "^7.3.3",
"ngx-cookie-service": "^13.1.2", "ngx-cookie-service": "^14.0.1",
"ngx-file-drop": "^13.0.0", "ngx-file-drop": "^13.0.0",
"rxjs": "~7.5.5", "rxjs": "~7.5.5",
"tslib": "^2.3.1", "tslib": "^2.3.1",
"uuid": "^8.3.1", "uuid": "^8.3.1",
"zone.js": "~0.11.4" "zone.js": "~0.11.6"
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/jest": "13.0.3", "@angular-builders/jest": "14.0.0",
"@angular-devkit/build-angular": "~13.3.4", "@angular-devkit/build-angular": "~14.0.4",
"@angular/cli": "~13.3.4", "@angular/cli": "~14.0.4",
"@angular/compiler-cli": "~13.3.5", "@angular/compiler-cli": "~14.0.4",
"@types/jest": "27.4.1", "@types/jest": "28.1.4",
"@types/node": "^17.0.30", "@types/node": "^18.0.0",
"codelyzer": "^6.0.2", "codelyzer": "^6.0.2",
"concurrently": "7.1.0", "concurrently": "7.2.2",
"jest": "28.0.3", "jest": "28.1.2",
"jest-environment-jsdom": "^28.0.2", "jest-environment-jsdom": "^28.1.2",
"jest-preset-angular": "^12.0.0-next.1", "jest-preset-angular": "^12.1.0",
"ts-node": "~10.7.0", "ts-node": "~10.8.1",
"tslint": "~6.1.3", "tslint": "~6.1.3",
"typescript": "~4.6.3", "typescript": "~4.6.3",
"wait-on": "~6.0.1" "wait-on": "~6.0.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"@cypress/schematic": "^1.6.0", "@cypress/schematic": "^2.0.0",
"cypress": "~9.6.0" "cypress": "~10.3.0"
} }
} }

View File

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

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 { Component, OnDestroy, OnInit } from '@angular/core'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
@ -6,6 +7,7 @@ import { ConsumerStatusService } from './services/consumer-status.service'
import { ToastService } from './services/toast.service' import { ToastService } from './services/toast.service'
import { NgxFileDropEntry } from 'ngx-file-drop' import { NgxFileDropEntry } from 'ngx-file-drop'
import { UploadDocumentsService } from './services/upload-documents.service' import { UploadDocumentsService } from './services/upload-documents.service'
import { TasksService } from './services/tasks.service'
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@ -26,7 +28,8 @@ export class AppComponent implements OnInit, OnDestroy {
private consumerStatusService: ConsumerStatusService, private consumerStatusService: ConsumerStatusService,
private toastService: ToastService, private toastService: ToastService,
private router: Router, private router: Router,
private uploadDocumentsService: UploadDocumentsService private uploadDocumentsService: UploadDocumentsService,
private tasksService: TasksService
) { ) {
let anyWindow = window as any let anyWindow = window as any
anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js' anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js'
@ -64,6 +67,7 @@ export class AppComponent implements OnInit, OnDestroy {
this.successSubscription = this.consumerStatusService this.successSubscription = this.consumerStatusService
.onDocumentConsumptionFinished() .onDocumentConsumptionFinished()
.subscribe((status) => { .subscribe((status) => {
this.tasksService.reload()
if ( if (
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS) this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)
) { ) {
@ -82,6 +86,7 @@ export class AppComponent implements OnInit, OnDestroy {
this.failedSubscription = this.consumerStatusService this.failedSubscription = this.consumerStatusService
.onDocumentConsumptionFailed() .onDocumentConsumptionFailed()
.subscribe((status) => { .subscribe((status) => {
this.tasksService.reload()
if ( if (
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED) this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)
) { ) {
@ -94,6 +99,7 @@ export class AppComponent implements OnInit, OnDestroy {
this.newDocumentSubscription = this.consumerStatusService this.newDocumentSubscription = this.consumerStatusService
.onDocumentDetected() .onDocumentDetected()
.subscribe((status) => { .subscribe((status) => {
this.tasksService.reload()
if ( if (
this.showNotification( this.showNotification(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT

View File

@ -1,5 +1,5 @@
import { BrowserModule } from '@angular/platform-browser' 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 { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component' import { AppComponent } from './app.component'
import { import {
@ -61,7 +61,7 @@ import { SafeUrlPipe } from './pipes/safeurl.pipe'
import { SafeHtmlPipe } from './pipes/safehtml.pipe' import { SafeHtmlPipe } from './pipes/safehtml.pipe'
import { CustomDatePipe } from './pipes/custom-date.pipe' import { CustomDatePipe } from './pipes/custom-date.pipe'
import { DateComponent } from './components/common/input/date/date.component' import { DateComponent } from './components/common/input/date/date.component'
import { ISODateTimeAdapter } from './utils/ngb-iso-date-time-adapter' import { ISODateAdapter } from './utils/ngb-iso-date-adapter'
import { LocalizedDateParserFormatter } from './utils/ngb-date-parser-formatter' import { LocalizedDateParserFormatter } from './utils/ngb-date-parser-formatter'
import { ApiVersionInterceptor } from './interceptors/api-version.interceptor' import { ApiVersionInterceptor } from './interceptors/api-version.interceptor'
import { ColorSliderModule } from 'ngx-color/slider' import { ColorSliderModule } from 'ngx-color/slider'
@ -87,6 +87,10 @@ import localeSr from '@angular/common/locales/sr'
import localeSv from '@angular/common/locales/sv' import localeSv from '@angular/common/locales/sv'
import localeTr from '@angular/common/locales/tr' import localeTr from '@angular/common/locales/tr'
import localeZh from '@angular/common/locales/zh' 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'
import { TasksComponent } from './components/manage/tasks/tasks.component'
registerLocaleData(localeBe) registerLocaleData(localeBe)
registerLocaleData(localeCs) registerLocaleData(localeCs)
@ -109,6 +113,12 @@ registerLocaleData(localeSv)
registerLocaleData(localeTr) registerLocaleData(localeTr)
registerLocaleData(localeZh) registerLocaleData(localeZh)
function initializeApp(settings: SettingsService) {
return () => {
return settings.initializeSettings()
}
}
@NgModule({ @NgModule({
declarations: [ declarations: [
AppComponent, AppComponent,
@ -118,6 +128,7 @@ registerLocaleData(localeZh)
TagListComponent, TagListComponent,
DocumentTypeListComponent, DocumentTypeListComponent,
CorrespondentListComponent, CorrespondentListComponent,
StoragePathListComponent,
LogsComponent, LogsComponent,
SettingsComponent, SettingsComponent,
NotFoundComponent, NotFoundComponent,
@ -125,6 +136,7 @@ registerLocaleData(localeZh)
ConfirmDialogComponent, ConfirmDialogComponent,
TagEditDialogComponent, TagEditDialogComponent,
DocumentTypeEditDialogComponent, DocumentTypeEditDialogComponent,
StoragePathEditDialogComponent,
TagComponent, TagComponent,
PageHeaderComponent, PageHeaderComponent,
AppFrameComponent, AppFrameComponent,
@ -160,6 +172,7 @@ registerLocaleData(localeZh)
DateComponent, DateComponent,
ColorComponent, ColorComponent,
DocumentAsnComponent, DocumentAsnComponent,
TasksComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,
@ -174,6 +187,12 @@ registerLocaleData(localeZh)
ColorSliderModule, ColorSliderModule,
], ],
providers: [ providers: [
{
provide: APP_INITIALIZER,
useFactory: initializeApp,
deps: [SettingsService],
multi: true,
},
DatePipe, DatePipe,
CookieService, CookieService,
{ {
@ -188,7 +207,7 @@ registerLocaleData(localeZh)
}, },
FilterPipe, FilterPipe,
DocumentTitlePipe, DocumentTitlePipe,
{ provide: NgbDateAdapter, useClass: ISODateTimeAdapter }, { provide: NgbDateAdapter, useClass: ISODateAdapter },
{ provide: NgbDateParserFormatter, useClass: LocalizedDateParserFormatter }, { provide: NgbDateParserFormatter, useClass: LocalizedDateParserFormatter },
], ],
bootstrap: [AppComponent], bootstrap: [AppComponent],

View File

@ -21,17 +21,17 @@
</div> </div>
<ul ngbNav class="order-sm-3"> <ul ngbNav class="order-sm-3">
<li ngbDropdown class="nav-item dropdown"> <li ngbDropdown class="nav-item dropdown">
<button class="btn text-light" id="userDropdown" ngbDropdownToggle> <button class="btn" id="userDropdown" ngbDropdownToggle>
<span *ngIf="displayName" class="navbar-text small me-2 text-light d-none d-sm-inline"> <span class="small me-2 d-none d-sm-inline">
{{displayName}} {{this.settingsService.displayName}}
</span> </span>
<svg width="1.3em" height="1.3em" fill="currentColor"> <svg width="1.3em" height="1.3em" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#person-circle"/> <use xlink:href="assets/bootstrap-icons.svg#person-circle"/>
</svg> </svg>
</button> </button>
<div ngbDropdownMenu class="dropdown-menu-end shadow me-2" aria-labelledby="userDropdown"> <div ngbDropdownMenu class="dropdown-menu-end shadow me-2" aria-labelledby="userDropdown">
<div *ngIf="displayName" class="d-sm-none"> <div class="d-sm-none">
<p class="small mb-0 px-3 text-muted" i18n>Logged in as {{displayName}}</p> <p class="small mb-0 px-3 text-muted" i18n>Logged in as {{this.settingsService.displayName}}</p>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
</div> </div>
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()"> <a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()">
@ -70,8 +70,9 @@
</li> </li>
</ul> </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> <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> </h6>
<ul class="nav flex-column mb-2"> <ul class="nav flex-column mb-2">
<li class="nav-item w-100" *ngFor="let view of savedViewService.sidebarViews"> <li class="nav-item w-100" *ngFor="let view of savedViewService.sidebarViews">
@ -133,6 +134,20 @@
</svg>&nbsp;<ng-container i18n>Document types</ng-container> </svg>&nbsp;<ng-container i18n>Document types</ng-container>
</a> </a>
</li> </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="tasks" routerLinkActive="active" (click)="closeMenu()">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#list-task"/>
</svg>&nbsp;<ng-container i18n>File Tasks<ng-container *ngIf="tasksService.failedFileTasks.length > 0"><span class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span></ng-container></ng-container>
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
@ -187,7 +202,7 @@
<div class="me-3">{{ versionString }}</div> <div class="me-3">{{ versionString }}</div>
<div *ngIf="appRemoteVersion" class="version-check"> <div *ngIf="appRemoteVersion" class="version-check">
<ng-template #updateAvailablePopContent> <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>
<ng-template #updateCheckingNotEnabledPopContent> <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> <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 */ z-index: 995; /* Behind the navbar */
padding: 50px 0 0; /* Height of navbar */ padding: 50px 0 0; /* Height of navbar */
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1); 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) { @media (max-width: 767.98px) {
.sidebar { .sidebar {

View File

@ -1,7 +1,7 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { FormControl } from '@angular/forms' import { FormControl } from '@angular/forms'
import { ActivatedRoute, Router, Params } from '@angular/router' import { ActivatedRoute, Router, Params } from '@angular/router'
import { from, Observable, Subscription, BehaviorSubject } from 'rxjs' import { from, Observable } from 'rxjs'
import { import {
debounceTime, debounceTime,
distinctUntilChanged, distinctUntilChanged,
@ -15,14 +15,14 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SearchService } from 'src/app/services/rest/search.service' import { SearchService } from 'src/app/services/rest/search.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { DocumentDetailComponent } from '../document-detail/document-detail.component' import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { Meta } from '@angular/platform-browser'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type' import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
import { import {
RemoteVersionService, RemoteVersionService,
AppRemoteVersion, AppRemoteVersion,
} from 'src/app/services/rest/remote-version.service' } 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'
import { TasksService } from 'src/app/services/tasks.service'
@Component({ @Component({
selector: 'app-app-frame', selector: 'app-app-frame',
@ -36,16 +36,17 @@ export class AppFrameComponent {
private openDocumentsService: OpenDocumentsService, private openDocumentsService: OpenDocumentsService,
private searchService: SearchService, private searchService: SearchService,
public savedViewService: SavedViewService, public savedViewService: SavedViewService,
private list: DocumentListViewService,
private meta: Meta,
private remoteVersionService: RemoteVersionService, private remoteVersionService: RemoteVersionService,
private queryParamsService: QueryParamsService private list: DocumentListViewService,
public settingsService: SettingsService,
public tasksService: TasksService
) { ) {
this.remoteVersionService this.remoteVersionService
.checkForUpdates() .checkForUpdates()
.subscribe((appRemoteVersion: AppRemoteVersion) => { .subscribe((appRemoteVersion: AppRemoteVersion) => {
this.appRemoteVersion = appRemoteVersion this.appRemoteVersion = appRemoteVersion
}) })
tasksService.reload()
} }
versionString = `${environment.appTitle} ${environment.version}` versionString = `${environment.appTitle} ${environment.version}`
@ -94,7 +95,7 @@ export class AppFrameComponent {
search() { search() {
this.closeMenu() this.closeMenu()
this.queryParamsService.navigateWithFilterRules([ this.list.quickFilter([
{ {
rule_type: FILTER_FULLTEXT_QUERY, rule_type: FILTER_FULLTEXT_QUERY,
value: (this.searchField.value as string).trim(), value: (this.searchField.value as string).trim(),
@ -143,17 +144,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>
<div class="modal-body"> <div class="modal-body">
<p *ngIf="messageBold"><b>{{messageBold}}</b></p> <p *ngIf="messageBold"><b>{{messageBold}}</b></p>
<p *ngIf="message">{{message}}</p> <p class="mb-0" *ngIf="message" [innerHTML]="message | safeHtml"></p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" [disabled]="!buttonsEnabled" i18n> <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

@ -16,13 +16,11 @@
<div class="dropdown-menu py-0 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}"> <div class="dropdown-menu py-0 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
<div *ngIf="!editing && multiple" class="list-group-item d-flex"> <div *ngIf="!editing && multiple" class="list-group-item d-flex">
<div class="btn-group btn-group-xs btn-group-toggle flex-fill" ngbRadioGroup [(ngModel)]="selectionModel.logicalOperator" (change)="selectionModel.toggleOperator()" [disabled]="!operatorToggleEnabled"> <div class="btn-group btn-group-xs flex-fill">
<label ngbButtonLabel class="btn btn-outline-primary"> <input [(ngModel)]="selectionModel.logicalOperator" [disabled]="!operatorToggleEnabled" (ngModelChange)="selectionModel.toggleOperator()" type="radio" class="btn-check" id="logicalOperatorAnd" value="and">
<input ngbButton type="radio" class="btn-check" name="logicalOperator" value="and" i18n> All <label class="btn btn-outline-primary" for="logicalOperatorAnd" i18n>All</label>
</label> <input [(ngModel)]="selectionModel.logicalOperator" [disabled]="!operatorToggleEnabled" (ngModelChange)="selectionModel.toggleOperator()" type="radio" class="btn-check" id="logicalOperatorOr" value="or">
<label ngbButtonLabel class="btn btn-outline-primary"> <label class="btn btn-outline-primary" for="logicalOperatorOr" i18n>Any</label>
<input ngbButton type="radio" class="btn-check" name="logicalOperator" value="or" i18n> Any
</label>
</div> </div>
</div> </div>
<div class="list-group-item"> <div class="list-group-item">

View File

@ -2,7 +2,7 @@
<label class="form-label" [for]="inputId">{{title}}</label> <label class="form-label" [for]="inputId">{{title}}</label>
<div class="input-group" [class.is-invalid]="error"> <div class="input-group" [class.is-invalid]="error">
<input class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10" <input class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
(dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel"> name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel">
<button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button"> <button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16">

View File

@ -1,6 +1,8 @@
import { Component, forwardRef, OnInit } from '@angular/core' import { Component, forwardRef, OnInit } from '@angular/core'
import { NG_VALUE_ACCESSOR } from '@angular/forms' import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { NgbDateParserFormatter } from '@ng-bootstrap/ng-bootstrap'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { LocalizedDateParserFormatter } from 'src/app/utils/ngb-date-parser-formatter'
import { AbstractInputComponent } from '../abstract-input' import { AbstractInputComponent } from '../abstract-input'
@Component({ @Component({
@ -19,7 +21,10 @@ export class DateComponent
extends AbstractInputComponent<string> extends AbstractInputComponent<string>
implements OnInit implements OnInit
{ {
constructor(private settings: SettingsService) { constructor(
private settings: SettingsService,
private ngbDateParserFormatter: NgbDateParserFormatter
) {
super() super()
} }
@ -30,7 +35,20 @@ export class DateComponent
placeholder: string placeholder: string
// prevent chars other than numbers and separators onPaste(event: ClipboardEvent) {
const clipboardData: DataTransfer =
event.clipboardData || window['clipboardData']
if (clipboardData) {
event.preventDefault()
let pastedText = clipboardData.getData('text')
pastedText = pastedText.replace(/[\sa-z#!$%\^&\*;:{}=\-_`~()]+/g, '')
const parsedDate = this.ngbDateParserFormatter.parse(pastedText)
const formattedDate = this.ngbDateParserFormatter.format(parsedDate)
this.writeValue(formattedDate)
this.onChange(formattedDate)
}
}
onKeyPress(event: KeyboardEvent) { onKeyPress(event: KeyboardEvent) {
if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) { if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) {
event.preventDefault() event.preventDefault()

View File

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

View File

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

View File

@ -1,7 +1,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label" [for]="inputId">{{title}}</label> <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)"> <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"> <div class="invalid-feedback">
{{error}} {{error}}
</div> </div>

View File

@ -1,3 +1,6 @@
a { a {
cursor: pointer; cursor: pointer;
white-space: normal;
word-break: break-word;
text-align: end;
} }

View File

@ -4,5 +4,5 @@
[class]="toast.classname" [class]="toast.classname"
(hidden)="toastService.closeToast(toast)"> (hidden)="toastService.closeToast(toast)">
<p>{{toast.content}}</p> <p>{{toast.content}}</p>
<p *ngIf="toast.action"><button class="btn btn-sm btn-outline-secondary" (click)="toastService.closeToast(toast); toast.action()">{{toast.actionName}}</button></p> <p class="mb-0" *ngIf="toast.action"><button class="btn btn-sm btn-outline-secondary" (click)="toastService.closeToast(toast); toast.action()">{{toast.actionName}}</button></p>
</ngb-toast> </ngb-toast>

View File

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

View File

@ -21,9 +21,14 @@
<div class='row'> <div class='row'>
<div class="col-lg-8"> <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> <app-saved-view-widget [savedView]="v"></app-saved-view-widget>
</ng-container> </ng-container>

View File

@ -1,43 +1,24 @@
import { Component, OnInit } from '@angular/core' import { Component, OnInit } from '@angular/core'
import { Meta } from '@angular/platform-browser' 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 { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
@Component({ @Component({
selector: 'app-dashboard', selector: 'app-dashboard',
templateUrl: './dashboard.component.html', templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss'], styleUrls: ['./dashboard.component.scss'],
}) })
export class DashboardComponent implements OnInit { export class DashboardComponent {
constructor(private savedViewService: SavedViewService, private meta: Meta) {} constructor(
public savedViewService: SavedViewService,
get displayName() { public settingsService: SettingsService
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
}
}
get subtitle() { get subtitle() {
if (this.displayName) { if (this.settingsService.displayName) {
return $localize`Hello ${this.displayName}, welcome to Paperless-ngx!` return $localize`Hello ${this.settingsService.displayName}, welcome to Paperless-ngx!`
} else { } else {
return $localize`Welcome to Paperless-ngx!` 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> <a class="btn-link" header-buttons [routerLink]="[]" (click)="showAll()" i18n>Show all</a>
@ -11,8 +11,8 @@
</tr> </tr>
</thead> </thead>
<tbody> <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.created_date | customDate}}</td>
<td>{{doc.title | documentTitle}}<app-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t); $event.stopPropagation();"></app-tag></td> <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> </tr>
</tbody> </tbody>

View File

@ -7,7 +7,8 @@ import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { PaperlessTag } from 'src/app/data/paperless-tag' import { PaperlessTag } from 'src/app/data/paperless-tag'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type' 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'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
@Component({ @Component({
selector: 'app-saved-view-widget', selector: 'app-saved-view-widget',
@ -15,11 +16,14 @@ import { QueryParamsService } from 'src/app/services/query-params.service'
styleUrls: ['./saved-view-widget.component.scss'], styleUrls: ['./saved-view-widget.component.scss'],
}) })
export class SavedViewWidgetComponent implements OnInit, OnDestroy { export class SavedViewWidgetComponent implements OnInit, OnDestroy {
loading: boolean = true
constructor( constructor(
private documentService: DocumentService, private documentService: DocumentService,
private router: Router, private router: Router,
private queryParamsService: QueryParamsService, private list: DocumentListViewService,
private consumerStatusService: ConsumerStatusService private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService
) {} ) {}
@Input() @Input()
@ -43,6 +47,7 @@ export class SavedViewWidgetComponent implements OnInit, OnDestroy {
} }
reload() { reload() {
this.loading = this.documents.length == 0
this.documentService this.documentService
.listFiltered( .listFiltered(
1, 1,
@ -52,6 +57,7 @@ export class SavedViewWidgetComponent implements OnInit, OnDestroy {
this.savedView.filter_rules this.savedView.filter_rules
) )
.subscribe((result) => { .subscribe((result) => {
this.loading = false
this.documents = result.results this.documents = result.results
}) })
} }
@ -67,7 +73,7 @@ export class SavedViewWidgetComponent implements OnInit, OnDestroy {
} }
clickTag(tag: PaperlessTag) { clickTag(tag: PaperlessTag) {
this.queryParamsService.navigateWithFilterRules([ this.list.quickFilter([
{ rule_type: FILTER_HAS_TAGS_ALL, value: tag.id.toString() }, { rule_type: FILTER_HAS_TAGS_ALL, value: tag.id.toString() },
]) ])
} }

View File

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

View File

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

View File

@ -2,6 +2,10 @@
<div class="card-header"> <div class="card-header">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">{{title}}</h5> <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> <ng-content select ="[header-buttons]"></ng-content>
</div> </div>

View File

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

View File

@ -8,7 +8,7 @@
<button type="button" class="btn btn-sm btn-outline-danger me-2 ms-auto" (click)="delete()"> <button type="button" class="btn btn-sm btn-outline-danger me-2 ms-auto" (click)="delete()">
<svg class="buttonicon" fill="currentColor"> <svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" /> <use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg>&nbsp;<span class="d-none d-lg-inline" i18n>Delete</span> </svg><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
</button> </button>
<div class="btn-group me-2"> <div class="btn-group me-2">
@ -16,7 +16,7 @@
<a [href]="downloadUrl" class="btn btn-sm btn-outline-primary"> <a [href]="downloadUrl" class="btn btn-sm btn-outline-primary">
<svg class="buttonicon" fill="currentColor"> <svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#download" /> <use xlink:href="assets/bootstrap-icons.svg#download" />
</svg>&nbsp;<span class="d-none d-lg-inline" i18n>Download</span> </svg><span class="d-none d-lg-inline ps-1" i18n>Download</span>
</a> </a>
<div class="btn-group" ngbDropdown role="group" *ngIf="metadata?.has_archive_version"> <div class="btn-group" ngbDropdown role="group" *ngIf="metadata?.has_archive_version">
@ -28,10 +28,16 @@
</div> </div>
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="redoOcr()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-counterclockwise" />
</svg><span class="d-none d-lg-inline ps-1" i18n>Redo OCR</span>
</button>
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="moreLike()"> <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="moreLike()">
<svg class="buttonicon" fill="currentColor"> <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> </svg><span class="d-none d-lg-inline ps-1" i18n>More like this</span>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-primary me-2" i18n-title title="Close" (click)="close()"> <button type="button" class="btn btn-sm btn-outline-primary me-2" i18n-title title="Close" (click)="close()">
@ -68,11 +74,13 @@
<app-input-text #inputTitle i18n-title title="Title" formControlName="title" (keyup)="titleKeyUp($event)" [error]="error?.title"></app-input-text> <app-input-text #inputTitle i18n-title title="Title" formControlName="title" (keyup)="titleKeyUp($event)" [error]="error?.title"></app-input-text>
<app-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" formControlName='archive_serial_number'></app-input-number> <app-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" formControlName='archive_serial_number'></app-input-number>
<app-input-date i18n-title title="Date created" formControlName="created" [error]="error?.created"></app-input-date> <app-input-date i18n-title title="Date created" formControlName="created_date" [error]="error?.created_date"></app-input-date>
<app-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" <app-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true"
(createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents"></app-input-select> (createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents"></app-input-select>
<app-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" <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> (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> <app-input-tags formControlName="tags" [suggestions]="suggestions?.tags"></app-input-tags>
</ng-template> </ng-template>

View File

@ -14,10 +14,14 @@
} }
::ng-deep .ng2-pdf-viewer-container .page { ::ng-deep .ng2-pdf-viewer-container .page {
--page-margin: 1px 0 -8px; --page-margin: 1px 0 10px;
width: 100% !important; width: 100% !important;
} }
::ng-deep .ng2-pdf-viewer-container .page:last-child {
--page-margin: 1px 0 20px;
}
.password-prompt { .password-prompt {
position: absolute; position: absolute;
top: 30%; top: 30%;

View File

@ -18,10 +18,7 @@ import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-
import { PDFDocumentProxy } from 'ng2-pdf-viewer' import { PDFDocumentProxy } from 'ng2-pdf-viewer'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { TextComponent } from '../common/input/text/text.component' import { TextComponent } from '../common/input/text/text.component'
import { import { SettingsService } from 'src/app/services/settings.service'
SettingsService,
SETTINGS_KEYS,
} from 'src/app/services/settings.service'
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms' import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
import { Observable, Subject, BehaviorSubject } from 'rxjs' import { Observable, Subject, BehaviorSubject } from 'rxjs'
import { import {
@ -34,8 +31,10 @@ import {
} from 'rxjs/operators' } from 'rxjs/operators'
import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions' import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions'
import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type' import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type'
import { normalizeDateStr } from 'src/app/utils/date' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { QueryParamsService } from 'src/app/services/query-params.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({ @Component({
selector: 'app-document-detail', selector: 'app-document-detail',
@ -68,13 +67,15 @@ export class DocumentDetailComponent
correspondents: PaperlessCorrespondent[] correspondents: PaperlessCorrespondent[]
documentTypes: PaperlessDocumentType[] documentTypes: PaperlessDocumentType[]
storagePaths: PaperlessStoragePath[]
documentForm: FormGroup = new FormGroup({ documentForm: FormGroup = new FormGroup({
title: new FormControl(''), title: new FormControl(''),
content: new FormControl(''), content: new FormControl(''),
created: new FormControl(), created_date: new FormControl(),
correspondent: new FormControl(), correspondent: new FormControl(),
document_type: new FormControl(), document_type: new FormControl(),
storage_path: new FormControl(),
archive_serial_number: new FormControl(), archive_serial_number: new FormControl(),
tags: new FormControl([]), tags: new FormControl([]),
}) })
@ -85,6 +86,7 @@ export class DocumentDetailComponent
store: BehaviorSubject<any> store: BehaviorSubject<any>
isDirty$: Observable<boolean> isDirty$: Observable<boolean>
unsubscribeNotifier: Subject<any> = new Subject() unsubscribeNotifier: Subject<any> = new Subject()
docChangeNotifier: Subject<any> = new Subject()
requiresPassword: boolean = false requiresPassword: boolean = false
password: string password: string
@ -116,19 +118,8 @@ export class DocumentDetailComponent
private documentTitlePipe: DocumentTitlePipe, private documentTitlePipe: DocumentTitlePipe,
private toastService: ToastService, private toastService: ToastService,
private settings: SettingsService, private settings: SettingsService,
private queryParamsService: QueryParamsService private storagePathService: StoragePathService
) { ) {}
this.titleSubject
.pipe(
debounceTime(1000),
distinctUntilChanged(),
takeUntil(this.unsubscribeNotifier)
)
.subscribe((titleValue) => {
this.title = titleValue
this.documentForm.patchValue({ title: titleValue })
})
}
titleKeyUp(event) { titleKeyUp(event) {
this.titleSubject.next(event.target?.value) this.titleSubject.next(event.target?.value)
@ -147,27 +138,8 @@ export class DocumentDetailComponent
ngOnInit(): void { ngOnInit(): void {
this.documentForm.valueChanges this.documentForm.valueChanges
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((changes) => { .subscribe(() => {
this.error = null this.error = null
if (this.ogDate) {
try {
let newDate = new Date(normalizeDateStr(changes['created']))
newDate.setHours(
this.ogDate.getHours(),
this.ogDate.getMinutes(),
this.ogDate.getSeconds(),
this.ogDate.getMilliseconds()
)
this.documentForm.patchValue(
{ created: newDate.toISOString() },
{ emitEvent: false }
)
} catch (e) {
// catch this before we try to save and simulate an api error
this.error = { created: e.message }
}
}
Object.assign(this.document, this.documentForm.value) Object.assign(this.document, this.documentForm.value)
}) })
@ -175,15 +147,23 @@ export class DocumentDetailComponent
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe((result) => (this.correspondents = result.results)) .subscribe((result) => (this.correspondents = result.results))
this.documentTypeService this.documentTypeService
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe((result) => (this.documentTypes = result.results)) .subscribe((result) => (this.documentTypes = result.results))
this.storagePathService
.listAll()
.pipe(first())
.subscribe((result) => (this.storagePaths = result.results))
this.route.paramMap this.route.paramMap
.pipe( .pipe(
takeUntil(this.unsubscribeNotifier),
switchMap((paramMap) => { switchMap((paramMap) => {
const documentId = +paramMap.get('id') const documentId = +paramMap.get('id')
this.docChangeNotifier.next(documentId)
return this.documentsService.get(documentId) return this.documentsService.get(documentId)
}) })
) )
@ -204,29 +184,45 @@ export class DocumentDetailComponent
this.openDocumentService.getOpenDocument(this.documentId) this.openDocumentService.getOpenDocument(this.documentId)
) )
} else { } else {
this.openDocumentService.openDocument(doc) this.openDocumentService.openDocument(doc, false)
this.updateComponent(doc) this.updateComponent(doc)
} }
this.ogDate = new Date(normalizeDateStr(doc.created.toString())) 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)
}
},
})
// Initialize dirtyCheck // Initialize dirtyCheck
this.store = new BehaviorSubject({ this.store = new BehaviorSubject({
title: doc.title, title: doc.title,
content: doc.content, content: doc.content,
created: this.ogDate.toISOString(), created_date: doc.created_date,
correspondent: doc.correspondent, correspondent: doc.correspondent,
document_type: doc.document_type, document_type: doc.document_type,
storage_path: doc.storage_path,
archive_serial_number: doc.archive_serial_number, archive_serial_number: doc.archive_serial_number,
tags: [...doc.tags], tags: [...doc.tags],
}) })
// start with ISO8601 string
this.documentForm.patchValue(
{ created: this.ogDate.toISOString() },
{ emitEvent: false }
)
this.isDirty$ = dirtyCheck( this.isDirty$ = dirtyCheck(
this.documentForm, this.documentForm,
this.store.asObservable() this.store.asObservable()
@ -235,7 +231,6 @@ export class DocumentDetailComponent
return this.isDirty$.pipe(map((dirty) => ({ doc, dirty }))) return this.isDirty$.pipe(map((dirty) => ({ doc, dirty })))
}) })
) )
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: ({ doc, dirty }) => { next: ({ doc, dirty }) => {
this.openDocumentService.setDirty(doc.id, dirty) this.openDocumentService.setDirty(doc.id, dirty)
@ -324,6 +319,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() { discard() {
this.documentsService this.documentsService
.get(this.documentId) .get(this.documentId)
@ -448,7 +464,7 @@ export class DocumentDetailComponent
} }
moreLike() { moreLike() {
this.queryParamsService.navigateWithFilterRules([ this.documentListViewService.quickFilter([
{ {
rule_type: FILTER_FULLTEXT_MORELIKE, rule_type: FILTER_FULLTEXT_MORELIKE,
value: this.documentId.toString(), value: this.documentId.toString(),
@ -456,6 +472,42 @@ export class DocumentDetailComponent
]) ])
} }
redoOcr() {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Redo OCR confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently redo OCR for this document.`
modal.componentInstance.message = $localize`This operation cannot be undone.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.documentsService
.bulkEdit([this.document.id], 'redo_ocr', {})
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Redo OCR operation will begin in the background.`
)
if (modal) {
modal.close()
}
},
error: (error) => {
if (modal) {
modal.componentInstance.buttonsEnabled = true
}
this.toastService.showError(
$localize`Error executing operation: ${JSON.stringify(
error.error
)}`
)
},
})
})
}
hasNext() { hasNext() {
return this.documentListViewService.hasNext(this.documentId) return this.documentListViewService.hasNext(this.documentId)
} }

View File

@ -53,27 +53,43 @@
[(selectionModel)]="documentTypeSelectionModel" [(selectionModel)]="documentTypeSelectionModel"
(apply)="setDocumentTypes($event)"> (apply)="setDocumentTypes($event)">
</app-filterable-dropdown> </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> </div>
<div class="col-auto ms-auto mb-2 mb-xl-0 d-flex"> <div class="col-auto ms-auto mb-2 mb-xl-0 d-flex">
<div class="btn-group btn-group-sm me-2"> <div class="btn-group btn-group-sm me-2">
<button type="button" [disabled]="awaitingDownload" class="btn btn-outline-primary btn-sm" (click)="downloadSelected()">
<svg *ngIf="!awaitingDownload" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor"> <div ngbDropdown class="me-2 d-flex">
<use xlink:href="assets/bootstrap-icons.svg#download" /> <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
</svg> <svg class="toolbaricon" fill="currentColor">
<div *ngIf="awaitingDownload" class="spinner-border spinner-border-sm" role="status"> <use xlink:href="assets/bootstrap-icons.svg#three-dots" />
<span class="visually-hidden">Preparing download...</span> </svg>
</div> <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
&nbsp; </button>
<ng-container i18n>Download</ng-container> <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
</button> <button ngbDropdownItem [disabled]="awaitingDownload" (click)="downloadSelected()" i18n>
<div class="btn-group" ngbDropdown role="group" aria-label="Button group with nested dropdown"> Download
<button [disabled]="awaitingDownload" class="btn btn-outline-primary btn-sm dropdown-toggle-split" ngbDropdownToggle></button> <div *ngIf="awaitingDownload" class="spinner-border spinner-border-sm" role="status">
<div class="dropdown-menu shadow" ngbDropdownMenu> <span class="visually-hidden">Preparing download...</span>
<button ngbDropdownItem i18n (click)="downloadSelected('originals')">Download originals</button> </div>
</button>
<button ngbDropdownItem [disabled]="awaitingDownload" (click)="downloadSelected('originals')" i18n>
Download originals
<div *ngIf="awaitingDownload" class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Preparing download...</span>
</div>
</button>
<button ngbDropdownItem (click)="redoOcrSelected()" i18n>Redo OCR</button>
</div> </div>
</div> </div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()"> <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor"> <svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">

View File

@ -19,12 +19,12 @@ import {
} from '../../common/filterable-dropdown/filterable-dropdown.component' } from '../../common/filterable-dropdown/filterable-dropdown.component'
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component' import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { import { SettingsService } from 'src/app/services/settings.service'
SettingsService,
SETTINGS_KEYS,
} from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { saveAs } from 'file-saver' 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({ @Component({
selector: 'app-bulk-editor', selector: 'app-bulk-editor',
@ -35,10 +35,12 @@ export class BulkEditorComponent {
tags: PaperlessTag[] tags: PaperlessTag[]
correspondents: PaperlessCorrespondent[] correspondents: PaperlessCorrespondent[]
documentTypes: PaperlessDocumentType[] documentTypes: PaperlessDocumentType[]
storagePaths: PaperlessStoragePath[]
tagSelectionModel = new FilterableDropdownSelectionModel() tagSelectionModel = new FilterableDropdownSelectionModel()
correspondentSelectionModel = new FilterableDropdownSelectionModel() correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
awaitingDownload: boolean awaitingDownload: boolean
constructor( constructor(
@ -50,7 +52,8 @@ export class BulkEditorComponent {
private modalService: NgbModal, private modalService: NgbModal,
private openDocumentService: OpenDocumentsService, private openDocumentService: OpenDocumentsService,
private settings: SettingsService, private settings: SettingsService,
private toastService: ToastService private toastService: ToastService,
private storagePathService: StoragePathService
) {} ) {}
applyOnClose: boolean = this.settings.get( applyOnClose: boolean = this.settings.get(
@ -70,6 +73,9 @@ export class BulkEditorComponent {
this.documentTypeService this.documentTypeService
.listAll() .listAll()
.subscribe((result) => (this.documentTypes = result.results)) .subscribe((result) => (this.documentTypes = result.results))
this.storagePathService
.listAll()
.subscribe((result) => (this.storagePaths = result.results))
} }
private executeBulkOperation(modal, method: string, args) { 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[]) { private _localizeList(items: MatchingModel[]) {
if (items.length == 0) { if (items.length == 0) {
return '' 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() { applyDelete() {
let modal = this.modalService.open(ConfirmDialogComponent, { let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static', backdrop: 'static',
@ -326,4 +379,19 @@ export class BulkEditorComponent {
this.awaitingDownload = false this.awaitingDownload = false
}) })
} }
redoOcrSelected() {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Redo OCR confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently redo OCR for ${this.list.selected.size} selected document(s).`
modal.componentInstance.message = $localize`This operation cannot be undone.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.executeBulkOperation(modal, 'redo_ocr', {})
})
}
} }

View File

@ -33,56 +33,67 @@
<div class="d-flex flex-column flex-md-row align-items-md-center"> <div class="d-flex flex-column flex-md-row align-items-md-center">
<div class="btn-group"> <div class="btn-group">
<a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()"> <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"> <svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<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"/> <use xlink:href="assets/bootstrap-icons.svg#diagram-3"/>
</svg>&nbsp;<span class="d-block d-md-inline" i18n>More like this</span> </svg>&nbsp;<span class="d-none d-md-inline" i18n>More like this</span>
</a> </a>
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary"> <a (click)="openDocumentsService.openDocument(document)" 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"> <svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<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"/> <use xlink:href="assets/bootstrap-icons.svg#pencil"/>
</svg>&nbsp;<span class="d-block d-md-inline" i18n>Edit</span> </svg>&nbsp;<span class="d-none d-md-inline" i18n>Edit</span>
</a> </a>
<a class="btn btn-sm btn-outline-secondary" target="_blank" [href]="previewUrl" <a class="btn btn-sm btn-outline-secondary" target="_blank" [href]="previewUrl"
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle" [ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle"
autoClose="true" popoverClass="shadow" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover"> 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"> <svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<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"/> <use xlink:href="assets/bootstrap-icons.svg#eye"/>
<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-none d-md-inline" i18n>View</span>
</svg>&nbsp;<span class="d-block d-md-inline" i18n>View</span>
</a> </a>
<ng-template #previewContent> <ng-template #previewContent>
<object [data]="previewUrl | safeUrl" class="preview" width="100%"></object> <object [data]="previewUrl | safeUrl" class="preview" width="100%"></object>
</ng-template> </ng-template>
<a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()"> <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"> <svg class="sidebaricon" fill="currentColor" class="sidebaricon">
<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"/> <use xlink:href="assets/bootstrap-icons.svg#download"/>
<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-none d-md-inline" i18n>Download</span>
</svg>&nbsp;<span class="d-block d-md-inline" i18n>Download</span>
</a> </a>
</div> </div>
<div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0"> <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()"> (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"> <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"/> <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> </svg>
<small>{{(document.document_type$ | async)?.name}}</small> <small>{{(document.document_type$ | async)?.name}}</small>
</button> </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"> <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"> <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"/> <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> </svg>
<small>#{{document.archive_serial_number}}</small> <small>#{{document.archive_serial_number}}</small>
</div> </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"> <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="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"/> <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> </svg>
<small>{{document.created | customDate:'mediumDate'}}</small> <small>{{document.created_date | customDate:'mediumDate'}}</small>
</div> </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"> <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> <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> <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, Output,
ViewChild, ViewChild,
} from '@angular/core' } from '@angular/core'
import { DomSanitizer } from '@angular/platform-browser'
import { PaperlessDocument } from 'src/app/data/paperless-document' import { PaperlessDocument } from 'src/app/data/paperless-document'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { import { SettingsService } from 'src/app/services/settings.service'
SettingsService,
SETTINGS_KEYS,
} from 'src/app/services/settings.service'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type' import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
@Component({ @Component({
selector: 'app-document-card-large', 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 { export class DocumentCardLargeComponent implements OnInit {
constructor( constructor(
private documentService: DocumentService, private documentService: DocumentService,
private sanitizer: DomSanitizer, private settingsService: SettingsService,
private settingsService: SettingsService public openDocumentsService: OpenDocumentsService
) {} ) {}
@Input() @Input()
@ -54,6 +52,9 @@ export class DocumentCardLargeComponent implements OnInit {
@Output() @Output()
clickDocumentType = new EventEmitter<number>() clickDocumentType = new EventEmitter<number>()
@Output()
clickStoragePath = new EventEmitter<number>()
@Output() @Output()
clickMoreLike = new EventEmitter() clickMoreLike = new EventEmitter()

View File

@ -10,10 +10,8 @@
</div> </div>
</div> </div>
<div style="top: 0; right: 0; font-size: large" class="text-end position-absolute me-1"> <div class="tags d-flex flex-column text-end position-absolute me-1 fs-6">
<div *ngFor="let t of getTagsLimited$() | async"> <app-tag *ngFor="let t of getTagsLimited$() | async" [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></app-tag>
<app-tag [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Filter by tag" i18n-linkTitle></app-tag>
</div>
<div *ngIf="moreTags"> <div *ngIf="moreTags">
<span class="badge badge-secondary">+ {{moreTags}}</span> <span class="badge badge-secondary">+ {{moreTags}}</span>
</div> </div>
@ -23,35 +21,41 @@
<div class="card-body p-2"> <div class="card-body p-2">
<p class="card-text"> <p class="card-text">
<ng-container *ngIf="document.correspondent"> <ng-container *ngIf="document.correspondent">
<a title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name}}</a>: <a title="Toggle correspondent filter" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name}}</a>:
</ng-container> </ng-container>
{{document.title | documentTitle}} {{document.title | documentTitle}}
</p> </p>
</div> </div>
<div class="card-footer pt-0 pb-2 px-2"> <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"> <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="Toggle document type filter" i18n-title
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()"> (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"> <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"/> <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> </svg>
<small>{{(document.document_type$ | async)?.name}}</small> <small>{{(document.document_type$ | async)?.name}}</small>
</button> </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="Toggle storage path filter" 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"> <div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between">
<ng-template #dateTooltip> <ng-template #dateTooltip>
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<span i18n>Created: {{ document.created | customDate}}</span> <span i18n>Created: {{ document.created | customDate }}</span>
<span i18n>Added: {{ document.added | customDate}}</span> <span i18n>Added: {{ document.added | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate}}</span> <span i18n>Modified: {{ document.modified | customDate }}</span>
</div> </div>
</ng-template> </ng-template>
<div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip"> <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"> <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="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"/> <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> </svg>
<small>{{document.created | customDate:'mediumDate'}}</small> <small>{{document.created_date | customDate:'mediumDate'}}</small>
</div> </div>
<div *ngIf="document.archive_serial_number" class="ps-0 p-1"> <div *ngIf="document.archive_serial_number" class="ps-0 p-1">
<svg class="metadata-icon me-2 text-muted bi bi-upc-scan" viewBox="0 0 16 16" fill="currentColor"> <svg class="metadata-icon me-2 text-muted bi bi-upc-scan" viewBox="0 0 16 16" fill="currentColor">
@ -63,7 +67,7 @@
</div> </div>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="btn-group w-100"> <div class="btn-group w-100">
<a 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"> <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/> <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg> </svg>
@ -79,7 +83,7 @@
<ng-template #previewContent> <ng-template #previewContent>
<object [data]="previewUrl | safeUrl" class="preview" width="100%"></object> <object [data]="previewUrl | safeUrl" class="preview" width="100%"></object>
</ng-template> </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"> <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="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"/> <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

@ -78,3 +78,11 @@
a { a {
cursor: pointer; cursor: pointer;
} }
.tags {
top: 0;
right: 0;
max-width: 80%;
row-gap: .2rem;
line-height: 1;
}

View File

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

View File

@ -1,10 +1,11 @@
<app-page-header [title]="getTitle()"> <app-page-header [title]="getTitle()">
<div ngbDropdown class="me-2 flex-fill d-flex"> <div ngbDropdown class="me-2 d-flex">
<button class="btn btn-sm btn-outline-primary flex-fill" id="dropdownSelect" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#text-indent-left" /> <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> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button> <button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button>
@ -12,23 +13,21 @@
<button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button> <button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button>
</div> </div>
</div> </div>
<div class="btn-group flex-fill" role="group">
<div class="btn-group btn-group-toggle flex-fill" ngbRadioGroup [(ngModel)]="displayMode" <input type="radio" class="btn-check" [(ngModel)]="displayMode" value="details" (ngModelChange)="saveDisplayMode()" id="displayModeDetails">
(ngModelChange)="saveDisplayMode()"> <label for="displayModeDetails" class="btn btn-outline-primary btn-sm">
<label ngbButtonLabel class="btn-outline-primary btn-sm">
<input ngbButton type="radio" class="btn-check btn-sm" value="details">
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#list-ul" /> <use xlink:href="assets/bootstrap-icons.svg#list-ul" />
</svg> </svg>
</label> </label>
<label ngbButtonLabel class="btn-outline-primary btn-sm"> <input type="radio" class="btn-check" [(ngModel)]="displayMode" value="smallCards" (ngModelChange)="saveDisplayMode()" id="displayModeSmall">
<input ngbButton type="radio" class="btn-check btn-sm" value="smallCards"> <label for="displayModeSmall" class="btn btn-outline-primary btn-sm">
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#grid" /> <use xlink:href="assets/bootstrap-icons.svg#grid" />
</svg> </svg>
</label> </label>
<label ngbButtonLabel class="btn-outline-primary btn-sm"> <input type="radio" class="btn-check" [(ngModel)]="displayMode" value="largeCards" (ngModelChange)="saveDisplayMode()" id="displayModeLarge">
<input ngbButton type="radio" class="btn-check btn-sm" value="largeCards"> <label for="displayModeLarge" class="btn btn-outline-primary btn-sm">
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#hdd-stack" /> <use xlink:href="assets/bootstrap-icons.svg#hdd-stack" />
</svg> </svg>
@ -38,15 +37,15 @@
<div ngbDropdown class="btn-group ms-2 flex-fill"> <div ngbDropdown class="btn-group ms-2 flex-fill">
<button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle i18n>Sort</button> <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 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)]="listSort"> <div class="w-100 d-flex pb-2 mb-1 border-bottom">
<label ngbButtonLabel class="btn-outline-primary btn-sm mx-2 flex-fill"> <input type="radio" class="btn-check" [value]="false" [(ngModel)]="listSortReverse" id="listSortReverseFalse">
<input ngbButton type="radio" class="btn btn-check btn-sm" [value]="false"> <label class="btn btn-outline-primary btn-sm mx-2 flex-fill" for="listSortReverseFalse">
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#sort-alpha-down" /> <use xlink:href="assets/bootstrap-icons.svg#sort-alpha-down" />
</svg> </svg>
</label> </label>
<label ngbButtonLabel class="btn-outline-primary btn-sm me-2 flex-fill"> <input type="radio" class="btn-check" [value]="true" [(ngModel)]="listSortReverse" id="listSortReverseTrue">
<input ngbButton type="radio" class="btn btn-check btn-sm" [value]="true"> <label class="btn btn-outline-primary btn-sm me-2 flex-fill" for="listSortReverseTrue">
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#sort-alpha-up-alt" /> <use xlink:href="assets/bootstrap-icons.svg#sort-alpha-up-alt" />
</svg> </svg>
@ -75,7 +74,7 @@
</app-page-header> </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-filter-editor [hidden]="isBulkEditing" [(filterRules)]="list.filterRules" [unmodifiedFilterRules]="unmodifiedFilterRules" #filterEditor></app-filter-editor>
<app-bulk-editor [hidden]="!isBulkEditing"></app-bulk-editor> <app-bulk-editor [hidden]="!isBulkEditing"></app-bulk-editor>
</div> </div>
@ -92,7 +91,7 @@
<span i18n *ngIf="list.selected.size == 0">{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span>&nbsp;<span i18n *ngIf="isFiltered">(filtered)</span> <span i18n *ngIf="list.selected.size == 0">{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span>&nbsp;<span i18n *ngIf="isFiltered">(filtered)</span>
</ng-container> </ng-container>
</p> </p>
<ngb-pagination [pageSize]="list.currentPageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5" <ngb-pagination [pageSize]="list.currentPageSize" [collectionSize]="list.collectionSize" (pageChange)="setPage($event)" [page]="list.currentPage" [maxSize]="5"
[rotate]="true" aria-label="Default pagination"></ngb-pagination> [rotate]="true" aria-label="Default pagination"></ngb-pagination>
</div> </div>
</ng-template> </ng-template>
@ -106,7 +105,7 @@
<ng-template #documentListNoError> <ng-template #documentListNoError>
<div *ngIf="displayMode == 'largeCards'"> <div *ngIf="displayMode == 'largeCards'">
<app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)" (clickMoreLike)="clickMoreLike(d.id)"> <app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)" (clickStoragePath)="clickStoragePath($event)" (clickMoreLike)="clickMoreLike(d.id)">
</app-document-card-large> </app-document-card-large>
</div> </div>
@ -137,6 +136,12 @@
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Document type</th> i18n>Document type</th>
<th class="d-none d-xl-table-cell"
sortable="storage_path__name"
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Storage path</th>
<th <th
sortable="created" sortable="created"
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
@ -163,20 +168,25 @@
</td> </td>
<td class="d-none d-md-table-cell"> <td class="d-none d-md-table-cell">
<ng-container *ngIf="d.correspondent"> <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> </ng-container>
</td> </td>
<td> <td>
<a routerLink="/documents/{{d.id}}" title="Edit document" style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a> <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" (click)="clickTag(t.id);$event.stopPropagation()"></app-tag> <app-tag [tag]="t" *ngFor="let t of d.tags$ | async" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(t.id);$event.stopPropagation()"></app-tag>
</td> </td>
<td class="d-none d-xl-table-cell"> <td class="d-none d-xl-table-cell">
<ng-container *ngIf="d.document_type"> <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> </ng-container>
</td> </td>
<td> <td>
{{d.created | customDate}} {{d.created_date | customDate}}
</td> </td>
<td class="d-none d-xl-table-cell"> <td class="d-none d-xl-table-cell">
{{d.added | customDate}} {{d.added | customDate}}
@ -186,7 +196,7 @@
</table> </table>
<div class="row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'"> <div class="row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'">
<app-document-card-small class="p-0" [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)"></app-document-card-small> <app-document-card-small class="p-0" [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickStoragePath)="clickStoragePath($event)" (clickDocumentType)="clickDocumentType($event)"></app-document-card-small>
</div> </div>
<div *ngIf="list.documents?.length > 15" class="mt-3"> <div *ngIf="list.documents?.length > 15" class="mt-3">
<ng-container *ngTemplateOutlet="pagination"></ng-container> <ng-container *ngTemplateOutlet="pagination"></ng-container>

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