Merge remote-tracking branch 'paperless/dev' into feature-consume-eml
							
								
								
									
										254
									
								
								.github/scripts/cleanup-tags.py
									
									
									
									
										vendored
									
									
										Normal 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() | ||||
							
								
								
									
										17
									
								
								.github/scripts/common.py
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,4 +1,6 @@ | ||||
| #!/usr/bin/env python3 | ||||
| import logging | ||||
| from argparse import ArgumentError | ||||
|  | ||||
|  | ||||
| def get_image_tag( | ||||
| @@ -25,3 +27,18 @@ def get_cache_image_tag( | ||||
|     rebuilds, generally almost instant for the same version | ||||
|     """ | ||||
|     return f"ghcr.io/{repo_name}/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 | ||||
|   | ||||
							
								
								
									
										10
									
								
								.github/scripts/get-build-json.py
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -50,7 +50,6 @@ def _main(): | ||||
|  | ||||
|     # Default output values | ||||
|     version = None | ||||
|     git_tag = None | ||||
|     extra_config = {} | ||||
|  | ||||
|     if args.package in pipfile_data["default"]: | ||||
| @@ -59,12 +58,6 @@ def _main(): | ||||
|         pkg_version = pkg_data["version"].split("==")[-1] | ||||
|         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 | ||||
|         if args.package == "pikepdf": | ||||
|             extra_config["qpdf_version"] = build_json["qpdf"]["version"] | ||||
| @@ -72,8 +65,6 @@ def _main(): | ||||
|     elif args.package in build_json: | ||||
|         version = build_json[args.package]["version"] | ||||
|  | ||||
|         if "git_tag" in build_json[args.package]: | ||||
|             git_tag = build_json[args.package]["git_tag"] | ||||
|     else: | ||||
|         raise NotImplementedError(args.package) | ||||
|  | ||||
| @@ -81,7 +72,6 @@ def _main(): | ||||
|     output = { | ||||
|         "name": args.package, | ||||
|         "version": version, | ||||
|         "git_tag": git_tag, | ||||
|         "image_tag": get_image_tag(repo_name, args.package, version), | ||||
|         "cache_tag": get_cache_image_tag( | ||||
|             repo_name, | ||||
|   | ||||
							
								
								
									
										28
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -26,7 +26,7 @@ jobs: | ||||
|         run: pipx install pipenv | ||||
|       - | ||||
|         name: Set up Python | ||||
|         uses: actions/setup-python@v3 | ||||
|         uses: actions/setup-python@v4 | ||||
|         with: | ||||
|           python-version: 3.9 | ||||
|           cache: "pipenv" | ||||
| @@ -73,7 +73,7 @@ jobs: | ||||
|         uses: actions/checkout@v3 | ||||
|       - | ||||
|         name: Set up Python | ||||
|         uses: actions/setup-python@v3 | ||||
|         uses: actions/setup-python@v4 | ||||
|         with: | ||||
|           python-version: "3.9" | ||||
|       - | ||||
| @@ -135,18 +135,24 @@ jobs: | ||||
|       - | ||||
|         name: Check pushing to 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 | ||||
|         run: | | ||||
|           if [[ ${{ github.repository }} == "paperless-ngx/paperless-ngx" ]] ; then | ||||
|           if [[ ${{ github.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" | ||||
|           else | ||||
|             echo "Not pushing to DockerHub" | ||||
|             echo ::set-output name=enable::"false" | ||||
|           fi | ||||
|       - | ||||
|         name: Gather Docker metadata | ||||
|         id: docker-meta | ||||
|         uses: docker/metadata-action@v3 | ||||
|         uses: docker/metadata-action@v4 | ||||
|         with: | ||||
|           images: | | ||||
|             ghcr.io/${{ github.repository }} | ||||
| @@ -163,20 +169,20 @@ jobs: | ||||
|         uses: actions/checkout@v3 | ||||
|       - | ||||
|         name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v1 | ||||
|         uses: docker/setup-buildx-action@v2 | ||||
|       - | ||||
|         name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v1 | ||||
|         uses: docker/setup-qemu-action@v2 | ||||
|       - | ||||
|         name: Login to Github Container Registry | ||||
|         uses: docker/login-action@v1 | ||||
|         uses: docker/login-action@v2 | ||||
|         with: | ||||
|           registry: ghcr.io | ||||
|           username: ${{ github.actor }} | ||||
|           password: ${{ secrets.GITHUB_TOKEN }} | ||||
|       - | ||||
|         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 | ||||
|         if: steps.docker-hub.outputs.enable == 'true' | ||||
|         with: | ||||
| @@ -184,7 +190,7 @@ jobs: | ||||
|           password: ${{ secrets.DOCKERHUB_TOKEN }} | ||||
|       - | ||||
|         name: Build and push | ||||
|         uses: docker/build-push-action@v2 | ||||
|         uses: docker/build-push-action@v3 | ||||
|         with: | ||||
|           context: . | ||||
|           file: ./Dockerfile | ||||
| @@ -231,7 +237,7 @@ jobs: | ||||
|         uses: actions/checkout@v3 | ||||
|       - | ||||
|         name: Set up Python | ||||
|         uses: actions/setup-python@v3 | ||||
|         uses: actions/setup-python@v4 | ||||
|         with: | ||||
|           python-version: 3.9 | ||||
|       - | ||||
|   | ||||
							
								
								
									
										48
									
								
								.github/workflows/cleanup-tags.yml
									
									
									
									
										vendored
									
									
										Normal 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 | ||||
							
								
								
									
										4
									
								
								.github/workflows/installer-library.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -41,7 +41,7 @@ jobs: | ||||
|         uses: actions/checkout@v3 | ||||
|       - | ||||
|         name: Set up Python | ||||
|         uses: actions/setup-python@v3 | ||||
|         uses: actions/setup-python@v4 | ||||
|         with: | ||||
|           python-version: "3.9" | ||||
|       - | ||||
| @@ -122,7 +122,6 @@ jobs: | ||||
|       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: | ||||
| @@ -137,5 +136,4 @@ jobs: | ||||
|       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 }} | ||||
|   | ||||
							
								
								
									
										6
									
								
								.github/workflows/reusable-ci-backend.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -65,7 +65,7 @@ jobs: | ||||
|         run: pipx install pipenv | ||||
|       - | ||||
|         name: Set up Python | ||||
|         uses: actions/setup-python@v3 | ||||
|         uses: actions/setup-python@v4 | ||||
|         with: | ||||
|           python-version: "${{ matrix.python-version }}" | ||||
|           cache: "pipenv" | ||||
| @@ -74,7 +74,7 @@ jobs: | ||||
|         name: Install system dependencies | ||||
|         run: | | ||||
|           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 | ||||
|         run: | | ||||
| @@ -87,7 +87,7 @@ jobs: | ||||
|       - | ||||
|         name: Get changed files | ||||
|         id: changed-files-specific | ||||
|         uses: tj-actions/changed-files@v19 | ||||
|         uses: tj-actions/changed-files@v23.1 | ||||
|         with: | ||||
|           files: | | ||||
|             src/** | ||||
|   | ||||
| @@ -28,20 +28,20 @@ jobs: | ||||
|         uses: actions/checkout@v3 | ||||
|       - | ||||
|         name: Login to Github Container Registry | ||||
|         uses: docker/login-action@v1 | ||||
|         uses: docker/login-action@v2 | ||||
|         with: | ||||
|           registry: ghcr.io | ||||
|           username: ${{ github.actor }} | ||||
|           password: ${{ secrets.GITHUB_TOKEN }} | ||||
|       - | ||||
|         name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v1 | ||||
|         uses: docker/setup-buildx-action@v2 | ||||
|       - | ||||
|         name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v1 | ||||
|         uses: docker/setup-qemu-action@v2 | ||||
|       - | ||||
|         name: Build ${{ fromJSON(inputs.build-json).name }} | ||||
|         uses: docker/build-push-action@v2 | ||||
|         uses: docker/build-push-action@v3 | ||||
|         with: | ||||
|           context: . | ||||
|           file: ${{ inputs.dockerfile }} | ||||
|   | ||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -70,6 +70,7 @@ target/ | ||||
| .virtualenv | ||||
| virtualenv | ||||
| /venv | ||||
| .venv/ | ||||
| /docker-compose.env | ||||
| /docker-compose.yml | ||||
|  | ||||
|   | ||||
| @@ -5,7 +5,7 @@ | ||||
| repos: | ||||
|   # General hooks | ||||
|   - repo: https://github.com/pre-commit/pre-commit-hooks | ||||
|     rev: v4.2.0 | ||||
|     rev: v4.3.0 | ||||
|     hooks: | ||||
|       - id: check-docstring-first | ||||
|       - id: check-json | ||||
| @@ -27,7 +27,7 @@ repos: | ||||
|       - id: check-case-conflict | ||||
|       - id: detect-private-key | ||||
|   - repo: https://github.com/pre-commit/mirrors-prettier | ||||
|     rev: "v2.6.2" | ||||
|     rev: "v2.7.1" | ||||
|     hooks: | ||||
|       - id: prettier | ||||
|         types_or: | ||||
| @@ -37,7 +37,7 @@ repos: | ||||
|         exclude: "(^Pipfile\\.lock$)" | ||||
|   # Python hooks | ||||
|   - repo: https://github.com/asottile/reorder_python_imports | ||||
|     rev: v3.1.0 | ||||
|     rev: v3.8.1 | ||||
|     hooks: | ||||
|       - id: reorder-python-imports | ||||
|         exclude: "(migrations)" | ||||
| @@ -59,11 +59,11 @@ repos: | ||||
|         args: | ||||
|           - "--config=./src/setup.cfg" | ||||
|   - repo: https://github.com/psf/black | ||||
|     rev: 22.3.0 | ||||
|     rev: 22.6.0 | ||||
|     hooks: | ||||
|       - id: black | ||||
|   - repo: https://github.com/asottile/pyupgrade | ||||
|     rev: v2.32.1 | ||||
|     rev: v2.37.1 | ||||
|     hooks: | ||||
|       - id: pyupgrade | ||||
|         exclude: "(migrations)" | ||||
|   | ||||
							
								
								
									
										16
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						| @@ -77,15 +77,12 @@ ARG RUNTIME_PACKAGES="\ | ||||
|   libraqm0 \ | ||||
|   libgnutls30 \ | ||||
|   libjpeg62-turbo \ | ||||
|   optipng \ | ||||
|   python3 \ | ||||
|   python3-pip \ | ||||
|   python3-setuptools \ | ||||
|   postgresql-client \ | ||||
|   # For Numpy | ||||
|   libatlas3-base \ | ||||
|   # thumbnail size reduction | ||||
|   pngquant \ | ||||
|   # OCRmyPDF dependencies | ||||
|   tesseract-ocr \ | ||||
|   tesseract-ocr-eng \ | ||||
| @@ -151,14 +148,14 @@ 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/qpdf_*.deb \ | ||||
|   && echo "Installing pikepdf and dependencies" \ | ||||
|     && python3 -m pip install --no-cache-dir /pikepdf/usr/src/pikepdf/wheels/packaging*.whl \ | ||||
|     && python3 -m pip install --no-cache-dir /pikepdf/usr/src/pikepdf/wheels/lxml*.whl \ | ||||
|     && python3 -m pip install --no-cache-dir /pikepdf/usr/src/pikepdf/wheels/Pillow*.whl \ | ||||
|     && python3 -m pip install --no-cache-dir /pikepdf/usr/src/pikepdf/wheels/pyparsing*.whl \ | ||||
|     && python3 -m pip install --no-cache-dir /pikepdf/usr/src/pikepdf/wheels/pikepdf*.whl \ | ||||
|     && python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/packaging*.whl \ | ||||
|     && python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/lxml*.whl \ | ||||
|     && python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/Pillow*.whl \ | ||||
|     && python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/pyparsing*.whl \ | ||||
|     && python3 -m pip install --no-cache-dir /pikepdf/usr/src/wheels/pikepdf*.whl \ | ||||
|     && python -m pip list \ | ||||
|   && 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 | ||||
|  | ||||
| # Python dependencies | ||||
| @@ -169,6 +166,7 @@ COPY requirements.txt ../ | ||||
| # dependencies | ||||
| ARG BUILD_PACKAGES="\ | ||||
|   build-essential \ | ||||
|   git \ | ||||
|   python3-dev" | ||||
|  | ||||
| RUN set -eux \ | ||||
|   | ||||
							
								
								
									
										17
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						| @@ -13,8 +13,8 @@ dateparser = "~=1.1" | ||||
| django = "~=4.0" | ||||
| django-cors-headers = "*" | ||||
| django-extensions = "*" | ||||
| django-filter = "~=21.1" | ||||
| django-q = "~=1.3" | ||||
| django-filter = "~=22.1" | ||||
| django-q = {editable = true, ref = "paperless-main", git = "https://github.com/paperless-ngx/django-q.git"} | ||||
| djangorestframework = "~=3.13" | ||||
| filelock = "*" | ||||
| fuzzywuzzy = {extras = ["speedup"], version = "*"} | ||||
| @@ -22,20 +22,17 @@ gunicorn = "*" | ||||
| imap-tools = "*" | ||||
| langdetect = "*" | ||||
| pathvalidate = "*" | ||||
| pillow = "~=9.1" | ||||
| # Any version update to pikepdf requires a base image update | ||||
| pillow = "~=9.2" | ||||
| pikepdf = "~=5.1" | ||||
| python-gnupg = "*" | ||||
| python-dotenv = "*" | ||||
| python-dateutil = "*" | ||||
| python-magic = "*" | ||||
| # Any version update to psycopg2 requires a base image update | ||||
| psycopg2 = "*" | ||||
| redis = "*" | ||||
| # Pinned because aarch64 wheels and updates cause warnings when loading the classifier model. | ||||
| scikit-learn="==1.0.2" | ||||
| whitenoise = "~=6.0.0" | ||||
| watchdog = "~=2.1.0" | ||||
| scikit-learn="~=1.1" | ||||
| whitenoise = "~=6.2.0" | ||||
| watchdog = "~=2.1.9" | ||||
| whoosh="~=2.7.4" | ||||
| inotifyrecursive = "~=0.3" | ||||
| ocrmypdf = "~=13.4" | ||||
| @@ -65,7 +62,7 @@ pytest-django = "*" | ||||
| pytest-env = "*" | ||||
| pytest-sugar = "*" | ||||
| pytest-xdist = "*" | ||||
| sphinx = "~=4.5.0" | ||||
| sphinx = "~=5.0.2" | ||||
| sphinx_rtd_theme = "*" | ||||
| tox = "*" | ||||
| black = "*" | ||||
|   | ||||
							
								
								
									
										1398
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						| @@ -34,6 +34,7 @@ branch_name=$(git rev-parse --abbrev-ref HEAD) | ||||
| export DOCKER_BUILDKIT=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:dev \ | ||||
| 	--build-arg JBIG2ENC_VERSION="${jbig2enc_version}" \ | ||||
|   | ||||
| @@ -2,8 +2,7 @@ | ||||
| # Inputs: | ||||
| #    - REPO - Docker repository to pull qpdf 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 - Used to force the built pikepdf version to match | ||||
| #    - PIKEPDF_VERSION - Version of pikepdf to build wheel for | ||||
|  | ||||
| # Default to pulling from the main repo registry when manually building | ||||
| ARG REPO="paperless-ngx/paperless-ngx" | ||||
| @@ -23,7 +22,6 @@ ARG BUILD_PACKAGES="\ | ||||
|   build-essential \ | ||||
|   python3-dev \ | ||||
|   python3-pip \ | ||||
|   git \ | ||||
|   # qpdf requirement - https://github.com/qpdf/qpdf#crypto-providers | ||||
|   libgnutls28-dev \ | ||||
|   # lxml requrements - https://lxml.de/installation.html | ||||
| @@ -72,21 +70,19 @@ RUN set -eux \ | ||||
| # For better caching, seperate the basic installs from | ||||
| # the building | ||||
|  | ||||
| ARG PIKEPDF_GIT_TAG | ||||
| ARG PIKEPDF_VERSION | ||||
|  | ||||
| RUN set -eux \ | ||||
|   && echo "building pikepdf wheel" \ | ||||
|   # 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 \ | ||||
|   && echo "Building pikepdf wheel ${PIKEPDF_VERSION}" \ | ||||
|   && 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 | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| # This Dockerfile builds the psycopg2 wheel | ||||
| # Inputs: | ||||
| #    - PSYCOPG2_GIT_TAG - The Git tag to clone and build from | ||||
| #    - PSYCOPG2_VERSION - Unused, kept for future possible usage | ||||
| #    - PSYCOPG2_VERSION - Version to build | ||||
|  | ||||
| FROM python:3.9-slim-bullseye as main | ||||
|  | ||||
| @@ -11,7 +10,6 @@ ARG DEBIAN_FRONTEND=noninteractive | ||||
|  | ||||
| ARG BUILD_PACKAGES="\ | ||||
|   build-essential \ | ||||
|   git \ | ||||
|   python3-dev \ | ||||
|   python3-pip \ | ||||
|   # https://www.psycopg.org/docs/install.html#prerequisites | ||||
| @@ -32,14 +30,20 @@ RUN set -eux \ | ||||
| # For better caching, seperate the basic installs from | ||||
| # the building | ||||
|  | ||||
| ARG PSYCOPG2_GIT_TAG | ||||
| ARG PSYCOPG2_VERSION | ||||
|  | ||||
| RUN set -eux \ | ||||
|   && echo "Building psycopg2 wheel" \ | ||||
|   && echo "Building psycopg2 wheel ${PSYCOPG2_VERSION}" \ | ||||
|   && cd /usr/src \ | ||||
|   && git clone --quiet --depth 1 --branch ${PSYCOPG2_GIT_TAG} https://github.com/psycopg/psycopg2.git \ | ||||
|   && cd psycopg2 \ | ||||
|   && 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/ | ||||
|   | ||||
| @@ -31,13 +31,13 @@ | ||||
| version: "3.4" | ||||
| services: | ||||
|   broker: | ||||
|     image: redis:6.0 | ||||
|     image: docker.io/library/redis:6.0 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|  | ||||
|   db: | ||||
|     image: postgres:13 | ||||
|     image: docker.io/library/postgres:13 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - pgdata:/var/lib/postgresql/data | ||||
|   | ||||
| @@ -33,13 +33,13 @@ | ||||
| version: "3.4" | ||||
| services: | ||||
|   broker: | ||||
|     image: redis:6.0 | ||||
|     image: docker.io/library/redis:6.0 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|  | ||||
|   db: | ||||
|     image: postgres:13 | ||||
|     image: docker.io/library/postgres:13 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - pgdata:/var/lib/postgresql/data | ||||
| @@ -77,7 +77,7 @@ services: | ||||
|       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 | ||||
|  | ||||
|   gotenberg: | ||||
|     image: gotenberg/gotenberg:7.4 | ||||
|     image: docker.io/gotenberg/gotenberg:7.4 | ||||
|     restart: unless-stopped | ||||
|  | ||||
|   tika: | ||||
|   | ||||
| @@ -29,13 +29,13 @@ | ||||
| version: "3.4" | ||||
| services: | ||||
|   broker: | ||||
|     image: redis:6.0 | ||||
|     image: docker.io/library/redis:6.0 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|  | ||||
|   db: | ||||
|     image: postgres:13 | ||||
|     image: docker.io/library/postgres:13 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - pgdata:/var/lib/postgresql/data | ||||
|   | ||||
| @@ -33,7 +33,7 @@ | ||||
| version: "3.4" | ||||
| services: | ||||
|   broker: | ||||
|     image: redis:6.0 | ||||
|     image: docker.io/library/redis:6.0 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
| @@ -65,7 +65,7 @@ services: | ||||
|       PAPERLESS_TIKA_ENDPOINT: http://tika:9998 | ||||
|  | ||||
|   gotenberg: | ||||
|     image: gotenberg/gotenberg:7.4 | ||||
|     image: docker.io/gotenberg/gotenberg:7.4 | ||||
|     restart: unless-stopped | ||||
|  | ||||
|   tika: | ||||
|   | ||||
| @@ -26,7 +26,7 @@ | ||||
| version: "3.4" | ||||
| services: | ||||
|   broker: | ||||
|     image: redis:6.0 | ||||
|     image: docker.io/library/redis:6.0 | ||||
|     restart: unless-stopped | ||||
|     volumes: | ||||
|       - redisdata:/data | ||||
|   | ||||
| @@ -2,6 +2,37 @@ | ||||
|  | ||||
| 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/ | ||||
| map_uidgid() { | ||||
| 	USERMAP_ORIG_UID=$(id -u paperless) | ||||
| @@ -15,23 +46,53 @@ map_uidgid() { | ||||
| 	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() { | ||||
|  | ||||
| 	# 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 | ||||
|  | ||||
| 	for dir in export data data/index media media/documents media/documents/originals media/documents/thumbnails; do | ||||
| 		if [[ ! -d "../$dir" ]]; then | ||||
| 			echo "Creating directory ../$dir" | ||||
| 			mkdir ../$dir | ||||
| 	# Check for overrides of certain folders | ||||
| 	map_folders | ||||
|  | ||||
| 	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 | ||||
| 	done | ||||
|  | ||||
| 	echo "Creating directory /tmp/paperless" | ||||
| 	mkdir -p /tmp/paperless | ||||
| 	local tmp_dir="/tmp/paperless" | ||||
| 	echo "Creating directory ${tmp_dir}" | ||||
| 	mkdir -p "${tmp_dir}" | ||||
|  | ||||
| 	set +e | ||||
| 	echo "Adjusting permissions of paperless files. This may take a while." | ||||
| 	chown -R paperless:paperless /tmp/paperless | ||||
| 	find .. -not \( -user paperless -and -group paperless \) -exec chown paperless:paperless {} + | ||||
| 	chown -R paperless:paperless ${tmp_dir} | ||||
| 	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 | ||||
|  | ||||
| 	gosu paperless /sbin/docker-prepare.sh | ||||
|   | ||||
| @@ -3,16 +3,17 @@ | ||||
| set -e | ||||
|  | ||||
| wait_for_postgres() { | ||||
| 	attempt_num=1 | ||||
| 	max_attempts=5 | ||||
| 	local attempt_num=1 | ||||
| 	local max_attempts=5 | ||||
|  | ||||
| 	echo "Waiting for PostgreSQL to start..." | ||||
|  | ||||
| 	host="${PAPERLESS_DBHOST:=localhost}" | ||||
| 	port="${PAPERLESS_DBPORT:=5432}" | ||||
| 	local host="${PAPERLESS_DBHOST:-localhost}" | ||||
| 	local port="${PAPERLESS_DBPORT:-5432}" | ||||
|  | ||||
|  | ||||
| 	while [ ! "$(pg_isready -h $host -p $port)" ]; do | ||||
| 	# Disable warning, host and port can't have spaces | ||||
| 	# shellcheck disable=SC2086 | ||||
| 	while [ ! "$(pg_isready -h ${host} -p ${port})" ]; do | ||||
|  | ||||
| 		if [ $attempt_num -eq $max_attempts ]; then | ||||
| 			echo "Unable to connect to database." | ||||
| @@ -43,17 +44,18 @@ migrations() { | ||||
| 		flock 200 | ||||
| 		echo "Apply database migrations..." | ||||
| 		python3 manage.py migrate | ||||
| 	) 200>/usr/src/paperless/data/migration_lock | ||||
| 	) 200>"${DATA_DIR}/migration_lock" | ||||
| } | ||||
|  | ||||
| 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..." | ||||
| 		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 | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -2,7 +2,18 @@ | ||||
|  | ||||
| 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 | ||||
| 	echo "installing $command..." | ||||
| 	sed "s/management_command/$command/g" management_script.sh > /usr/local/bin/$command | ||||
|   | ||||
| @@ -26,9 +26,11 @@ if __name__ == "__main__": | ||||
|             try: | ||||
|                 client.ping() | ||||
|                 break | ||||
|             except Exception: | ||||
|             except Exception as e: | ||||
|                 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, | ||||
|                 ) | ||||
|                 time.sleep(RETRY_SLEEP_SECONDS) | ||||
|   | ||||
| @@ -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. | ||||
| *   ``document_type``: Document type 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. | ||||
| *   ``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. | ||||
|   | ||||
| @@ -424,14 +424,23 @@ PAPERLESS_OCR_IMAGE_DPI=<num> | ||||
|     the produced PDF documents are A4 sized. | ||||
|  | ||||
| PAPERLESS_OCR_MAX_IMAGE_PIXELS=<num> | ||||
|     Paperless will not OCR images that have more pixels than this limit. | ||||
|     This is intended to prevent decompression bombs from overloading paperless. | ||||
|     Increasing this limit is desired if you face a DecompressionBombError despite | ||||
|     the concerning file not being malicious; this could e.g. be caused by invalidly | ||||
|     recognized metadata. | ||||
|     If you have enough resources or if you are certain that your uploaded files | ||||
|     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. | ||||
|     Paperless will raise a warning when OCRing images which are over this limit and | ||||
|     will not OCR images which are more than twice this limit.  Note this does not | ||||
|     prevent the document from being consumed, but could result in missing text content. | ||||
|  | ||||
|     If unset, will default to the value determined by | ||||
|     `Pillow <https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.MAX_IMAGE_PIXELS>`_. | ||||
|  | ||||
|     .. 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> | ||||
|     OCRmyPDF offers many more options. Use this parameter to specify any | ||||
| @@ -700,13 +709,6 @@ PAPERLESS_CONVERT_TMPDIR=<path> | ||||
|  | ||||
|     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> | ||||
|     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 | ||||
| @@ -777,9 +779,6 @@ PAPERLESS_CONVERT_BINARY=<path> | ||||
| PAPERLESS_GS_BINARY=<path> | ||||
|     Defaults to "/usr/bin/gs". | ||||
|  | ||||
| PAPERLESS_OPTIPNG_BINARY=<path> | ||||
|     Defaults to "/usr/bin/optipng". | ||||
|  | ||||
|  | ||||
| .. _configuration-docker: | ||||
|  | ||||
|   | ||||
| @@ -200,6 +200,19 @@ Install Paperless from Docker Hub | ||||
|         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. | ||||
|  | ||||
|     .. 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:: | ||||
|  | ||||
|         Some file systems such as NFS network shares don't support file system | ||||
| @@ -286,7 +299,6 @@ writing. Windows is not and will never be supported. | ||||
|  | ||||
|     *   ``fonts-liberation`` for generating thumbnails for plain text files | ||||
|     *   ``imagemagick`` >= 6 for PDF conversion | ||||
|     *   ``optipng`` for optimizing thumbnails | ||||
|     *   ``gnupg`` for handling encrypted documents | ||||
|     *   ``libpq-dev`` for PostgreSQL | ||||
|     *   ``libmagic-dev`` for mime type detection | ||||
| @@ -298,7 +310,7 @@ writing. Windows is not and will never be supported. | ||||
|  | ||||
|     .. 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. | ||||
|  | ||||
| @@ -730,8 +742,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``. | ||||
|     This will speed up OCR times and use less memory at the expense of slightly worse | ||||
|     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 | ||||
|     1. This will save some memory. | ||||
|  | ||||
|   | ||||
| @@ -161,6 +161,9 @@ These are as follows: | ||||
|     will not consume flagged mails. | ||||
| *   **Move to folder:** Moves consumed mails out of the way so that paperless wont | ||||
|     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:: | ||||
|  | ||||
|   | ||||
| @@ -65,7 +65,6 @@ | ||||
| #PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS=false | ||||
| #PAPERLESS_CONSUMER_ENABLE_BARCODES=false | ||||
| #PAPERLESS_CONSUMER_ENABLE_BARCODES=PATCHT | ||||
| #PAPERLESS_OPTIMIZE_THUMBNAILS=true | ||||
| #PAPERLESS_PRE_CONSUME_SCRIPT=/path/to/an/arbitrary/script.sh | ||||
| #PAPERLESS_POST_CONSUME_SCRIPT=/path/to/an/arbitrary/script.sh | ||||
| #PAPERLESS_FILENAME_DATE_ORDER=YMD | ||||
| @@ -84,4 +83,3 @@ | ||||
|  | ||||
| #PAPERLESS_CONVERT_BINARY=/usr/bin/convert | ||||
| #PAPERLESS_GS_BINARY=/usr/bin/gs | ||||
| #PAPERLESS_OPTIPNG_BINARY=/usr/bin/optipng | ||||
|   | ||||
| @@ -1,10 +1,3 @@ | ||||
| # | ||||
| # These requirements were autogenerated by pipenv | ||||
| # To regenerate from the project's Pipfile, run: | ||||
| # | ||||
| #    pipenv lock --requirements | ||||
| # | ||||
|  | ||||
| -i https://pypi.python.org/simple | ||||
| --extra-index-url https://www.piwheels.org/simple | ||||
| aioredis==1.3.1 | ||||
| @@ -13,30 +6,32 @@ arrow==1.2.2; python_version >= '3.6' | ||||
| asgiref==3.5.2; python_version >= '3.7' | ||||
| async-timeout==4.0.2; python_version >= '3.6' | ||||
| attrs==21.4.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' | ||||
| autobahn==22.4.2; python_version >= '3.7' | ||||
| autobahn==22.6.1; python_version >= '3.7' | ||||
| automat==20.2.0 | ||||
| backports.zoneinfo==0.2.1; python_version < '3.9' | ||||
| blessed==1.19.1; python_version >= '2.7' | ||||
| certifi==2021.10.8 | ||||
| cffi==1.15.0 | ||||
| certifi==2022.6.15; python_version >= '3.6' | ||||
| cffi==1.15.1 | ||||
| channels==3.0.5 | ||||
| channels-redis==3.4.0 | ||||
| channels==3.0.4 | ||||
| charset-normalizer==2.0.12; python_version >= '3.5' | ||||
| charset-normalizer==2.1.0; python_version >= '3.6' | ||||
| click==8.1.3; python_version >= '3.7' | ||||
| coloredlogs==15.0.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' | ||||
| concurrent-log-handler==0.9.20 | ||||
| constantly==15.1.0 | ||||
| cryptography==36.0.2; python_version >= '3.6' | ||||
| cryptography==37.0.4; python_version >= '3.6' | ||||
| daphne==3.0.2; python_version >= '3.6' | ||||
| dateparser==1.1.1 | ||||
| django-cors-headers==3.12.0 | ||||
| django-extensions==3.1.5 | ||||
| django-filter==21.1 | ||||
| django-picklefield==3.0.1; python_version >= '3' | ||||
| django-q==1.3.9 | ||||
| django==4.0.4 | ||||
| deprecated==1.2.13; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' | ||||
| deprecation==2.1.0 | ||||
| django==4.0.6 | ||||
| django-cors-headers==3.13.0 | ||||
| django-extensions==3.2.0 | ||||
| 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 | ||||
| filelock==3.7.0 | ||||
| filelock==3.7.1 | ||||
| fuzzywuzzy[speedup]==0.18.0 | ||||
| gunicorn==20.1.0 | ||||
| h11==0.13.0; python_version >= '3.6' | ||||
| @@ -44,50 +39,50 @@ hiredis==2.0.0; python_version >= '3.6' | ||||
| httptools==0.4.0 | ||||
| humanfriendly==10.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' | ||||
| hyperlink==21.0.0 | ||||
| idna==3.3; python_version >= '3' | ||||
| imap-tools==0.55.0 | ||||
| idna==3.3; python_version >= '3.5' | ||||
| imap-tools==0.56.0 | ||||
| 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 | ||||
| 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 | ||||
| joblib==1.1.0; python_version >= '3.6' | ||||
| langdetect==1.0.9 | ||||
| lxml==4.8.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' | ||||
| msgpack==1.0.3 | ||||
| numpy==1.22.3; python_version >= '3.8' | ||||
| ocrmypdf==13.4.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.4 | ||||
| numpy==1.23.1; python_version >= '3.8' | ||||
| ocrmypdf==13.6.0 | ||||
| packaging==21.3; python_version >= '3.6' | ||||
| pathvalidate==2.5.0 | ||||
| pdf2image==1.16.0 | ||||
| pdfminer.six==20220506 | ||||
| pikepdf==5.1.3 | ||||
| pillow==9.1.0 | ||||
| pdfminer.six==20220524 | ||||
| pikepdf==5.3.1 | ||||
| pillow==9.2.0 | ||||
| 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 | ||||
| pyasn1-modules==0.2.8 | ||||
| pyasn1==0.4.8 | ||||
| pyasn1-modules==0.2.8 | ||||
| pycparser==2.21 | ||||
| pyopenssl==22.0.0 | ||||
| pyparsing==3.0.9; python_full_version >= '3.6.8' | ||||
| python-dateutil==2.8.2 | ||||
| python-dotenv==0.20.0 | ||||
| python-gnupg==0.4.8 | ||||
| python-gnupg==0.4.9 | ||||
| python-levenshtein==0.12.2 | ||||
| python-magic==0.4.25 | ||||
| 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' | ||||
| python-magic==0.4.27 | ||||
| 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 | ||||
| pyzbar==0.1.9 | ||||
| redis==3.5.3 | ||||
| redis==4.3.4 | ||||
| regex==2022.3.2; python_version >= '3.6' | ||||
| reportlab==3.6.9; 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' | ||||
| scikit-learn==1.0.2 | ||||
| scipy==1.8.0; python_version < '3.11' and python_version >= '3.8' | ||||
| reportlab==3.6.11; python_version >= '3.7' and python_version < '4' | ||||
| requests==2.28.1; python_version >= '3.7' and python_version < '4' | ||||
| scikit-learn==1.1.1 | ||||
| scipy==1.8.1; python_version < '3.11' and python_version >= '3.8' | ||||
| service-identity==21.1.0 | ||||
| setuptools==62.2.0; python_version >= '3.7' | ||||
| setuptools==63.1.0; python_version >= '3.7' | ||||
| six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' | ||||
| sniffio==1.2.0; python_version >= '3.5' | ||||
| sqlparse==0.4.2; python_version >= '3.5' | ||||
| @@ -96,17 +91,18 @@ tika==1.24 | ||||
| tqdm==4.64.0 | ||||
| twisted[tls]==22.4.0; python_full_version >= '3.6.7' | ||||
| 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' | ||||
| tzlocal==4.2; python_version >= '3.6' | ||||
| urllib3==1.26.9; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4' | ||||
| uvicorn[standard]==0.17.6 | ||||
| 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.18.2 | ||||
| uvloop==0.16.0 | ||||
| watchdog==2.1.8 | ||||
| watchgod==0.8.2 | ||||
| watchdog==2.1.9 | ||||
| watchfiles==0.15.0 | ||||
| wcwidth==0.2.5 | ||||
| websockets==10.3 | ||||
| whitenoise==6.0.0 | ||||
| whitenoise==6.2.0 | ||||
| whoosh==2.7.4 | ||||
| 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.0; 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' | ||||
|   | ||||
							
								
								
									
										13
									
								
								src-ui/cypress.config.ts
									
									
									
									
									
										Normal 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', | ||||
|   }, | ||||
| }) | ||||
| @@ -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" | ||||
| } | ||||
| @@ -1,10 +1,9 @@ | ||||
| describe('document-detail', () => { | ||||
|   beforeEach(() => { | ||||
|     // also uses global fixtures from cypress/support/e2e.ts
 | ||||
| 
 | ||||
|     this.modifiedDocuments = [] | ||||
| 
 | ||||
|     cy.intercept('http://localhost:8000/api/ui_settings/', { | ||||
|       fixture: 'ui_settings/settings.json', | ||||
|     }) | ||||
|     cy.fixture('documents/documents.json').then((documentsJson) => { | ||||
|       cy.intercept('GET', 'http://localhost:8000/api/documents/1/', (req) => { | ||||
|         let response = { ...documentsJson } | ||||
| @@ -18,30 +17,6 @@ describe('document-detail', () => { | ||||
|       req.reply({ result: 'OK' }) | ||||
|     }).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.visit('/documents/1/') | ||||
|   }) | ||||
| @@ -1,11 +1,9 @@ | ||||
| describe('documents-list', () => { | ||||
|   beforeEach(() => { | ||||
|     // also uses global fixtures from cypress/support/e2e.ts
 | ||||
| 
 | ||||
|     this.bulkEdits = {} | ||||
| 
 | ||||
|     // mock API methods
 | ||||
|     cy.intercept('http://localhost:8000/api/ui_settings/', { | ||||
|       fixture: 'ui_settings/settings.json', | ||||
|     }) | ||||
|     cy.fixture('documents/documents.json').then((documentsJson) => { | ||||
|       // bulk edit
 | ||||
|       cy.intercept( | ||||
| @@ -56,40 +54,25 @@ describe('documents-list', () => { | ||||
|       }) | ||||
|     }) | ||||
| 
 | ||||
|     cy.intercept('http://localhost:8000/api/documents/1/thumb/', { | ||||
|       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.viewport(1280, 1024) | ||||
|     cy.visit('/documents') | ||||
|   }) | ||||
| 
 | ||||
|   it('should show a list of documents rendered as cards with thumbnails', () => { | ||||
|     cy.contains('3 documents') | ||||
|     cy.contains('lorem-ipsum') | ||||
|     cy.contains('lorem ipsum') | ||||
|     cy.get('app-document-card-small:first-of-type img') | ||||
|       .invoke('attr', 'src') | ||||
|       .should('eq', 'http://localhost:8000/api/documents/1/thumb/') | ||||
|   }) | ||||
| 
 | ||||
|   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') | ||||
|   }) | ||||
| 
 | ||||
|   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') | ||||
|   }) | ||||
| 
 | ||||
							
								
								
									
										331
									
								
								src-ui/cypress/e2e/documents/query-params.cy.ts
									
									
									
									
									
										Normal 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') | ||||
|   }) | ||||
| }) | ||||
| @@ -1,15 +1,5 @@ | ||||
| describe('manage', () => { | ||||
|   beforeEach(() => { | ||||
|     cy.intercept('http://localhost:8000/api/ui_settings/', { | ||||
|       fixture: 'ui_settings/settings.json', | ||||
|     }) | ||||
|     cy.intercept('http://localhost:8000/api/correspondents/*', { | ||||
|       fixture: 'correspondents/correspondents.json', | ||||
|     }) | ||||
|     cy.intercept('http://localhost:8000/api/tags/*', { | ||||
|       fixture: 'tags/tags.json', | ||||
|     }) | ||||
|   }) | ||||
|   // also uses global fixtures from cypress/support/e2e.ts
 | ||||
| 
 | ||||
|   it('should show a list of correspondents with bottom pagination as well', () => { | ||||
|     cy.visit('/correspondents') | ||||
| @@ -1,5 +1,7 @@ | ||||
| describe('settings', () => { | ||||
|   beforeEach(() => { | ||||
|     // also uses global fixtures from cypress/support/e2e.ts
 | ||||
| 
 | ||||
|     this.modifiedViews = [] | ||||
| 
 | ||||
|     // mock API methods
 | ||||
| @@ -42,14 +44,6 @@ describe('settings', () => { | ||||
|           req.reply(response) | ||||
|         }) | ||||
|       }) | ||||
| 
 | ||||
|       cy.intercept('http://localhost:8000/api/documents/1/metadata/', { | ||||
|         fixture: 'documents/1/metadata.json', | ||||
|       }) | ||||
| 
 | ||||
|       cy.intercept('http://localhost:8000/api/documents/1/suggestions/', { | ||||
|         fixture: 'documents/1/suggestions.json', | ||||
|       }) | ||||
|     }) | ||||
| 
 | ||||
|     cy.viewport(1024, 1024) | ||||
							
								
								
									
										60
									
								
								src-ui/cypress/e2e/tasks/tasks.cy.ts
									
									
									
									
									
										Normal 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') | ||||
|       }) | ||||
|   }) | ||||
| }) | ||||
| @@ -0,0 +1 @@ | ||||
| {"version":"v1.7.1","update_available":false,"feature_is_set":true} | ||||
							
								
								
									
										17
									
								
								src-ui/cypress/fixtures/storage_paths/storage_paths.json
									
									
									
									
									
										Normal 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 | ||||
|         } | ||||
|     ] | ||||
| } | ||||
| @@ -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 | ||||
|         } | ||||
|     ] | ||||
| } | ||||
|   | ||||
							
								
								
									
										1
									
								
								src-ui/cypress/fixtures/tasks/tasks.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										43
									
								
								src-ui/cypress/support/e2e.ts
									
									
									
									
									
										Normal 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', | ||||
|   }) | ||||
| }) | ||||
| @@ -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'; | ||||
							
								
								
									
										17459
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						| @@ -13,48 +13,48 @@ | ||||
|   }, | ||||
|   "private": true, | ||||
|   "dependencies": { | ||||
|     "@angular/common": "~13.3.5", | ||||
|     "@angular/compiler": "~13.3.5", | ||||
|     "@angular/core": "~13.3.5", | ||||
|     "@angular/forms": "~13.3.5", | ||||
|     "@angular/localize": "~13.3.5", | ||||
|     "@angular/platform-browser": "~13.3.5", | ||||
|     "@angular/platform-browser-dynamic": "~13.3.5", | ||||
|     "@angular/router": "~13.3.5", | ||||
|     "@ng-bootstrap/ng-bootstrap": "^12.1.1", | ||||
|     "@ng-select/ng-select": "^8.1.1", | ||||
|     "@angular/common": "~14.0.4", | ||||
|     "@angular/compiler": "~14.0.4", | ||||
|     "@angular/core": "~14.0.4", | ||||
|     "@angular/forms": "~14.0.4", | ||||
|     "@angular/localize": "~14.0.4", | ||||
|     "@angular/platform-browser": "~14.0.4", | ||||
|     "@angular/platform-browser-dynamic": "~14.0.4", | ||||
|     "@angular/router": "~14.0.4", | ||||
|     "@ng-bootstrap/ng-bootstrap": "^13.0.0-beta.1", | ||||
|     "@ng-select/ng-select": "^9.0.2", | ||||
|     "@ngneat/dirty-check-forms": "^3.0.2", | ||||
|     "@popperjs/core": "^2.11.4", | ||||
|     "@popperjs/core": "^2.11.5", | ||||
|     "bootstrap": "^5.1.3", | ||||
|     "file-saver": "^2.0.5", | ||||
|     "ng2-pdf-viewer": "^9.0.0", | ||||
|     "ngx-color": "^7.3.3", | ||||
|     "ngx-cookie-service": "^13.1.2", | ||||
|     "ngx-cookie-service": "^14.0.1", | ||||
|     "ngx-file-drop": "^13.0.0", | ||||
|     "rxjs": "~7.5.5", | ||||
|     "tslib": "^2.3.1", | ||||
|     "uuid": "^8.3.1", | ||||
|     "zone.js": "~0.11.4" | ||||
|     "zone.js": "~0.11.6" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@angular-builders/jest": "13.0.3", | ||||
|     "@angular-devkit/build-angular": "~13.3.4", | ||||
|     "@angular/cli": "~13.3.4", | ||||
|     "@angular/compiler-cli": "~13.3.5", | ||||
|     "@types/jest": "27.4.1", | ||||
|     "@types/node": "^17.0.30", | ||||
|     "@angular-builders/jest": "14.0.0", | ||||
|     "@angular-devkit/build-angular": "~14.0.4", | ||||
|     "@angular/cli": "~14.0.4", | ||||
|     "@angular/compiler-cli": "~14.0.4", | ||||
|     "@types/jest": "28.1.4", | ||||
|     "@types/node": "^18.0.0", | ||||
|     "codelyzer": "^6.0.2", | ||||
|     "concurrently": "7.1.0", | ||||
|     "jest": "28.0.3", | ||||
|     "jest-environment-jsdom": "^28.0.2", | ||||
|     "jest-preset-angular": "^12.0.0-next.1", | ||||
|     "ts-node": "~10.7.0", | ||||
|     "concurrently": "7.2.2", | ||||
|     "jest": "28.1.2", | ||||
|     "jest-environment-jsdom": "^28.1.2", | ||||
|     "jest-preset-angular": "^12.1.0", | ||||
|     "ts-node": "~10.8.1", | ||||
|     "tslint": "~6.1.3", | ||||
|     "typescript": "~4.6.3", | ||||
|     "wait-on": "~6.0.1" | ||||
|   }, | ||||
|   "optionalDependencies": { | ||||
|     "@cypress/schematic": "^1.6.0", | ||||
|     "cypress": "~9.6.0" | ||||
|     "@cypress/schematic": "^2.0.0", | ||||
|     "cypress": "~10.3.0" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -13,6 +13,7 @@ import { NotFoundComponent } from './components/not-found/not-found.component' | ||||
| import { DocumentAsnComponent } from './components/document-asn/document-asn.component' | ||||
| import { DirtyFormGuard } from './guards/dirty-form.guard' | ||||
| import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component' | ||||
| import { TasksComponent } from './components/manage/tasks/tasks.component' | ||||
|  | ||||
| const routes: Routes = [ | ||||
|   { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, | ||||
| @@ -35,6 +36,7 @@ const routes: Routes = [ | ||||
|         component: SettingsComponent, | ||||
|         canDeactivate: [DirtyFormGuard], | ||||
|       }, | ||||
|       { path: 'tasks', component: TasksComponent }, | ||||
|     ], | ||||
|   }, | ||||
|  | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import { ConsumerStatusService } from './services/consumer-status.service' | ||||
| import { ToastService } from './services/toast.service' | ||||
| import { NgxFileDropEntry } from 'ngx-file-drop' | ||||
| import { UploadDocumentsService } from './services/upload-documents.service' | ||||
| import { TasksService } from './services/tasks.service' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-root', | ||||
| @@ -27,7 +28,8 @@ export class AppComponent implements OnInit, OnDestroy { | ||||
|     private consumerStatusService: ConsumerStatusService, | ||||
|     private toastService: ToastService, | ||||
|     private router: Router, | ||||
|     private uploadDocumentsService: UploadDocumentsService | ||||
|     private uploadDocumentsService: UploadDocumentsService, | ||||
|     private tasksService: TasksService | ||||
|   ) { | ||||
|     let anyWindow = window as any | ||||
|     anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js' | ||||
| @@ -65,6 +67,7 @@ export class AppComponent implements OnInit, OnDestroy { | ||||
|     this.successSubscription = this.consumerStatusService | ||||
|       .onDocumentConsumptionFinished() | ||||
|       .subscribe((status) => { | ||||
|         this.tasksService.reload() | ||||
|         if ( | ||||
|           this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS) | ||||
|         ) { | ||||
| @@ -83,6 +86,7 @@ export class AppComponent implements OnInit, OnDestroy { | ||||
|     this.failedSubscription = this.consumerStatusService | ||||
|       .onDocumentConsumptionFailed() | ||||
|       .subscribe((status) => { | ||||
|         this.tasksService.reload() | ||||
|         if ( | ||||
|           this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED) | ||||
|         ) { | ||||
| @@ -95,6 +99,7 @@ export class AppComponent implements OnInit, OnDestroy { | ||||
|     this.newDocumentSubscription = this.consumerStatusService | ||||
|       .onDocumentDetected() | ||||
|       .subscribe((status) => { | ||||
|         this.tasksService.reload() | ||||
|         if ( | ||||
|           this.showNotification( | ||||
|             SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT | ||||
|   | ||||
| @@ -61,7 +61,7 @@ import { SafeUrlPipe } from './pipes/safeurl.pipe' | ||||
| import { SafeHtmlPipe } from './pipes/safehtml.pipe' | ||||
| import { CustomDatePipe } from './pipes/custom-date.pipe' | ||||
| 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 { ApiVersionInterceptor } from './interceptors/api-version.interceptor' | ||||
| import { ColorSliderModule } from 'ngx-color/slider' | ||||
| @@ -90,6 +90,7 @@ 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(localeCs) | ||||
| @@ -171,6 +172,7 @@ function initializeApp(settings: SettingsService) { | ||||
|     DateComponent, | ||||
|     ColorComponent, | ||||
|     DocumentAsnComponent, | ||||
|     TasksComponent, | ||||
|   ], | ||||
|   imports: [ | ||||
|     BrowserModule, | ||||
| @@ -205,7 +207,7 @@ function initializeApp(settings: SettingsService) { | ||||
|     }, | ||||
|     FilterPipe, | ||||
|     DocumentTitlePipe, | ||||
|     { provide: NgbDateAdapter, useClass: ISODateTimeAdapter }, | ||||
|     { provide: NgbDateAdapter, useClass: ISODateAdapter }, | ||||
|     { provide: NgbDateParserFormatter, useClass: LocalizedDateParserFormatter }, | ||||
|   ], | ||||
|   bootstrap: [AppComponent], | ||||
|   | ||||
| @@ -141,6 +141,13 @@ | ||||
|               </svg> <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> <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"> | ||||
|             <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()"> | ||||
|               <svg class="sidebaricon" fill="currentColor"> | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { Component } from '@angular/core' | ||||
| import { FormControl } from '@angular/forms' | ||||
| import { ActivatedRoute, Router, Params } from '@angular/router' | ||||
| import { from, Observable, Subscription, BehaviorSubject } from 'rxjs' | ||||
| import { from, Observable } from 'rxjs' | ||||
| import { | ||||
|   debounceTime, | ||||
|   distinctUntilChanged, | ||||
| @@ -15,15 +15,14 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| import { SearchService } from 'src/app/services/rest/search.service' | ||||
| import { environment } from 'src/environments/environment' | ||||
| 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 { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type' | ||||
| import { | ||||
|   RemoteVersionService, | ||||
|   AppRemoteVersion, | ||||
| } from 'src/app/services/rest/remote-version.service' | ||||
| import { QueryParamsService } from 'src/app/services/query-params.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { TasksService } from 'src/app/services/tasks.service' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-app-frame', | ||||
| @@ -38,14 +37,16 @@ export class AppFrameComponent { | ||||
|     private searchService: SearchService, | ||||
|     public savedViewService: SavedViewService, | ||||
|     private remoteVersionService: RemoteVersionService, | ||||
|     private queryParamsService: QueryParamsService, | ||||
|     public settingsService: SettingsService | ||||
|     private list: DocumentListViewService, | ||||
|     public settingsService: SettingsService, | ||||
|     public tasksService: TasksService | ||||
|   ) { | ||||
|     this.remoteVersionService | ||||
|       .checkForUpdates() | ||||
|       .subscribe((appRemoteVersion: AppRemoteVersion) => { | ||||
|         this.appRemoteVersion = appRemoteVersion | ||||
|       }) | ||||
|     tasksService.reload() | ||||
|   } | ||||
|  | ||||
|   versionString = `${environment.appTitle} ${environment.version}` | ||||
| @@ -94,7 +95,7 @@ export class AppFrameComponent { | ||||
|  | ||||
|   search() { | ||||
|     this.closeMenu() | ||||
|     this.queryParamsService.navigateWithFilterRules([ | ||||
|     this.list.quickFilter([ | ||||
|       { | ||||
|         rule_type: FILTER_FULLTEXT_QUERY, | ||||
|         value: (this.searchField.value as string).trim(), | ||||
|   | ||||
| @@ -16,13 +16,11 @@ | ||||
|   <div class="dropdown-menu py-0 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}"> | ||||
|     <div class="list-group list-group-flush"> | ||||
|       <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"> | ||||
|           <label ngbButtonLabel class="btn btn-outline-primary"> | ||||
|             <input ngbButton type="radio" class="btn-check" name="logicalOperator" value="and" i18n> All | ||||
|           </label> | ||||
|           <label ngbButtonLabel class="btn btn-outline-primary"> | ||||
|             <input ngbButton type="radio" class="btn-check" name="logicalOperator" value="or" i18n> Any | ||||
|           </label> | ||||
|         <div class="btn-group btn-group-xs flex-fill"> | ||||
|           <input [(ngModel)]="selectionModel.logicalOperator" [disabled]="!operatorToggleEnabled" (ngModelChange)="selectionModel.toggleOperator()" type="radio" class="btn-check" id="logicalOperatorAnd" value="and"> | ||||
|           <label class="btn btn-outline-primary" for="logicalOperatorAnd" i18n>All</label> | ||||
|           <input [(ngModel)]="selectionModel.logicalOperator" [disabled]="!operatorToggleEnabled" (ngModelChange)="selectionModel.toggleOperator()" type="radio" class="btn-check" id="logicalOperatorOr" value="or"> | ||||
|           <label class="btn btn-outline-primary" for="logicalOperatorOr" i18n>Any</label> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="list-group-item"> | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|   <label class="form-label" [for]="inputId">{{title}}</label> | ||||
|   <div class="input-group" [class.is-invalid]="error"> | ||||
|     <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"> | ||||
|     <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"> | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import { Component, forwardRef, OnInit } from '@angular/core' | ||||
| import { NG_VALUE_ACCESSOR } from '@angular/forms' | ||||
| import { NgbDateParserFormatter } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { LocalizedDateParserFormatter } from 'src/app/utils/ngb-date-parser-formatter' | ||||
| import { AbstractInputComponent } from '../abstract-input' | ||||
|  | ||||
| @Component({ | ||||
| @@ -19,7 +21,10 @@ export class DateComponent | ||||
|   extends AbstractInputComponent<string> | ||||
|   implements OnInit | ||||
| { | ||||
|   constructor(private settings: SettingsService) { | ||||
|   constructor( | ||||
|     private settings: SettingsService, | ||||
|     private ngbDateParserFormatter: NgbDateParserFormatter | ||||
|   ) { | ||||
|     super() | ||||
|   } | ||||
|  | ||||
| @@ -30,7 +35,20 @@ export class DateComponent | ||||
|  | ||||
|   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) { | ||||
|     if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) { | ||||
|       event.preventDefault() | ||||
|   | ||||
| @@ -1,3 +1,6 @@ | ||||
| a { | ||||
|     cursor: pointer; | ||||
|     white-space: normal; | ||||
|     word-break: break-word; | ||||
|     text-align: end; | ||||
| } | ||||
|   | ||||
| @@ -4,5 +4,5 @@ | ||||
|   [class]="toast.classname" | ||||
|   (hidden)="toastService.closeToast(toast)"> | ||||
|   <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> | ||||
|   | ||||
| @@ -12,7 +12,7 @@ | ||||
|     </thead> | ||||
|     <tbody> | ||||
|       <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> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|   | ||||
| @@ -7,8 +7,8 @@ import { ConsumerStatusService } from 'src/app/services/consumer-status.service' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { PaperlessTag } from 'src/app/data/paperless-tag' | ||||
| import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type' | ||||
| import { QueryParamsService } from 'src/app/services/query-params.service' | ||||
| import { OpenDocumentsService } from 'src/app/services/open-documents.service' | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-saved-view-widget', | ||||
| @@ -21,7 +21,7 @@ export class SavedViewWidgetComponent implements OnInit, OnDestroy { | ||||
|   constructor( | ||||
|     private documentService: DocumentService, | ||||
|     private router: Router, | ||||
|     private queryParamsService: QueryParamsService, | ||||
|     private list: DocumentListViewService, | ||||
|     private consumerStatusService: ConsumerStatusService, | ||||
|     public openDocumentsService: OpenDocumentsService | ||||
|   ) {} | ||||
| @@ -47,7 +47,7 @@ export class SavedViewWidgetComponent implements OnInit, OnDestroy { | ||||
|   } | ||||
|  | ||||
|   reload() { | ||||
|     this.loading = true | ||||
|     this.loading = this.documents.length == 0 | ||||
|     this.documentService | ||||
|       .listFiltered( | ||||
|         1, | ||||
| @@ -73,7 +73,7 @@ export class SavedViewWidgetComponent implements OnInit, OnDestroy { | ||||
|   } | ||||
|  | ||||
|   clickTag(tag: PaperlessTag) { | ||||
|     this.queryParamsService.navigateWithFilterRules([ | ||||
|     this.list.quickFilter([ | ||||
|       { rule_type: FILTER_HAS_TAGS_ALL, value: tag.id.toString() }, | ||||
|     ]) | ||||
|   } | ||||
|   | ||||
| @@ -68,7 +68,7 @@ | ||||
|  | ||||
|                         <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-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" | ||||
|                             (createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents"></app-input-select> | ||||
|                         <app-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" | ||||
|   | ||||
| @@ -14,10 +14,14 @@ | ||||
| } | ||||
|  | ||||
| ::ng-deep .ng2-pdf-viewer-container .page { | ||||
|   --page-margin: 1px 0 -8px; | ||||
|   --page-margin: 1px 0 10px; | ||||
|   width: 100% !important; | ||||
| } | ||||
|  | ||||
| ::ng-deep .ng2-pdf-viewer-container .page:last-child { | ||||
|   --page-margin: 1px 0 20px; | ||||
| } | ||||
|  | ||||
| .password-prompt { | ||||
|   position: absolute; | ||||
|   top: 30%; | ||||
|   | ||||
| @@ -31,8 +31,6 @@ import { | ||||
| } from 'rxjs/operators' | ||||
| import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions' | ||||
| import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type' | ||||
| import { normalizeDateStr } from 'src/app/utils/date' | ||||
| import { QueryParamsService } from 'src/app/services/query-params.service' | ||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||
| import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' | ||||
| import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' | ||||
| @@ -74,7 +72,7 @@ export class DocumentDetailComponent | ||||
|   documentForm: FormGroup = new FormGroup({ | ||||
|     title: new FormControl(''), | ||||
|     content: new FormControl(''), | ||||
|     created: new FormControl(), | ||||
|     created_date: new FormControl(), | ||||
|     correspondent: new FormControl(), | ||||
|     document_type: new FormControl(), | ||||
|     storage_path: new FormControl(), | ||||
| @@ -120,8 +118,7 @@ export class DocumentDetailComponent | ||||
|     private documentTitlePipe: DocumentTitlePipe, | ||||
|     private toastService: ToastService, | ||||
|     private settings: SettingsService, | ||||
|     private storagePathService: StoragePathService, | ||||
|     private queryParamsService: QueryParamsService | ||||
|     private storagePathService: StoragePathService | ||||
|   ) {} | ||||
|  | ||||
|   titleKeyUp(event) { | ||||
| @@ -141,27 +138,8 @@ export class DocumentDetailComponent | ||||
|   ngOnInit(): void { | ||||
|     this.documentForm.valueChanges | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe((changes) => { | ||||
|       .subscribe(() => { | ||||
|         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) | ||||
|       }) | ||||
|  | ||||
| @@ -233,13 +211,11 @@ export class DocumentDetailComponent | ||||
|               }, | ||||
|             }) | ||||
|  | ||||
|           this.ogDate = new Date(normalizeDateStr(doc.created.toString())) | ||||
|  | ||||
|           // Initialize dirtyCheck | ||||
|           this.store = new BehaviorSubject({ | ||||
|             title: doc.title, | ||||
|             content: doc.content, | ||||
|             created: this.ogDate.toISOString(), | ||||
|             created_date: doc.created_date, | ||||
|             correspondent: doc.correspondent, | ||||
|             document_type: doc.document_type, | ||||
|             storage_path: doc.storage_path, | ||||
| @@ -247,12 +223,6 @@ export class DocumentDetailComponent | ||||
|             tags: [...doc.tags], | ||||
|           }) | ||||
|  | ||||
|           // start with ISO8601 string | ||||
|           this.documentForm.patchValue( | ||||
|             { created: this.ogDate.toISOString() }, | ||||
|             { emitEvent: false } | ||||
|           ) | ||||
|  | ||||
|           this.isDirty$ = dirtyCheck( | ||||
|             this.documentForm, | ||||
|             this.store.asObservable() | ||||
| @@ -494,7 +464,7 @@ export class DocumentDetailComponent | ||||
|   } | ||||
|  | ||||
|   moreLike() { | ||||
|     this.queryParamsService.navigateWithFilterRules([ | ||||
|     this.documentListViewService.quickFilter([ | ||||
|       { | ||||
|         rule_type: FILTER_FULLTEXT_MORELIKE, | ||||
|         value: this.documentId.toString(), | ||||
|   | ||||
| @@ -66,21 +66,28 @@ | ||||
|   </div> | ||||
|   <div class="col-auto ms-auto mb-2 mb-xl-0 d-flex"> | ||||
|     <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"> | ||||
|           <use xlink:href="assets/bootstrap-icons.svg#download" /> | ||||
|  | ||||
|       <div ngbDropdown class="me-2 d-flex"> | ||||
|         <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle> | ||||
|           <svg class="toolbaricon" fill="currentColor"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#three-dots" /> | ||||
|           </svg> | ||||
|           <div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div> | ||||
|         </button> | ||||
|         <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||
|           <button ngbDropdownItem [disabled]="awaitingDownload" (click)="downloadSelected()" i18n> | ||||
|             Download | ||||
|             <div *ngIf="awaitingDownload" class="spinner-border spinner-border-sm" role="status"> | ||||
|               <span class="visually-hidden">Preparing download...</span> | ||||
|             </div> | ||||
|           | ||||
|         <ng-container i18n>Download</ng-container> | ||||
|           </button> | ||||
|       <div class="btn-group" ngbDropdown role="group" aria-label="Button group with nested dropdown"> | ||||
|         <button [disabled]="awaitingDownload" class="btn btn-outline-primary btn-sm dropdown-toggle-split" ngbDropdownToggle></button> | ||||
|         <div class="dropdown-menu shadow" ngbDropdownMenu> | ||||
|           <button ngbDropdownItem i18n (click)="downloadSelected('originals')">Download originals</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> | ||||
|  | ||||
|   | ||||
| @@ -379,4 +379,19 @@ export class BulkEditorComponent { | ||||
|         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', {}) | ||||
|     }) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -92,7 +92,7 @@ | ||||
|                 <path d="M11 6.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1z"/> | ||||
|                 <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/> | ||||
|               </svg> | ||||
|               <small>{{document.created | customDate:'mediumDate'}}</small> | ||||
|               <small>{{document.created_date | customDate:'mediumDate'}}</small> | ||||
|             </div> | ||||
|             <div *ngIf="document.__search_hit__?.score" class="list-group-item bg-light text-dark border-0 d-flex p-0 ps-4 search-score"> | ||||
|               <small class="text-muted" i18n>Score:</small> | ||||
|   | ||||
| @@ -10,10 +10,8 @@ | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div style="top: 0; right: 0; font-size: large" class="text-end position-absolute me-1"> | ||||
|         <div *ngFor="let t of getTagsLimited$() | async"> | ||||
|           <app-tag [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Filter by tag" i18n-linkTitle></app-tag> | ||||
|         </div> | ||||
|       <div class="tags d-flex flex-column text-end position-absolute me-1 fs-6"> | ||||
|         <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> | ||||
|         <div *ngIf="moreTags"> | ||||
|           <span class="badge badge-secondary">+ {{moreTags}}</span> | ||||
|         </div> | ||||
| @@ -23,21 +21,21 @@ | ||||
|     <div class="card-body p-2"> | ||||
|       <p class="card-text"> | ||||
|         <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> | ||||
|         {{document.title | documentTitle}} | ||||
|       </p> | ||||
|     </div> | ||||
|     <div class="card-footer pt-0 pb-2 px-2"> | ||||
|       <div class="list-group list-group-flush border-0 pt-1 pb-2 card-info"> | ||||
|         <button *ngIf="document.document_type" type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Filter by document type" i18n-title | ||||
|         <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()"> | ||||
|           <svg class="metadata-icon me-2 text-muted bi bi-file-earmark" viewBox="0 0 16 16" fill="currentColor"> | ||||
|             <path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/> | ||||
|           </svg> | ||||
|           <small>{{(document.document_type$ | async)?.name}}</small> | ||||
|         </button> | ||||
|         <button *ngIf="document.storage_path" type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Filter by storage path" i18n-title | ||||
|         <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"/> | ||||
| @@ -57,7 +55,7 @@ | ||||
|               <path d="M11 6.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1z"/> | ||||
|               <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/> | ||||
|             </svg> | ||||
|             <small>{{document.created | customDate:'mediumDate'}}</small> | ||||
|             <small>{{document.created_date | customDate:'mediumDate'}}</small> | ||||
|           </div> | ||||
|           <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"> | ||||
|   | ||||
| @@ -78,3 +78,11 @@ | ||||
| a { | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| .tags { | ||||
|   top: 0; | ||||
|   right: 0; | ||||
|   max-width: 80%; | ||||
|   row-gap: .2rem; | ||||
|   line-height: 1; | ||||
| } | ||||
|   | ||||
| @@ -13,23 +13,21 @@ | ||||
|       <button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button> | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
|   <div class="btn-group btn-group-toggle flex-fill" ngbRadioGroup [(ngModel)]="displayMode" | ||||
|     (ngModelChange)="saveDisplayMode()"> | ||||
|     <label ngbButtonLabel class="btn-outline-primary btn-sm"> | ||||
|       <input ngbButton type="radio" class="btn-check btn-sm" value="details"> | ||||
|   <div class="btn-group flex-fill" role="group"> | ||||
|     <input type="radio" class="btn-check" [(ngModel)]="displayMode" value="details" (ngModelChange)="saveDisplayMode()" id="displayModeDetails"> | ||||
|     <label for="displayModeDetails" class="btn btn-outline-primary btn-sm"> | ||||
|       <svg class="toolbaricon" fill="currentColor"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#list-ul" /> | ||||
|       </svg> | ||||
|     </label> | ||||
|     <label ngbButtonLabel class="btn-outline-primary btn-sm"> | ||||
|       <input ngbButton type="radio" class="btn-check btn-sm" value="smallCards"> | ||||
|     <input type="radio" class="btn-check" [(ngModel)]="displayMode" value="smallCards" (ngModelChange)="saveDisplayMode()" id="displayModeSmall"> | ||||
|     <label for="displayModeSmall" class="btn btn-outline-primary btn-sm"> | ||||
|       <svg class="toolbaricon" fill="currentColor"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#grid" /> | ||||
|       </svg> | ||||
|     </label> | ||||
|     <label ngbButtonLabel class="btn-outline-primary btn-sm"> | ||||
|       <input ngbButton type="radio" class="btn-check btn-sm" value="largeCards"> | ||||
|     <input type="radio" class="btn-check" [(ngModel)]="displayMode" value="largeCards" (ngModelChange)="saveDisplayMode()" id="displayModeLarge"> | ||||
|     <label for="displayModeLarge" class="btn btn-outline-primary btn-sm"> | ||||
|       <svg class="toolbaricon" fill="currentColor"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#hdd-stack" /> | ||||
|       </svg> | ||||
| @@ -39,15 +37,15 @@ | ||||
|   <div ngbDropdown class="btn-group ms-2 flex-fill"> | ||||
|     <button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle i18n>Sort</button> | ||||
|     <div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right"> | ||||
|       <div class="w-100 d-flex btn-group-toggle pb-2 mb-1 border-bottom" ngbRadioGroup [(ngModel)]="listSort"> | ||||
|         <label ngbButtonLabel class="btn-outline-primary btn-sm mx-2 flex-fill"> | ||||
|           <input ngbButton type="radio" class="btn btn-check btn-sm" [value]="false"> | ||||
|       <div class="w-100 d-flex pb-2 mb-1 border-bottom"> | ||||
|         <input type="radio" class="btn-check" [value]="false" [(ngModel)]="listSortReverse" id="listSortReverseFalse"> | ||||
|         <label class="btn btn-outline-primary btn-sm mx-2 flex-fill" for="listSortReverseFalse"> | ||||
|           <svg class="toolbaricon" fill="currentColor"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#sort-alpha-down" /> | ||||
|           </svg> | ||||
|         </label> | ||||
|         <label ngbButtonLabel class="btn-outline-primary btn-sm me-2 flex-fill"> | ||||
|           <input ngbButton type="radio" class="btn btn-check btn-sm" [value]="true"> | ||||
|         <input type="radio" class="btn-check" [value]="true" [(ngModel)]="listSortReverse" id="listSortReverseTrue"> | ||||
|         <label class="btn btn-outline-primary btn-sm me-2 flex-fill" for="listSortReverseTrue"> | ||||
|           <svg class="toolbaricon" fill="currentColor"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#sort-alpha-up-alt" /> | ||||
|           </svg> | ||||
| @@ -93,7 +91,7 @@ | ||||
|         <span i18n *ngIf="list.selected.size == 0">{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span> <span i18n *ngIf="isFiltered">(filtered)</span> | ||||
|       </ng-container> | ||||
|     </p> | ||||
|     <ngb-pagination [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> | ||||
|   </div> | ||||
| </ng-template> | ||||
| @@ -188,7 +186,7 @@ | ||||
|           </ng-container> | ||||
|         </td> | ||||
|         <td> | ||||
|           {{d.created | customDate}} | ||||
|           {{d.created_date | customDate}} | ||||
|         </td> | ||||
|         <td class="d-none d-xl-table-cell"> | ||||
|           {{d.added | customDate}} | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| import { | ||||
|   AfterViewInit, | ||||
|   Component, | ||||
|   OnDestroy, | ||||
|   OnInit, | ||||
| @@ -21,7 +20,6 @@ import { | ||||
| import { ConsumerStatusService } from 'src/app/services/consumer-status.service' | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service' | ||||
| import { OpenDocumentsService } from 'src/app/services/open-documents.service' | ||||
| import { QueryParamsService } from 'src/app/services/query-params.service' | ||||
| import { | ||||
|   DOCUMENT_SORT_FIELDS, | ||||
|   DOCUMENT_SORT_FIELDS_FULLTEXT, | ||||
| @@ -36,7 +34,7 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi | ||||
|   templateUrl: './document-list.component.html', | ||||
|   styleUrls: ['./document-list.component.scss'], | ||||
| }) | ||||
| export class DocumentListComponent implements OnInit, OnDestroy, AfterViewInit { | ||||
| export class DocumentListComponent implements OnInit, OnDestroy { | ||||
|   constructor( | ||||
|     public list: DocumentListViewService, | ||||
|     public savedViewService: SavedViewService, | ||||
| @@ -45,7 +43,6 @@ export class DocumentListComponent implements OnInit, OnDestroy, AfterViewInit { | ||||
|     private toastService: ToastService, | ||||
|     private modalService: NgbModal, | ||||
|     private consumerStatusService: ConsumerStatusService, | ||||
|     private queryParamsService: QueryParamsService, | ||||
|     public openDocumentsService: OpenDocumentsService | ||||
|   ) {} | ||||
|  | ||||
| @@ -74,26 +71,24 @@ export class DocumentListComponent implements OnInit, OnDestroy, AfterViewInit { | ||||
|       : DOCUMENT_SORT_FIELDS | ||||
|   } | ||||
|  | ||||
|   set listSort(reverse: boolean) { | ||||
|   set listSortReverse(reverse: boolean) { | ||||
|     this.list.sortReverse = reverse | ||||
|     this.queryParamsService.sortField = this.list.sortField | ||||
|     this.queryParamsService.sortReverse = reverse | ||||
|   } | ||||
|  | ||||
|   get listSort(): boolean { | ||||
|   get listSortReverse(): boolean { | ||||
|     return this.list.sortReverse | ||||
|   } | ||||
|  | ||||
|   setSortField(field: string) { | ||||
|     this.list.sortField = field | ||||
|     this.queryParamsService.sortField = field | ||||
|     this.queryParamsService.sortReverse = this.listSort | ||||
|   } | ||||
|  | ||||
|   onSort(event: SortEvent) { | ||||
|     this.list.setSort(event.column, event.reverse) | ||||
|     this.queryParamsService.sortField = event.column | ||||
|     this.queryParamsService.sortReverse = event.reverse | ||||
|   } | ||||
|  | ||||
|   setPage(page: number) { | ||||
|     this.list.currentPage = page | ||||
|   } | ||||
|  | ||||
|   get isBulkEditing(): boolean { | ||||
| @@ -133,7 +128,6 @@ export class DocumentListComponent implements OnInit, OnDestroy, AfterViewInit { | ||||
|         } | ||||
|         this.list.activateSavedView(view) | ||||
|         this.list.reload() | ||||
|         this.queryParamsService.updateFromView(view) | ||||
|         this.unmodifiedFilterRules = view.filter_rules | ||||
|       }) | ||||
|  | ||||
| @@ -148,22 +142,12 @@ export class DocumentListComponent implements OnInit, OnDestroy, AfterViewInit { | ||||
|           this.loadViewConfig(parseInt(queryParams.get('view'))) | ||||
|         } else { | ||||
|           this.list.activateSavedView(null) | ||||
|           this.queryParamsService.parseQueryParams(queryParams) | ||||
|           this.list.loadFromQueryParams(queryParams) | ||||
|           this.unmodifiedFilterRules = [] | ||||
|         } | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   ngAfterViewInit(): void { | ||||
|     this.filterEditor.filterRulesChange | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe({ | ||||
|         next: (filterRules) => { | ||||
|           this.queryParamsService.updateFilterRules(filterRules) | ||||
|         }, | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy() { | ||||
|     // unsubscribes all | ||||
|     this.unsubscribeNotifier.next(this) | ||||
| @@ -175,9 +159,8 @@ export class DocumentListComponent implements OnInit, OnDestroy, AfterViewInit { | ||||
|       .getCached(viewId) | ||||
|       .pipe(first()) | ||||
|       .subscribe((view) => { | ||||
|         this.list.loadSavedView(view) | ||||
|         this.list.activateSavedView(view) | ||||
|         this.list.reload() | ||||
|         this.queryParamsService.updateFromView(view) | ||||
|       }) | ||||
|   } | ||||
|  | ||||
| @@ -246,34 +229,26 @@ export class DocumentListComponent implements OnInit, OnDestroy, AfterViewInit { | ||||
|  | ||||
|   clickTag(tagID: number) { | ||||
|     this.list.selectNone() | ||||
|     setTimeout(() => { | ||||
|       this.filterEditor.addTag(tagID) | ||||
|     }) | ||||
|     this.filterEditor.toggleTag(tagID) | ||||
|   } | ||||
|  | ||||
|   clickCorrespondent(correspondentID: number) { | ||||
|     this.list.selectNone() | ||||
|     setTimeout(() => { | ||||
|       this.filterEditor.addCorrespondent(correspondentID) | ||||
|     }) | ||||
|     this.filterEditor.toggleCorrespondent(correspondentID) | ||||
|   } | ||||
|  | ||||
|   clickDocumentType(documentTypeID: number) { | ||||
|     this.list.selectNone() | ||||
|     setTimeout(() => { | ||||
|       this.filterEditor.addDocumentType(documentTypeID) | ||||
|     }) | ||||
|     this.filterEditor.toggleDocumentType(documentTypeID) | ||||
|   } | ||||
|  | ||||
|   clickStoragePath(storagePathID: number) { | ||||
|     this.list.selectNone() | ||||
|     setTimeout(() => { | ||||
|       this.filterEditor.addStoragePath(storagePathID) | ||||
|     }) | ||||
|     this.filterEditor.toggleStoragePath(storagePathID) | ||||
|   } | ||||
|  | ||||
|   clickMoreLike(documentID: number) { | ||||
|     this.queryParamsService.navigateWithFilterRules([ | ||||
|     this.list.quickFilter([ | ||||
|       { rule_type: FILTER_FULLTEXT_MORELIKE, value: documentID.toString() }, | ||||
|     ]) | ||||
|   } | ||||
|   | ||||
| @@ -16,7 +16,7 @@ import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators' | ||||
| import { DocumentTypeService } from 'src/app/services/rest/document-type.service' | ||||
| import { TagService } from 'src/app/services/rest/tag.service' | ||||
| import { CorrespondentService } from 'src/app/services/rest/correspondent.service' | ||||
| import { FilterRule } from 'src/app/data/filter-rule' | ||||
| import { filterRulesDiffer, FilterRule } from 'src/app/data/filter-rule' | ||||
| import { | ||||
|   FILTER_ADDED_AFTER, | ||||
|   FILTER_ADDED_BEFORE, | ||||
| @@ -204,7 +204,10 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|   @Input() | ||||
|   set unmodifiedFilterRules(value: FilterRule[]) { | ||||
|     this._unmodifiedFilterRules = value | ||||
|     this.checkIfRulesHaveChanged() | ||||
|     this.rulesModified = filterRulesDiffer( | ||||
|       this._unmodifiedFilterRules, | ||||
|       this._filterRules | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   get unmodifiedFilterRules(): FilterRule[] { | ||||
| @@ -313,7 +316,10 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|           break | ||||
|         case FILTER_ASN_ISNULL: | ||||
|           this.textFilterTarget = TEXT_FILTER_TARGET_ASN | ||||
|           this.textFilterModifier = TEXT_FILTER_MODIFIER_NULL | ||||
|           this.textFilterModifier = | ||||
|             rule.value == 'true' || rule.value == '1' | ||||
|               ? TEXT_FILTER_MODIFIER_NULL | ||||
|               : TEXT_FILTER_MODIFIER_NOTNULL | ||||
|           break | ||||
|         case FILTER_ASN_GT: | ||||
|           this.textFilterTarget = TEXT_FILTER_TARGET_ASN | ||||
| @@ -327,7 +333,10 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|           break | ||||
|       } | ||||
|     }) | ||||
|     this.checkIfRulesHaveChanged() | ||||
|     this.rulesModified = filterRulesDiffer( | ||||
|       this._unmodifiedFilterRules, | ||||
|       this._filterRules | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   get filterRules(): FilterRule[] { | ||||
| @@ -470,31 +479,6 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|  | ||||
|   rulesModified: boolean = false | ||||
|  | ||||
|   private checkIfRulesHaveChanged() { | ||||
|     let modified = false | ||||
|     if (this._unmodifiedFilterRules.length != this._filterRules.length) { | ||||
|       modified = true | ||||
|     } else { | ||||
|       modified = this._unmodifiedFilterRules.some((rule) => { | ||||
|         return ( | ||||
|           this._filterRules.find( | ||||
|             (fri) => fri.rule_type == rule.rule_type && fri.value == rule.value | ||||
|           ) == undefined | ||||
|         ) | ||||
|       }) | ||||
|  | ||||
|       if (!modified) { | ||||
|         // only check other direction if we havent already determined is modified | ||||
|         modified = this._filterRules.some((rule) => { | ||||
|           this._unmodifiedFilterRules.find( | ||||
|             (fr) => fr.rule_type == rule.rule_type && fr.value == rule.value | ||||
|           ) == undefined | ||||
|         }) | ||||
|       } | ||||
|     } | ||||
|     this.rulesModified = modified | ||||
|   } | ||||
|  | ||||
|   updateRules() { | ||||
|     this.filterRulesChange.next(this.filterRules) | ||||
|   } | ||||
| @@ -547,29 +531,20 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | ||||
|     this.updateRules() | ||||
|   } | ||||
|  | ||||
|   addTag(tagId: number) { | ||||
|     this.tagSelectionModel.set(tagId, ToggleableItemState.Selected) | ||||
|   toggleTag(tagId: number) { | ||||
|     this.tagSelectionModel.toggle(tagId) | ||||
|   } | ||||
|  | ||||
|   addCorrespondent(correspondentId: number) { | ||||
|     this.correspondentSelectionModel.set( | ||||
|       correspondentId, | ||||
|       ToggleableItemState.Selected | ||||
|     ) | ||||
|   toggleCorrespondent(correspondentId: number) { | ||||
|     this.correspondentSelectionModel.toggle(correspondentId) | ||||
|   } | ||||
|  | ||||
|   addDocumentType(documentTypeId: number) { | ||||
|     this.documentTypeSelectionModel.set( | ||||
|       documentTypeId, | ||||
|       ToggleableItemState.Selected | ||||
|     ) | ||||
|   toggleDocumentType(documentTypeId: number) { | ||||
|     this.documentTypeSelectionModel.toggle(documentTypeId) | ||||
|   } | ||||
|  | ||||
|   addStoragePath(storagePathID: number) { | ||||
|     this.storagePathSelectionModel.set( | ||||
|       storagePathID, | ||||
|       ToggleableItemState.Selected | ||||
|     ) | ||||
|   toggleStoragePath(storagePathID: number) { | ||||
|     this.storagePathSelectionModel.toggle(storagePathID) | ||||
|   } | ||||
|  | ||||
|   onTagsDropdownOpen() { | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { FILTER_CORRESPONDENT } from 'src/app/data/filter-rule-type' | ||||
| import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent' | ||||
| import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' | ||||
| import { QueryParamsService } from 'src/app/services/query-params.service' | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service' | ||||
| import { CorrespondentService } from 'src/app/services/rest/correspondent.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' | ||||
| @@ -20,7 +20,7 @@ export class CorrespondentListComponent extends ManagementListComponent<Paperles | ||||
|     correspondentsService: CorrespondentService, | ||||
|     modalService: NgbModal, | ||||
|     toastService: ToastService, | ||||
|     queryParamsService: QueryParamsService, | ||||
|     documentListViewService: DocumentListViewService, | ||||
|     private datePipe: CustomDatePipe | ||||
|   ) { | ||||
|     super( | ||||
| @@ -28,7 +28,7 @@ export class CorrespondentListComponent extends ManagementListComponent<Paperles | ||||
|       modalService, | ||||
|       CorrespondentEditDialogComponent, | ||||
|       toastService, | ||||
|       queryParamsService, | ||||
|       documentListViewService, | ||||
|       FILTER_CORRESPONDENT, | ||||
|       $localize`correspondent`, | ||||
|       $localize`correspondents`, | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { Component } from '@angular/core' | ||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { FILTER_DOCUMENT_TYPE } from 'src/app/data/filter-rule-type' | ||||
| import { PaperlessDocumentType } from 'src/app/data/paperless-document-type' | ||||
| import { QueryParamsService } from 'src/app/services/query-params.service' | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service' | ||||
| import { DocumentTypeService } from 'src/app/services/rest/document-type.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' | ||||
| @@ -18,14 +18,14 @@ export class DocumentTypeListComponent extends ManagementListComponent<Paperless | ||||
|     documentTypeService: DocumentTypeService, | ||||
|     modalService: NgbModal, | ||||
|     toastService: ToastService, | ||||
|     queryParamsService: QueryParamsService | ||||
|     documentListViewService: DocumentListViewService | ||||
|   ) { | ||||
|     super( | ||||
|       documentTypeService, | ||||
|       modalService, | ||||
|       DocumentTypeEditDialogComponent, | ||||
|       toastService, | ||||
|       queryParamsService, | ||||
|       documentListViewService, | ||||
|       FILTER_DOCUMENT_TYPE, | ||||
|       $localize`document type`, | ||||
|       $localize`document types`, | ||||
|   | ||||
| @@ -18,7 +18,7 @@ import { | ||||
|   SortableDirective, | ||||
|   SortEvent, | ||||
| } from 'src/app/directives/sortable.directive' | ||||
| import { QueryParamsService } from 'src/app/services/query-params.service' | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service' | ||||
| import { AbstractNameFilterService } from 'src/app/services/rest/abstract-name-filter-service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| @@ -42,7 +42,7 @@ export abstract class ManagementListComponent<T extends ObjectWithId> | ||||
|     private modalService: NgbModal, | ||||
|     private editDialogComponent: any, | ||||
|     private toastService: ToastService, | ||||
|     private queryParamsService: QueryParamsService, | ||||
|     private documentListViewService: DocumentListViewService, | ||||
|     protected filterRuleType: number, | ||||
|     public typeName: string, | ||||
|     public typeNamePlural: string, | ||||
| @@ -141,7 +141,7 @@ export abstract class ManagementListComponent<T extends ObjectWithId> | ||||
|   } | ||||
|  | ||||
|   filterDocuments(object: ObjectWithId) { | ||||
|     this.queryParamsService.navigateWithFilterRules([ | ||||
|     this.documentListViewService.quickFilter([ | ||||
|       { rule_type: this.filterRuleType, value: object.id.toString() }, | ||||
|     ]) | ||||
|   } | ||||
|   | ||||
| @@ -22,7 +22,7 @@ | ||||
|               <option *ngFor="let lang of displayLanguageOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code && currentLocale != 'en-US'"> - {{lang.englishName}}</span></option> | ||||
|             </select> | ||||
|  | ||||
|             <small class="form-text text-muted" i18n>You need to reload the page after applying a new language.</small> | ||||
|             <small *ngIf="displayLanguageIsDirty" class="form-text text-primary" i18n>You need to reload the page after applying a new language.</small> | ||||
|  | ||||
|           </div> | ||||
|         </div> | ||||
|   | ||||
| @@ -14,7 +14,7 @@ import { | ||||
|   LanguageOption, | ||||
|   SettingsService, | ||||
| } from 'src/app/services/settings.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { Toast, ToastService } from 'src/app/services/toast.service' | ||||
| import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms' | ||||
| import { Observable, Subscription, BehaviorSubject, first } from 'rxjs' | ||||
| import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings' | ||||
| @@ -61,6 +61,13 @@ export class SettingsComponent implements OnInit, OnDestroy, DirtyComponent { | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   get displayLanguageIsDirty(): boolean { | ||||
|     return ( | ||||
|       this.settingsForm.get('displayLanguage').value != | ||||
|       this.store?.getValue()['displayLanguage'] | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     public savedViewService: SavedViewService, | ||||
|     private documentListViewService: DocumentListViewService, | ||||
| @@ -170,6 +177,7 @@ export class SettingsComponent implements OnInit, OnDestroy, DirtyComponent { | ||||
|   } | ||||
|  | ||||
|   private saveLocalSettings() { | ||||
|     const reloadRequired = this.displayLanguageIsDirty // just this one, for now | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.BULK_EDIT_APPLY_ON_CLOSE, | ||||
|       this.settingsForm.value.bulkEditApplyOnClose | ||||
| @@ -235,7 +243,20 @@ export class SettingsComponent implements OnInit, OnDestroy, DirtyComponent { | ||||
|           this.store.next(this.settingsForm.value) | ||||
|           this.documentListViewService.updatePageSize() | ||||
|           this.settings.updateAppearanceSettings() | ||||
|           this.toastService.showInfo($localize`Settings saved successfully.`) | ||||
|           let savedToast: Toast = { | ||||
|             title: $localize`Settings saved`, | ||||
|             content: $localize`Settings were saved successfully.`, | ||||
|             delay: 500000, | ||||
|           } | ||||
|           if (reloadRequired) { | ||||
|             ;(savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.`), | ||||
|               (savedToast.actionName = $localize`Reload now`) | ||||
|             savedToast.action = () => { | ||||
|               location.reload() | ||||
|             } | ||||
|           } | ||||
|  | ||||
|           this.toastService.show(savedToast) | ||||
|         }, | ||||
|         error: (error) => { | ||||
|           this.toastService.showError( | ||||
|   | ||||
| @@ -3,7 +3,6 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { FILTER_STORAGE_PATH } from 'src/app/data/filter-rule-type' | ||||
| import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service' | ||||
| import { QueryParamsService } from 'src/app/services/query-params.service' | ||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' | ||||
| @@ -19,14 +18,14 @@ export class StoragePathListComponent extends ManagementListComponent<PaperlessS | ||||
|     directoryService: StoragePathService, | ||||
|     modalService: NgbModal, | ||||
|     toastService: ToastService, | ||||
|     queryParamsService: QueryParamsService | ||||
|     documentListViewService: DocumentListViewService | ||||
|   ) { | ||||
|     super( | ||||
|       directoryService, | ||||
|       modalService, | ||||
|       StoragePathEditDialogComponent, | ||||
|       toastService, | ||||
|       queryParamsService, | ||||
|       documentListViewService, | ||||
|       FILTER_STORAGE_PATH, | ||||
|       $localize`storage path`, | ||||
|       $localize`storage paths`, | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { Component } from '@angular/core' | ||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type' | ||||
| import { PaperlessTag } from 'src/app/data/paperless-tag' | ||||
| import { QueryParamsService } from 'src/app/services/query-params.service' | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service' | ||||
| import { TagService } from 'src/app/services/rest/tag.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' | ||||
| @@ -18,14 +18,14 @@ export class TagListComponent extends ManagementListComponent<PaperlessTag> { | ||||
|     tagService: TagService, | ||||
|     modalService: NgbModal, | ||||
|     toastService: ToastService, | ||||
|     queryParamsService: QueryParamsService | ||||
|     documentListViewService: DocumentListViewService | ||||
|   ) { | ||||
|     super( | ||||
|       tagService, | ||||
|       modalService, | ||||
|       TagEditDialogComponent, | ||||
|       toastService, | ||||
|       queryParamsService, | ||||
|       documentListViewService, | ||||
|       FILTER_HAS_TAGS_ALL, | ||||
|       $localize`tag`, | ||||
|       $localize`tags`, | ||||
|   | ||||
							
								
								
									
										120
									
								
								src-ui/src/app/components/manage/tasks/tasks.component.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,120 @@ | ||||
| <app-page-header title="File Tasks" i18n-title> | ||||
|   <div class="btn-toolbar col col-md-auto"> | ||||
|     <button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size == 0"> | ||||
|       <svg class="sidebaricon" fill="currentColor"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#x"/> | ||||
|       </svg> <ng-container i18n>Clear selection</ng-container> | ||||
|     </button> | ||||
|     <button class="btn btn-sm btn-outline-primary me-4" (click)="dismissTasks()" [disabled]="tasksService.total == 0"> | ||||
|       <svg class="sidebaricon" fill="currentColor"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#check2-all"/> | ||||
|       </svg> <ng-container i18n>{{dismissButtonText}}</ng-container> | ||||
|     </button> | ||||
|     <button class="btn btn-sm btn-outline-primary" (click)="tasksService.reload()"> | ||||
|       <svg *ngIf="!tasksService.loading" class="sidebaricon" fill="currentColor"> | ||||
|         <use xlink:href="assets/bootstrap-icons.svg#arrow-clockwise"/> | ||||
|       </svg> | ||||
|       <ng-container *ngIf="tasksService.loading"> | ||||
|         <div class="spinner-border spinner-border-sm fw-normal" role="status"></div> | ||||
|         <div class="visually-hidden" i18n>Loading...</div> | ||||
|       </ng-container> <ng-container i18n>Refresh</ng-container> | ||||
|     </button> | ||||
|   </div> | ||||
| </app-page-header> | ||||
|  | ||||
| <ng-container *ngIf="!tasksService.completedFileTasks && tasksService.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-template let-tasks="tasks" #tasksTemplate> | ||||
|   <table class="table table-striped align-middle border shadow-sm"> | ||||
|     <thead> | ||||
|       <tr> | ||||
|         <th scope="col"> | ||||
|           <div class="form-check"> | ||||
|             <input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length == 0" (click)="toggleAll($event); $event.stopPropagation();"> | ||||
|             <label class="form-check-label" for="all-tasks"></label> | ||||
|           </div> | ||||
|         </th> | ||||
|         <th scope="col" i18n>Name</th> | ||||
|         <th scope="col" class="d-none d-lg-table-cell" i18n>Created</th> | ||||
|         <th scope="col" class="d-none d-lg-table-cell" *ngIf="activeTab != 'started' && activeTab != 'queued'" i18n>Results</th> | ||||
|         <th scope="col" class="d-table-cell d-lg-none" i18n>Info</th> | ||||
|         <th scope="col" i18n>Actions</th> | ||||
|       </tr> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|       <ng-container *ngFor="let task of tasks"> | ||||
|       <tr (click)="toggleSelected(task, $event); $event.stopPropagation();"> | ||||
|         <th> | ||||
|           <div class="form-check"> | ||||
|             <input type="checkbox" class="form-check-input" id="task{{task.id}}" [checked]="selectedTasks.has(task.id)" (click)="toggleSelected(task, $event); $event.stopPropagation();"> | ||||
|             <label class="form-check-label" for="task{{task.id}}"></label> | ||||
|           </div> | ||||
|         </th> | ||||
|         <td class="overflow-auto">{{ task.name }}</td> | ||||
|         <td class="d-none d-lg-table-cell">{{ task.created | customDate:'short' }}</td> | ||||
|         <td class="d-none d-lg-table-cell" *ngIf="activeTab != 'incomplete'"> | ||||
|           <div *ngIf="task.result.length > 50" class="result" (click)="expandTask(task); $event.stopPropagation();" | ||||
|             [ngbPopover]="resultPopover" popoverClass="shadow small mobile" triggers="mouseenter:mouseleave" container="body"> | ||||
|             <span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result | slice:0:50 }}…</span> | ||||
|           </div> | ||||
|           <span *ngIf="task.result.length <= 50" class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result }}</span> | ||||
|           <ng-template #resultPopover> | ||||
|             <pre class="small mb-0">{{ task.result | slice:0:300 }}<ng-container *ngIf="task.result.length > 300">…</ng-container></pre> | ||||
|             <ng-container *ngIf="task.result.length > 300"><br/><em>(<ng-container i18n>click for full output</ng-container>)</em></ng-container> | ||||
|           </ng-template> | ||||
|         </td> | ||||
|         <td class="d-lg-none"> | ||||
|           <button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();"> | ||||
|             <svg fill="currentColor" class="" width="1.2em" height="1.2em" style="vertical-align: text-top;" viewBox="0 0 16 16"> | ||||
|               <use xlink:href="assets/bootstrap-icons.svg#info-circle" /> | ||||
|             </svg> | ||||
|           </button> | ||||
|         </td> | ||||
|         <td scope="row"> | ||||
|           <button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();"> | ||||
|             <svg class="sidebaricon" fill="currentColor"> | ||||
|               <use xlink:href="assets/bootstrap-icons.svg#check"/> | ||||
|             </svg> <ng-container i18n>Dismiss</ng-container> | ||||
|           </button> | ||||
|         </td> | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td class="p-0" [class.border-0]="expandedTask != task.id" colspan="5"> | ||||
|           <pre #collapse="ngbCollapse" [ngbCollapse]="expandedTask !== task.id" class="small mb-0"><div class="small p-1 p-lg-3 ms-lg-3">{{ task.result }}</div></pre> | ||||
|         </td> | ||||
|       </tr> | ||||
|       </ng-container> | ||||
|     </tbody> | ||||
|   </table> | ||||
| </ng-template> | ||||
|  | ||||
| <ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-tabs"> | ||||
|   <li ngbNavItem="failed"> | ||||
|     <a ngbNavLink i18n>Failed <span *ngIf="tasksService.failedFileTasks.length > 0" class="badge bg-danger ms-1">{{tasksService.failedFileTasks.length}}</span></a> | ||||
|     <ng-template ngbNavContent> | ||||
|       <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.failedFileTasks}"></ng-container> | ||||
|     </ng-template> | ||||
|   </li> | ||||
|   <li ngbNavItem="completed"> | ||||
|     <a ngbNavLink i18n>Complete <span *ngIf="tasksService.completedFileTasks.length > 0" class="badge bg-secondary ms-1">{{tasksService.completedFileTasks.length}}</span></a> | ||||
|     <ng-template ngbNavContent> | ||||
|       <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.completedFileTasks}"></ng-container> | ||||
|     </ng-template> | ||||
|   </li> | ||||
|   <li ngbNavItem="started"> | ||||
|     <a ngbNavLink i18n>Started <span *ngIf="tasksService.startedFileTasks.length > 0" class="badge bg-secondary ms-1">{{tasksService.startedFileTasks.length}}</span></a> | ||||
|     <ng-template ngbNavContent> | ||||
|       <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.startedFileTasks}"></ng-container> | ||||
|     </ng-template> | ||||
|   </li> | ||||
|   <li ngbNavItem="queued"> | ||||
|     <a ngbNavLink i18n>Queued <span *ngIf="tasksService.queuedFileTasks.length > 0" class="badge bg-secondary ms-1">{{tasksService.queuedFileTasks.length}}</span></a> | ||||
|     <ng-template ngbNavContent> | ||||
|       <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.queuedFileTasks}"></ng-container> | ||||
|     </ng-template> | ||||
|   </li> | ||||
| </ul> | ||||
| <div [ngbNavOutlet]="nav"></div> | ||||
							
								
								
									
										22
									
								
								src-ui/src/app/components/manage/tasks/tasks.component.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,22 @@ | ||||
| ::ng-deep .popover { | ||||
|     max-width: 350px; | ||||
|  | ||||
|     pre { | ||||
|         white-space: pre-wrap; | ||||
|         word-break: break-word; | ||||
|     } | ||||
| } | ||||
|  | ||||
| pre { | ||||
|     white-space: pre-wrap; | ||||
|     word-break: break-word; | ||||
| } | ||||
|  | ||||
| .result { | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| .btn .spinner-border-sm { | ||||
|     width: 0.8rem; | ||||
|     height: 0.8rem; | ||||
| } | ||||
							
								
								
									
										109
									
								
								src-ui/src/app/components/manage/tasks/tasks.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,109 @@ | ||||
| import { Component, OnInit, OnDestroy } from '@angular/core' | ||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { takeUntil, Subject, first } from 'rxjs' | ||||
| import { PaperlessTask } from 'src/app/data/paperless-task' | ||||
| import { TasksService } from 'src/app/services/tasks.service' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'app-tasks', | ||||
|   templateUrl: './tasks.component.html', | ||||
|   styleUrls: ['./tasks.component.scss'], | ||||
| }) | ||||
| export class TasksComponent implements OnInit, OnDestroy { | ||||
|   public activeTab: string | ||||
|   public selectedTasks: Set<number> = new Set() | ||||
|   private unsubscribeNotifer = new Subject() | ||||
|   public expandedTask: number | ||||
|  | ||||
|   get dismissButtonText(): string { | ||||
|     return this.selectedTasks.size > 0 | ||||
|       ? $localize`Dismiss selected` | ||||
|       : $localize`Dismiss all` | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     public tasksService: TasksService, | ||||
|     private modalService: NgbModal | ||||
|   ) {} | ||||
|  | ||||
|   ngOnInit() { | ||||
|     this.tasksService.reload() | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy() { | ||||
|     this.unsubscribeNotifer.next(true) | ||||
|   } | ||||
|  | ||||
|   dismissTask(task: PaperlessTask) { | ||||
|     this.dismissTasks(task) | ||||
|   } | ||||
|  | ||||
|   dismissTasks(task: PaperlessTask = undefined) { | ||||
|     let tasks = task ? new Set([task.id]) : this.selectedTasks | ||||
|     if (!task && this.selectedTasks.size == 0) | ||||
|       tasks = new Set(this.tasksService.allFileTasks.map((t) => t.id)) | ||||
|     if (tasks.size > 1) { | ||||
|       let modal = this.modalService.open(ConfirmDialogComponent, { | ||||
|         backdrop: 'static', | ||||
|       }) | ||||
|       modal.componentInstance.title = $localize`Confirm Dismiss All` | ||||
|       modal.componentInstance.messageBold = | ||||
|         $localize`Dismiss all` + ` ${tasks.size} ` + $localize`tasks?` | ||||
|       modal.componentInstance.btnClass = 'btn-warning' | ||||
|       modal.componentInstance.btnCaption = $localize`Dismiss` | ||||
|       modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { | ||||
|         modal.componentInstance.buttonsEnabled = false | ||||
|         modal.close() | ||||
|         this.tasksService.dismissTasks(tasks) | ||||
|         this.selectedTasks.clear() | ||||
|       }) | ||||
|     } else { | ||||
|       this.tasksService.dismissTasks(tasks) | ||||
|       this.selectedTasks.clear() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   expandTask(task: PaperlessTask) { | ||||
|     this.expandedTask = this.expandedTask == task.id ? undefined : task.id | ||||
|   } | ||||
|  | ||||
|   toggleSelected(task: PaperlessTask) { | ||||
|     this.selectedTasks.has(task.id) | ||||
|       ? this.selectedTasks.delete(task.id) | ||||
|       : this.selectedTasks.add(task.id) | ||||
|   } | ||||
|  | ||||
|   get currentTasks(): PaperlessTask[] { | ||||
|     let tasks: PaperlessTask[] | ||||
|     switch (this.activeTab) { | ||||
|       case 'queued': | ||||
|         tasks = this.tasksService.queuedFileTasks | ||||
|         break | ||||
|       case 'started': | ||||
|         tasks = this.tasksService.startedFileTasks | ||||
|         break | ||||
|       case 'completed': | ||||
|         tasks = this.tasksService.completedFileTasks | ||||
|         break | ||||
|       case 'failed': | ||||
|         tasks = this.tasksService.failedFileTasks | ||||
|         break | ||||
|       default: | ||||
|         break | ||||
|     } | ||||
|     return tasks | ||||
|   } | ||||
|  | ||||
|   toggleAll(event: PointerEvent) { | ||||
|     if ((event.target as HTMLInputElement).checked) { | ||||
|       this.selectedTasks = new Set(this.currentTasks.map((t) => t.id)) | ||||
|     } else { | ||||
|       this.clearSelection() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   clearSelection() { | ||||
|     this.selectedTasks.clear() | ||||
|   } | ||||
| } | ||||
| @@ -1,34 +1,38 @@ | ||||
| export const FILTER_TITLE = 0 | ||||
| export const FILTER_CONTENT = 1 | ||||
|  | ||||
| export const FILTER_ASN = 2 | ||||
| export const FILTER_ASN_ISNULL = 18 | ||||
| export const FILTER_ASN_GT = 23 | ||||
| export const FILTER_ASN_LT = 24 | ||||
|  | ||||
| export const FILTER_CORRESPONDENT = 3 | ||||
|  | ||||
| export const FILTER_DOCUMENT_TYPE = 4 | ||||
|  | ||||
| export const FILTER_IS_IN_INBOX = 5 | ||||
| export const FILTER_HAS_TAGS_ALL = 6 | ||||
| export const FILTER_HAS_ANY_TAG = 7 | ||||
| export const FILTER_DOES_NOT_HAVE_TAG = 17 | ||||
| export const FILTER_HAS_TAGS_ANY = 22 | ||||
|  | ||||
| export const FILTER_STORAGE_PATH = 25 | ||||
|  | ||||
| export const FILTER_CREATED_BEFORE = 8 | ||||
| export const FILTER_CREATED_AFTER = 9 | ||||
| export const FILTER_CREATED_YEAR = 10 | ||||
| export const FILTER_CREATED_MONTH = 11 | ||||
| export const FILTER_CREATED_DAY = 12 | ||||
|  | ||||
| export const FILTER_ADDED_BEFORE = 13 | ||||
| export const FILTER_ADDED_AFTER = 14 | ||||
|  | ||||
| export const FILTER_MODIFIED_BEFORE = 15 | ||||
| export const FILTER_MODIFIED_AFTER = 16 | ||||
|  | ||||
| export const FILTER_DOES_NOT_HAVE_TAG = 17 | ||||
|  | ||||
| export const FILTER_ASN_ISNULL = 18 | ||||
| export const FILTER_ASN_GT = 19 | ||||
| export const FILTER_ASN_LT = 20 | ||||
|  | ||||
| export const FILTER_TITLE_CONTENT = 21 | ||||
|  | ||||
| export const FILTER_FULLTEXT_QUERY = 22 | ||||
| export const FILTER_FULLTEXT_MORELIKE = 23 | ||||
|  | ||||
| export const FILTER_STORAGE_PATH = 30 | ||||
| export const FILTER_TITLE_CONTENT = 19 | ||||
| export const FILTER_FULLTEXT_QUERY = 20 | ||||
| export const FILTER_FULLTEXT_MORELIKE = 21 | ||||
|  | ||||
| export const FILTER_RULE_TYPES: FilterRuleType[] = [ | ||||
|   { | ||||
|   | ||||
| @@ -25,6 +25,25 @@ export function isFullTextFilterRule(filterRules: FilterRule[]): boolean { | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export function filterRulesDiffer( | ||||
|   filterRulesA: FilterRule[], | ||||
|   filterRulesB: FilterRule[] | ||||
| ): boolean { | ||||
|   let differ = false | ||||
|   if (filterRulesA.length != filterRulesB.length) { | ||||
|     differ = true | ||||
|   } else { | ||||
|     differ = filterRulesA.some((rule) => { | ||||
|       return ( | ||||
|         filterRulesB.find( | ||||
|           (fri) => fri.rule_type == rule.rule_type && fri.value == rule.value | ||||
|         ) == undefined | ||||
|       ) | ||||
|     }) | ||||
|   } | ||||
|   return differ | ||||
| } | ||||
|  | ||||
| export interface FilterRule { | ||||
|   rule_type: number | ||||
|   value: string | ||||
|   | ||||
| @@ -37,8 +37,12 @@ export interface PaperlessDocument extends ObjectWithId { | ||||
|  | ||||
|   checksum?: string | ||||
|  | ||||
|   // UTC | ||||
|   created?: Date | ||||
|  | ||||
|   // localized date | ||||
|   created_date?: Date | ||||
|  | ||||
|   modified?: Date | ||||
|  | ||||
|   added?: Date | ||||
|   | ||||
							
								
								
									
										32
									
								
								src-ui/src/app/data/paperless-task.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,32 @@ | ||||
| import { ObjectWithId } from './object-with-id' | ||||
|  | ||||
| export enum PaperlessTaskType { | ||||
|   // just file tasks, for now | ||||
|   File = 'file', | ||||
| } | ||||
|  | ||||
| export enum PaperlessTaskStatus { | ||||
|   Queued = 'queued', | ||||
|   Started = 'started', | ||||
|   Complete = 'complete', | ||||
|   Failed = 'failed', | ||||
|   Unknown = 'unknown', | ||||
| } | ||||
|  | ||||
| export interface PaperlessTask extends ObjectWithId { | ||||
|   type: PaperlessTaskType | ||||
|  | ||||
|   status: PaperlessTaskStatus | ||||
|  | ||||
|   acknowledged: boolean | ||||
|  | ||||
|   task_id: string | ||||
|  | ||||
|   name: string | ||||
|  | ||||
|   created: Date | ||||
|  | ||||
|   started?: Date | ||||
|  | ||||
|   result: string | ||||
| } | ||||
| @@ -2,7 +2,6 @@ import { DatePipe } from '@angular/common' | ||||
| import { Inject, LOCALE_ID, Pipe, PipeTransform } from '@angular/core' | ||||
| import { SETTINGS_KEYS } from '../data/paperless-uisettings' | ||||
| import { SettingsService } from '../services/settings.service' | ||||
| import { normalizeDateStr } from '../utils/date' | ||||
|  | ||||
| const FORMAT_TO_ISO_FORMAT = { | ||||
|   longDate: 'y-MM-dd', | ||||
| @@ -35,7 +34,6 @@ export class CustomDatePipe implements PipeTransform { | ||||
|       this.settings.get(SETTINGS_KEYS.DATE_LOCALE) || | ||||
|       this.defaultLocale | ||||
|     let f = format || this.settings.get(SETTINGS_KEYS.DATE_FORMAT) | ||||
|     if (typeof value == 'string') value = normalizeDateStr(value) | ||||
|     if (l == 'iso-8601') { | ||||
|       return this.datePipe.transform(value, FORMAT_TO_ISO_FORMAT[f], timezone) | ||||
|     } else { | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import { Injectable } from '@angular/core' | ||||
| import { ActivatedRoute, Params, Router } from '@angular/router' | ||||
| import { ParamMap, Router } from '@angular/router' | ||||
| import { Observable } from 'rxjs' | ||||
| import { | ||||
|   filterRulesDiffer, | ||||
|   cloneFilterRules, | ||||
|   FilterRule, | ||||
|   isFullTextFilterRule, | ||||
| @@ -10,13 +11,14 @@ import { PaperlessDocument } from '../data/paperless-document' | ||||
| import { PaperlessSavedView } from '../data/paperless-saved-view' | ||||
| import { SETTINGS_KEYS } from '../data/paperless-uisettings' | ||||
| import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys' | ||||
| import { generateParams, parseParams } from '../utils/query-params' | ||||
| import { DocumentService, DOCUMENT_SORT_FIELDS } from './rest/document.service' | ||||
| import { SettingsService } from './settings.service' | ||||
|  | ||||
| /** | ||||
|  * Captures the current state of the list view. | ||||
|  */ | ||||
| interface ListViewState { | ||||
| export interface ListViewState { | ||||
|   /** | ||||
|    * Title of the document list view. Either "Documents" (localized) or the name of a saved view. | ||||
|    */ | ||||
| @@ -32,7 +34,7 @@ interface ListViewState { | ||||
|   /** | ||||
|    * Total amount of documents with the current filter rules. Used to calculate the number of pages. | ||||
|    */ | ||||
|   collectionSize: number | ||||
|   collectionSize?: number | ||||
|  | ||||
|   /** | ||||
|    * Currently selected sort field. | ||||
| @@ -66,6 +68,7 @@ interface ListViewState { | ||||
| }) | ||||
| export class DocumentListViewService { | ||||
|   isReloading: boolean = false | ||||
|   initialized: boolean = false | ||||
|   error: string = null | ||||
|  | ||||
|   rangeSelectionAnchorIndex: number | ||||
| @@ -85,6 +88,32 @@ export class DocumentListViewService { | ||||
|     return this.activeListViewState.title | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     private documentService: DocumentService, | ||||
|     private settings: SettingsService, | ||||
|     private router: Router | ||||
|   ) { | ||||
|     let documentListViewConfigJson = localStorage.getItem( | ||||
|       DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG | ||||
|     ) | ||||
|     if (documentListViewConfigJson) { | ||||
|       try { | ||||
|         let savedState: ListViewState = JSON.parse(documentListViewConfigJson) | ||||
|         // Remove null elements from the restored state | ||||
|         Object.keys(savedState).forEach((k) => { | ||||
|           if (savedState[k] == null) { | ||||
|             delete savedState[k] | ||||
|           } | ||||
|         }) | ||||
|         //only use restored state attributes instead of defaults if they are not null | ||||
|         let newState = Object.assign(this.defaultListViewState(), savedState) | ||||
|         this.listViewStates.set(null, newState) | ||||
|       } catch (e) { | ||||
|         localStorage.removeItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private defaultListViewState(): ListViewState { | ||||
|     return { | ||||
|       title: null, | ||||
| @@ -122,20 +151,53 @@ export class DocumentListViewService { | ||||
|     if (closeCurrentView) { | ||||
|       this._activeSavedViewId = null | ||||
|     } | ||||
|  | ||||
|     this.activeListViewState.filterRules = cloneFilterRules(view.filter_rules) | ||||
|     this.activeListViewState.sortField = view.sort_field | ||||
|     this.activeListViewState.sortReverse = view.sort_reverse | ||||
|     if (this._activeSavedViewId) { | ||||
|       this.activeListViewState.title = view.name | ||||
|     } | ||||
|  | ||||
|     this.reduceSelectionToFilter() | ||||
|  | ||||
|     if (!this.router.routerState.snapshot.url.includes('/view/')) { | ||||
|       this.router.navigate([], { | ||||
|         queryParams: { view: view.id }, | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   reload(onFinish?) { | ||||
|   loadFromQueryParams(queryParams: ParamMap) { | ||||
|     const paramsEmpty: boolean = queryParams.keys.length == 0 | ||||
|     let newState: ListViewState = this.listViewStates.get(null) | ||||
|     if (!paramsEmpty) newState = parseParams(queryParams) | ||||
|     if (newState == undefined) newState = this.defaultListViewState() // if nothing in local storage | ||||
|  | ||||
|     // only reload if things have changed | ||||
|     if ( | ||||
|       !this.initialized || | ||||
|       paramsEmpty || | ||||
|       this.activeListViewState.sortField !== newState.sortField || | ||||
|       this.activeListViewState.sortReverse !== newState.sortReverse || | ||||
|       this.activeListViewState.currentPage !== newState.currentPage || | ||||
|       filterRulesDiffer( | ||||
|         this.activeListViewState.filterRules, | ||||
|         newState.filterRules | ||||
|       ) | ||||
|     ) { | ||||
|       this.activeListViewState.filterRules = newState.filterRules | ||||
|       this.activeListViewState.sortField = newState.sortField | ||||
|       this.activeListViewState.sortReverse = newState.sortReverse | ||||
|       this.activeListViewState.currentPage = newState.currentPage | ||||
|       this.reload(null, paramsEmpty) // update the params if there arent any | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   reload(onFinish?, updateQueryParams: boolean = true) { | ||||
|     this.isReloading = true | ||||
|     this.error = null | ||||
|     let activeListViewState = this.activeListViewState | ||||
|  | ||||
|     this.documentService | ||||
|       .listFiltered( | ||||
|         activeListViewState.currentPage, | ||||
| @@ -146,9 +208,18 @@ export class DocumentListViewService { | ||||
|       ) | ||||
|       .subscribe({ | ||||
|         next: (result) => { | ||||
|           this.initialized = true | ||||
|           this.isReloading = false | ||||
|           activeListViewState.collectionSize = result.count | ||||
|           activeListViewState.documents = result.results | ||||
|  | ||||
|           if (updateQueryParams && !this._activeSavedViewId) { | ||||
|             let base = ['/documents'] | ||||
|             this.router.navigate(base, { | ||||
|               queryParams: generateParams(activeListViewState), | ||||
|             }) | ||||
|           } | ||||
|  | ||||
|           if (onFinish) { | ||||
|             onFinish() | ||||
|           } | ||||
| @@ -191,6 +262,7 @@ export class DocumentListViewService { | ||||
|     ) { | ||||
|       this.activeListViewState.sortField = 'created' | ||||
|     } | ||||
|     this._activeSavedViewId = null | ||||
|     this.activeListViewState.filterRules = filterRules | ||||
|     this.reload() | ||||
|     this.reduceSelectionToFilter() | ||||
| @@ -202,6 +274,7 @@ export class DocumentListViewService { | ||||
|   } | ||||
|  | ||||
|   set sortField(field: string) { | ||||
|     this._activeSavedViewId = null | ||||
|     this.activeListViewState.sortField = field | ||||
|     this.reload() | ||||
|     this.saveDocumentListView() | ||||
| @@ -212,6 +285,7 @@ export class DocumentListViewService { | ||||
|   } | ||||
|  | ||||
|   set sortReverse(reverse: boolean) { | ||||
|     this._activeSavedViewId = null | ||||
|     this.activeListViewState.sortReverse = reverse | ||||
|     this.reload() | ||||
|     this.saveDocumentListView() | ||||
| @@ -221,13 +295,6 @@ export class DocumentListViewService { | ||||
|     return this.activeListViewState.sortReverse | ||||
|   } | ||||
|  | ||||
|   get sortParams(): Params { | ||||
|     return { | ||||
|       sortField: this.sortField, | ||||
|       sortReverse: this.sortReverse, | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   get collectionSize(): number { | ||||
|     return this.activeListViewState.collectionSize | ||||
|   } | ||||
| @@ -237,6 +304,8 @@ export class DocumentListViewService { | ||||
|   } | ||||
|  | ||||
|   set currentPage(page: number) { | ||||
|     if (this.activeListViewState.currentPage == page) return | ||||
|     this._activeSavedViewId = null | ||||
|     this.activeListViewState.currentPage = page | ||||
|     this.reload() | ||||
|     this.saveDocumentListView() | ||||
| @@ -273,6 +342,10 @@ export class DocumentListViewService { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   quickFilter(filterRules: FilterRule[]) { | ||||
|     this.filterRules = filterRules | ||||
|   } | ||||
|  | ||||
|   getLastPage(): number { | ||||
|     return Math.ceil(this.collectionSize / this.currentPageSize) | ||||
|   } | ||||
| @@ -431,29 +504,4 @@ export class DocumentListViewService { | ||||
|   documentIndexInCurrentView(documentID: number): number { | ||||
|     return this.documents.map((d) => d.id).indexOf(documentID) | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     private documentService: DocumentService, | ||||
|     private settings: SettingsService | ||||
|   ) { | ||||
|     let documentListViewConfigJson = localStorage.getItem( | ||||
|       DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG | ||||
|     ) | ||||
|     if (documentListViewConfigJson) { | ||||
|       try { | ||||
|         let savedState: ListViewState = JSON.parse(documentListViewConfigJson) | ||||
|         // Remove null elements from the restored state | ||||
|         Object.keys(savedState).forEach((k) => { | ||||
|           if (savedState[k] == null) { | ||||
|             delete savedState[k] | ||||
|           } | ||||
|         }) | ||||
|         //only use restored state attributes instead of defaults if they are not null | ||||
|         let newState = Object.assign(this.defaultListViewState(), savedState) | ||||
|         this.listViewStates.set(null, newState) | ||||
|       } catch (e) { | ||||
|         localStorage.removeItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,163 +0,0 @@ | ||||
| import { Injectable } from '@angular/core' | ||||
| import { ParamMap, Params, Router } from '@angular/router' | ||||
| import { FilterRule } from '../data/filter-rule' | ||||
| import { FilterRuleType, FILTER_RULE_TYPES } from '../data/filter-rule-type' | ||||
| import { PaperlessSavedView } from '../data/paperless-saved-view' | ||||
| import { DocumentListViewService } from './document-list-view.service' | ||||
|  | ||||
| const SORT_FIELD_PARAMETER = 'sort' | ||||
| const SORT_REVERSE_PARAMETER = 'reverse' | ||||
|  | ||||
| @Injectable({ | ||||
|   providedIn: 'root', | ||||
| }) | ||||
| export class QueryParamsService { | ||||
|   constructor(private router: Router, private list: DocumentListViewService) {} | ||||
|  | ||||
|   private filterParams: Params = {} | ||||
|   private sortParams: Params = {} | ||||
|  | ||||
|   updateFilterRules( | ||||
|     filterRules: FilterRule[], | ||||
|     updateQueryParams: boolean = true | ||||
|   ) { | ||||
|     this.filterParams = filterRulesToQueryParams(filterRules) | ||||
|     if (updateQueryParams) this.updateQueryParams() | ||||
|   } | ||||
|  | ||||
|   set sortField(field: string) { | ||||
|     this.sortParams[SORT_FIELD_PARAMETER] = field | ||||
|     this.updateQueryParams() | ||||
|   } | ||||
|  | ||||
|   set sortReverse(reverse: boolean) { | ||||
|     if (!reverse) this.sortParams[SORT_REVERSE_PARAMETER] = undefined | ||||
|     else this.sortParams[SORT_REVERSE_PARAMETER] = reverse | ||||
|     this.updateQueryParams() | ||||
|   } | ||||
|  | ||||
|   get params(): Params { | ||||
|     return { | ||||
|       ...this.sortParams, | ||||
|       ...this.filterParams, | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private updateQueryParams() { | ||||
|     // if we were on a saved view we navigate 'away' to /documents | ||||
|     let base = [] | ||||
|     if (this.router.routerState.snapshot.url.includes('/view/')) | ||||
|       base = ['/documents'] | ||||
|  | ||||
|     this.router.navigate(base, { | ||||
|       queryParams: this.params, | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   public parseQueryParams(queryParams: ParamMap) { | ||||
|     let filterRules = filterRulesFromQueryParams(queryParams) | ||||
|     if ( | ||||
|       filterRules.length || | ||||
|       queryParams.has(SORT_FIELD_PARAMETER) || | ||||
|       queryParams.has(SORT_REVERSE_PARAMETER) | ||||
|     ) { | ||||
|       this.list.filterRules = filterRules | ||||
|       this.list.sortField = queryParams.get(SORT_FIELD_PARAMETER) | ||||
|       this.list.sortReverse = | ||||
|         queryParams.has(SORT_REVERSE_PARAMETER) || | ||||
|         (!queryParams.has(SORT_FIELD_PARAMETER) && | ||||
|           !queryParams.has(SORT_REVERSE_PARAMETER)) | ||||
|       this.list.reload() | ||||
|     } else if ( | ||||
|       filterRules.length == 0 && | ||||
|       !queryParams.has(SORT_FIELD_PARAMETER) | ||||
|     ) { | ||||
|       // this is navigating to /documents so we need to update the params from the list | ||||
|       this.updateFilterRules(this.list.filterRules, false) | ||||
|       this.sortParams[SORT_FIELD_PARAMETER] = this.list.sortField | ||||
|       this.sortParams[SORT_REVERSE_PARAMETER] = this.list.sortReverse | ||||
|       this.router.navigate([], { | ||||
|         queryParams: this.params, | ||||
|         replaceUrl: true, | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   updateFromView(view: PaperlessSavedView) { | ||||
|     if (!this.router.routerState.snapshot.url.includes('/view/')) { | ||||
|       // navigation for /documents?view= | ||||
|       this.router.navigate([], { | ||||
|         queryParams: { view: view.id }, | ||||
|       }) | ||||
|     } | ||||
|     // make sure params are up-to-date | ||||
|     this.updateFilterRules(view.filter_rules, false) | ||||
|     this.sortParams[SORT_FIELD_PARAMETER] = this.list.sortField | ||||
|     this.sortParams[SORT_REVERSE_PARAMETER] = this.list.sortReverse | ||||
|   } | ||||
|  | ||||
|   navigateWithFilterRules(filterRules: FilterRule[]) { | ||||
|     this.updateFilterRules(filterRules) | ||||
|     this.router.navigate(['/documents'], { | ||||
|       queryParams: this.params, | ||||
|     }) | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function filterRulesToQueryParams(filterRules: FilterRule[]): Object { | ||||
|   if (filterRules) { | ||||
|     let params = {} | ||||
|     for (let rule of filterRules) { | ||||
|       let ruleType = FILTER_RULE_TYPES.find((t) => t.id == rule.rule_type) | ||||
|       if (ruleType.multi) { | ||||
|         params[ruleType.filtervar] = params[ruleType.filtervar] | ||||
|           ? params[ruleType.filtervar] + ',' + rule.value | ||||
|           : rule.value | ||||
|       } else if (ruleType.isnull_filtervar && rule.value == null) { | ||||
|         params[ruleType.isnull_filtervar] = true | ||||
|       } else { | ||||
|         params[ruleType.filtervar] = rule.value | ||||
|       } | ||||
|     } | ||||
|     return params | ||||
|   } else { | ||||
|     return null | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function filterRulesFromQueryParams(queryParams: ParamMap) { | ||||
|   const allFilterRuleQueryParams: string[] = FILTER_RULE_TYPES.map( | ||||
|     (rt) => rt.filtervar | ||||
|   ) | ||||
|     .concat(FILTER_RULE_TYPES.map((rt) => rt.isnull_filtervar)) | ||||
|     .filter((rt) => rt !== undefined) | ||||
|  | ||||
|   // transform query params to filter rules | ||||
|   let filterRulesFromQueryParams: FilterRule[] = [] | ||||
|   allFilterRuleQueryParams | ||||
|     .filter((frqp) => queryParams.has(frqp)) | ||||
|     .forEach((filterQueryParamName) => { | ||||
|       const rule_type: FilterRuleType = FILTER_RULE_TYPES.find( | ||||
|         (rt) => | ||||
|           rt.filtervar == filterQueryParamName || | ||||
|           rt.isnull_filtervar == filterQueryParamName | ||||
|       ) | ||||
|       const isNullRuleType = rule_type.isnull_filtervar == filterQueryParamName | ||||
|       const valueURIComponent: string = queryParams.get(filterQueryParamName) | ||||
|       const filterQueryParamValues: string[] = rule_type.multi | ||||
|         ? valueURIComponent.split(',') | ||||
|         : [valueURIComponent] | ||||
|  | ||||
|       filterRulesFromQueryParams = filterRulesFromQueryParams.concat( | ||||
|         // map all values to filter rules | ||||
|         filterQueryParamValues.map((val) => { | ||||
|           return { | ||||
|             rule_type: rule_type.id, | ||||
|             value: isNullRuleType ? null : val, | ||||
|           } | ||||
|         }) | ||||
|       ) | ||||
|     }) | ||||
|  | ||||
|   return filterRulesFromQueryParams | ||||
| } | ||||
| @@ -6,12 +6,12 @@ import { HttpClient, HttpParams } from '@angular/common/http' | ||||
| import { Observable } from 'rxjs' | ||||
| import { Results } from 'src/app/data/results' | ||||
| import { FilterRule } from 'src/app/data/filter-rule' | ||||
| import { map } from 'rxjs/operators' | ||||
| import { map, tap } from 'rxjs/operators' | ||||
| import { CorrespondentService } from './correspondent.service' | ||||
| import { DocumentTypeService } from './document-type.service' | ||||
| import { TagService } from './tag.service' | ||||
| import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions' | ||||
| import { filterRulesToQueryParams } from '../query-params.service' | ||||
| import { queryParamsFromFilterRules } from '../../utils/query-params' | ||||
| import { StoragePathService } from './storage-path.service' | ||||
|  | ||||
| export const DOCUMENT_SORT_FIELDS = [ | ||||
| @@ -70,7 +70,13 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument> | ||||
|       doc.document_type$ = this.documentTypeService.getCached(doc.document_type) | ||||
|     } | ||||
|     if (doc.tags) { | ||||
|       doc.tags$ = this.tagService.getCachedMany(doc.tags) | ||||
|       doc.tags$ = this.tagService | ||||
|         .getCachedMany(doc.tags) | ||||
|         .pipe( | ||||
|           tap((tags) => | ||||
|             tags.sort((tagA, tagB) => tagA.name.localeCompare(tagB.name)) | ||||
|           ) | ||||
|         ) | ||||
|     } | ||||
|     if (doc.storage_path) { | ||||
|       doc.storage_path$ = this.storagePathService.getCached(doc.storage_path) | ||||
| @@ -91,7 +97,7 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument> | ||||
|       pageSize, | ||||
|       sortField, | ||||
|       sortReverse, | ||||
|       Object.assign(extraParams, filterRulesToQueryParams(filterRules)) | ||||
|       Object.assign(extraParams, queryParamsFromFilterRules(filterRules)) | ||||
|     ).pipe( | ||||
|       map((results) => { | ||||
|         results.results.forEach((doc) => this.addObservablesToDocument(doc)) | ||||
| @@ -127,6 +133,12 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument> | ||||
|     return url | ||||
|   } | ||||
|  | ||||
|   update(o: PaperlessDocument): Observable<PaperlessDocument> { | ||||
|     // we want to only set created_date | ||||
|     o.created = undefined | ||||
|     return super.update(o) | ||||
|   } | ||||
|  | ||||
|   uploadDocument(formData) { | ||||
|     return this.http.post( | ||||
|       this.getResourceUrl(null, 'post_document'), | ||||
|   | ||||
							
								
								
									
										71
									
								
								src-ui/src/app/services/tasks.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,71 @@ | ||||
| import { HttpClient } from '@angular/common/http' | ||||
| import { Injectable } from '@angular/core' | ||||
| import { first, map } from 'rxjs/operators' | ||||
| import { | ||||
|   PaperlessTask, | ||||
|   PaperlessTaskStatus, | ||||
|   PaperlessTaskType, | ||||
| } from 'src/app/data/paperless-task' | ||||
| import { environment } from 'src/environments/environment' | ||||
|  | ||||
| @Injectable({ | ||||
|   providedIn: 'root', | ||||
| }) | ||||
| export class TasksService { | ||||
|   private baseUrl: string = environment.apiBaseUrl | ||||
|  | ||||
|   loading: boolean | ||||
|  | ||||
|   private fileTasks: PaperlessTask[] = [] | ||||
|  | ||||
|   public get total(): number { | ||||
|     return this.fileTasks?.length | ||||
|   } | ||||
|  | ||||
|   public get allFileTasks(): PaperlessTask[] { | ||||
|     return this.fileTasks.slice(0) | ||||
|   } | ||||
|  | ||||
|   public get queuedFileTasks(): PaperlessTask[] { | ||||
|     return this.fileTasks.filter((t) => t.status == PaperlessTaskStatus.Queued) | ||||
|   } | ||||
|  | ||||
|   public get startedFileTasks(): PaperlessTask[] { | ||||
|     return this.fileTasks.filter((t) => t.status == PaperlessTaskStatus.Started) | ||||
|   } | ||||
|  | ||||
|   public get completedFileTasks(): PaperlessTask[] { | ||||
|     return this.fileTasks.filter( | ||||
|       (t) => t.status == PaperlessTaskStatus.Complete | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   public get failedFileTasks(): PaperlessTask[] { | ||||
|     return this.fileTasks.filter((t) => t.status == PaperlessTaskStatus.Failed) | ||||
|   } | ||||
|  | ||||
|   constructor(private http: HttpClient) {} | ||||
|  | ||||
|   public reload() { | ||||
|     this.loading = true | ||||
|  | ||||
|     this.http | ||||
|       .get<PaperlessTask[]>(`${this.baseUrl}tasks/`) | ||||
|       .pipe(first()) | ||||
|       .subscribe((r) => { | ||||
|         this.fileTasks = r.filter((t) => t.type == PaperlessTaskType.File) // they're all File tasks, for now | ||||
|         this.loading = false | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   public dismissTasks(task_ids: Set<number>) { | ||||
|     this.http | ||||
|       .post(`${this.baseUrl}acknowledge_tasks/`, { | ||||
|         tasks: [...task_ids], | ||||
|       }) | ||||
|       .pipe(first()) | ||||
|       .subscribe((r) => { | ||||
|         this.reload() | ||||
|       }) | ||||
|   } | ||||
| } | ||||
| @@ -1,5 +0,0 @@ | ||||
| // see https://github.com/dateutil/dateutil/issues/878 , JS Date does not | ||||
| // seem to accept these strings as valid dates so we must normalize offset | ||||
| export function normalizeDateStr(dateStr: string): string { | ||||
|   return dateStr.replace(/[\+-](\d\d):\d\d:\d\d/gm, `-$1:00`) | ||||
| } | ||||
| @@ -5,12 +5,21 @@ import { NgbDateAdapter, NgbDateStruct } from '@ng-bootstrap/ng-bootstrap' | ||||
| export class ISODateAdapter extends NgbDateAdapter<string> { | ||||
|   fromModel(value: string | null): NgbDateStruct | null { | ||||
|     if (value) { | ||||
|       if (value.match(/\d\d\d\d\-\d\d\-\d\d/g)) { | ||||
|         const segs = value.split('-') | ||||
|         return { | ||||
|           year: parseInt(segs[0]), | ||||
|           month: parseInt(segs[1]), | ||||
|           day: parseInt(segs[2]), | ||||
|         } | ||||
|       } else { | ||||
|         let date = new Date(value) | ||||
|         return { | ||||
|           day: date.getDate(), | ||||
|           month: date.getMonth() + 1, | ||||
|           year: date.getFullYear(), | ||||
|         } | ||||
|       } | ||||
|     } else { | ||||
|       return null | ||||
|     } | ||||
|   | ||||
| @@ -1,24 +0,0 @@ | ||||
| import { Injectable } from '@angular/core' | ||||
| import { NgbDateAdapter, NgbDateStruct } from '@ng-bootstrap/ng-bootstrap' | ||||
|  | ||||
| @Injectable() | ||||
| export class ISODateTimeAdapter extends NgbDateAdapter<string> { | ||||
|   fromModel(value: string | null): NgbDateStruct | null { | ||||
|     if (value) { | ||||
|       let date = new Date(value) | ||||
|       return { | ||||
|         day: date.getDate(), | ||||
|         month: date.getMonth() + 1, | ||||
|         year: date.getFullYear(), | ||||
|       } | ||||
|     } else { | ||||
|       return null | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   toModel(date: NgbDateStruct | null): string | null { | ||||
|     return date | ||||
|       ? new Date(date.year, date.month - 1, date.day).toISOString() | ||||
|       : null | ||||
|   } | ||||
| } | ||||
							
								
								
									
										101
									
								
								src-ui/src/app/utils/query-params.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,101 @@ | ||||
| import { ParamMap, Params } from '@angular/router' | ||||
| import { FilterRule } from '../data/filter-rule' | ||||
| import { FilterRuleType, FILTER_RULE_TYPES } from '../data/filter-rule-type' | ||||
| import { ListViewState } from '../services/document-list-view.service' | ||||
|  | ||||
| const SORT_FIELD_PARAMETER = 'sort' | ||||
| const SORT_REVERSE_PARAMETER = 'reverse' | ||||
| const PAGE_PARAMETER = 'page' | ||||
|  | ||||
| export function generateParams(viewState: ListViewState): Params { | ||||
|   let params = queryParamsFromFilterRules(viewState.filterRules) | ||||
|   params[SORT_FIELD_PARAMETER] = viewState.sortField | ||||
|   params[SORT_REVERSE_PARAMETER] = viewState.sortReverse ? 1 : undefined | ||||
|   params[PAGE_PARAMETER] = isNaN(viewState.currentPage) | ||||
|     ? 1 | ||||
|     : viewState.currentPage | ||||
|   return params | ||||
| } | ||||
|  | ||||
| export function parseParams(queryParams: ParamMap): ListViewState { | ||||
|   let filterRules = filterRulesFromQueryParams(queryParams) | ||||
|   let sortField = queryParams.get(SORT_FIELD_PARAMETER) | ||||
|   let sortReverse = | ||||
|     queryParams.has(SORT_REVERSE_PARAMETER) || | ||||
|     (!queryParams.has(SORT_FIELD_PARAMETER) && | ||||
|       !queryParams.has(SORT_REVERSE_PARAMETER)) | ||||
|   let currentPage = queryParams.has(PAGE_PARAMETER) | ||||
|     ? parseInt(queryParams.get(PAGE_PARAMETER)) | ||||
|     : 1 | ||||
|   return { | ||||
|     currentPage: currentPage, | ||||
|     filterRules: filterRules, | ||||
|     sortField: sortField, | ||||
|     sortReverse: sortReverse, | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function filterRulesFromQueryParams( | ||||
|   queryParams: ParamMap | ||||
| ): FilterRule[] { | ||||
|   const allFilterRuleQueryParams: string[] = FILTER_RULE_TYPES.map( | ||||
|     (rt) => rt.filtervar | ||||
|   ) | ||||
|     .concat(FILTER_RULE_TYPES.map((rt) => rt.isnull_filtervar)) | ||||
|     .filter((rt) => rt !== undefined) | ||||
|  | ||||
|   // transform query params to filter rules | ||||
|   let filterRulesFromQueryParams: FilterRule[] = [] | ||||
|   allFilterRuleQueryParams | ||||
|     .filter((frqp) => queryParams.has(frqp)) | ||||
|     .forEach((filterQueryParamName) => { | ||||
|       const rule_type: FilterRuleType = FILTER_RULE_TYPES.find( | ||||
|         (rt) => | ||||
|           rt.filtervar == filterQueryParamName || | ||||
|           rt.isnull_filtervar == filterQueryParamName | ||||
|       ) | ||||
|       const isNullRuleType = rule_type.isnull_filtervar == filterQueryParamName | ||||
|       const valueURIComponent: string = queryParams.get(filterQueryParamName) | ||||
|       const filterQueryParamValues: string[] = rule_type.multi | ||||
|         ? valueURIComponent.split(',') | ||||
|         : [valueURIComponent] | ||||
|  | ||||
|       filterRulesFromQueryParams = filterRulesFromQueryParams.concat( | ||||
|         // map all values to filter rules | ||||
|         filterQueryParamValues.map((val) => { | ||||
|           if (rule_type.datatype == 'boolean') | ||||
|             val = val.replace('1', 'true').replace('0', 'false') | ||||
|           return { | ||||
|             rule_type: rule_type.id, | ||||
|             value: isNullRuleType ? null : val, | ||||
|           } | ||||
|         }) | ||||
|       ) | ||||
|     }) | ||||
|  | ||||
|   return filterRulesFromQueryParams | ||||
| } | ||||
|  | ||||
| export function queryParamsFromFilterRules(filterRules: FilterRule[]): Params { | ||||
|   if (filterRules) { | ||||
|     let params = {} | ||||
|     for (let rule of filterRules) { | ||||
|       let ruleType = FILTER_RULE_TYPES.find((t) => t.id == rule.rule_type) | ||||
|       if (ruleType.multi) { | ||||
|         params[ruleType.filtervar] = params[ruleType.filtervar] | ||||
|           ? params[ruleType.filtervar] + ',' + rule.value | ||||
|           : rule.value | ||||
|       } else if (ruleType.isnull_filtervar && rule.value == null) { | ||||
|         params[ruleType.isnull_filtervar] = 1 | ||||
|       } else { | ||||
|         params[ruleType.filtervar] = rule.value | ||||
|         if (ruleType.datatype == 'boolean') | ||||
|           params[ruleType.filtervar] = | ||||
|             rule.value == 'true' || rule.value == '1' ? 1 : 0 | ||||
|       } | ||||
|     } | ||||
|     return params | ||||
|   } else { | ||||
|     return null | ||||
|   } | ||||
| } | ||||
| @@ -1,19 +1,3 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <!-- Generator: Adobe Illustrator 25.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  --> | ||||
| <svg version="1.1" | ||||
| 	 id="svg4812" inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)" sodipodi:docname="logo-dark-notext.svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg" | ||||
| 	 xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 198.4 238.9" | ||||
| 	 style="enable-background:new 0 0 198.4 238.9;" xml:space="preserve"> | ||||
| <sodipodi:namedview  bordercolor="#666666" borderopacity="1.0" id="base" inkscape:current-layer="SvgjsG1020" inkscape:cx="328.04904" inkscape:cy="330.33332" inkscape:document-rotation="0" inkscape:document-units="mm" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:window-height="1016" inkscape:window-maximized="1" inkscape:window-width="1920" inkscape:window-x="1280" inkscape:window-y="27" inkscape:zoom="0.98994949" pagecolor="#ffffff" showgrid="false"> | ||||
| 	</sodipodi:namedview> | ||||
| <g id="layer1" transform="translate(-9.9999792,-10.000082)" inkscape:groupmode="layer" inkscape:label="Layer 1"> | ||||
| 	<g id="SvgjsG1020" transform="matrix(0.10341565,0,0,0.10341565,1.2287665,8.3453496)"> | ||||
| 		<path id="path57" d="M1967.5,16C1672.7,702,255.4,787,709,1892.5c5.7,14.2-104.9,164.4-178.6,289.1c-17-62.4-36.9-130.4-34-136.1 | ||||
| 			c368.5-436.5-263.6-683.1-297.6-1040.3C40,1288.7-16.7,1784.8,462.3,2071.1c2.8,0,25.5,107.7,36.9,161.6 | ||||
| 			c-11.3,22.7-22.7,45.4-28.3,62.4c-11.3,28.3,73.7,25.5,73.7,31.2c8.5-2.8,209.8-357.2,215.4-360 | ||||
| 			C1899.5,1705.4,2100.8,679.3,1967.5,16z M1386.4,738.8C853.5,1215,762.8,1569.4,779.8,1742.3 | ||||
| 			C601.2,1319.9,1125.7,855,1386.4,738.8z M357.5,1419.1c102,93.5,272.1,379.8,127.6,547.1C519,1889.7,530.4,1716.8,357.5,1419.1z" | ||||
| 			/> | ||||
| 	</g> | ||||
| </g> | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.4 238.9" style="enable-background:new 0 0 198.4 238.9" xml:space="preserve"> | ||||
|   <path d="M194.7 0C164.211 70.943 17.64 79.733 64.55 194.06c.59 1.468-10.848 17-18.47 29.897-1.758-6.453-3.816-13.486-3.516-14.075 38.109-45.141-27.26-70.643-30.776-107.583-16.423 29.318-22.286 80.623 27.25 110.23.29 0 2.637 11.138 3.816 16.712-1.169 2.348-2.348 4.695-2.927 6.454-1.168 2.926 7.622 2.637 7.622 3.226.879-.29 21.697-36.94 22.276-37.23C187.667 174.711 208.485 68.596 194.699 0zm-60.096 74.749c-55.11 49.246-64.49 85.897-62.732 103.777-18.47-43.682 35.772-91.76 62.732-103.777zM28.2 145.102c10.548 9.67 28.14 39.278 13.196 56.58 3.506-7.912 4.684-25.793-13.196-56.58z"/> | ||||
| </svg> | ||||
|   | ||||
| Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 727 B | 
| @@ -1,71 +1,4 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0)  --> | ||||
| <svg version="1.1" | ||||
| 	 id="svg9580" inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)" sodipodi:docname="logo.svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg" | ||||
| 	 xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 2897.4 896.6" | ||||
| 	 style="enable-background:new 0 0 2897.4 896.6;" xml:space="preserve"> | ||||
| <style type="text/css"> | ||||
| 	.st0{fill:#17541F;} | ||||
| </style> | ||||
| <sodipodi:namedview  bordercolor="#666666" borderopacity="1" gridtolerance="10" guidetolerance="10" id="namedview9582" inkscape:current-layer="g9578" inkscape:cx="1393.617" inkscape:cy="393.61704" inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:window-height="1016" inkscape:window-maximized="1" inkscape:window-width="1920" inkscape:window-x="1280" inkscape:window-y="27" inkscape:zoom="0.46439736" objecttolerance="10" pagecolor="#ffffff" showgrid="false"> | ||||
| 	</sodipodi:namedview> | ||||
| <g> | ||||
| 	<path d="M1022.3,428.7c-17.8-19.9-42.7-29.8-74.7-29.8c-22.3,0-42.4,5.7-60.5,17.3c-18.1,11.6-32.3,27.5-42.5,47.8 | ||||
| 		s-15.3,42.9-15.3,67.8c0,24.9,5.1,47.5,15.3,67.8c10.3,20.3,24.4,36.2,42.5,47.8c18.1,11.5,38.3,17.3,60.5,17.3 | ||||
| 		c32,0,56.9-9.9,74.7-29.8v20.4v0.2h84.5V408.3h-84.5V428.7z M1010.5,575c-10.2,11.7-23.6,17.6-40.2,17.6s-29.9-5.9-40-17.6 | ||||
| 		s-15.1-26.1-15.1-43.3c0-17.1,5-31.6,15.1-43.3s23.4-17.6,40-17.6c16.6,0,30,5.9,40.2,17.6s15.3,26.1,15.3,43.3 | ||||
| 		S1020.7,563.3,1010.5,575z"/> | ||||
| 	<path d="M1381,416.1c-18.1-11.5-38.3-17.3-60.5-17.4c-32,0-56.9,9.9-74.7,29.8v-20.4h-84.5v390.7h84.5v-164 | ||||
| 		c17.8,19.9,42.7,29.8,74.7,29.8c22.3,0,42.4-5.7,60.5-17.3s32.3-27.5,42.5-47.8c10.2-20.3,15.3-42.9,15.3-67.8s-5.1-47.5-15.3-67.8 | ||||
| 		C1413.2,443.6,1399.1,427.7,1381,416.1z M1337.9,575c-10.1,11.7-23.4,17.6-40,17.6s-29.9-5.9-40-17.6s-15.1-26.1-15.1-43.3 | ||||
| 		c0-17.1,5-31.6,15.1-43.3s23.4-17.6,40-17.6s29.9,5.9,40,17.6s15.1,26.1,15.1,43.3S1347.9,563.3,1337.9,575z"/> | ||||
| 	<path d="M1672.2,416.8c-20.5-12-43-18-67.6-18c-24.9,0-47.6,5.9-68,17.6c-20.4,11.7-36.5,27.7-48.2,48s-17.6,42.7-17.6,67.3 | ||||
| 		c0.3,25.2,6.2,47.8,17.8,68c11.5,20.2,28,36,49.3,47.6c21.3,11.5,45.9,17.3,73.8,17.3c48.6,0,86.8-14.7,114.7-44l-52.5-48.9 | ||||
| 		c-8.6,8.3-17.6,14.6-26.7,19c-9.3,4.3-21.1,6.4-35.3,6.4c-11.6,0-22.5-3.6-32.7-10.9c-10.3-7.3-17.1-16.5-20.7-27.8h180l0.4-11.6 | ||||
| 		c0-29.6-6-55.7-18-78.2S1692.6,428.8,1672.2,416.8z M1558.3,503.2c2.1-12.1,7.5-21.8,16.2-29.1s18.7-10.9,30-10.9 | ||||
| 		s21.2,3.6,29.8,10.9c8.6,7.2,13.9,16.9,16,29.1H1558.3z"/> | ||||
| 	<path d="M1895.3,411.7c-11,5.6-20.3,13.7-28,24.4h-0.1v-28h-84.5v247.3h84.5V536.3c0-22.6,4.7-38.1,14.2-46.5 | ||||
| 		c9.5-8.5,22.7-12.7,39.6-12.7c6.2,0,13.5,1,21.8,3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9C1917.1,403.3,1906.3,406.1,1895.3,411.7z" | ||||
| 		/> | ||||
| 	<rect x="1985" y="277.4" width="84.5" height="377.8"/> | ||||
| 	<path d="M2313.2,416.8c-20.5-12-43-18-67.6-18c-24.9,0-47.6,5.9-68,17.6s-36.5,27.7-48.2,48c-11.7,20.3-17.6,42.7-17.6,67.3 | ||||
| 		c0.3,25.2,6.2,47.8,17.8,68c11.5,20.2,28,36,49.3,47.6c21.3,11.5,45.9,17.3,73.8,17.3c48.6,0,86.8-14.7,114.7-44l-52.5-48.9 | ||||
| 		c-8.6,8.3-17.6,14.6-26.7,19c-9.3,4.3-21.1,6.4-35.3,6.4c-11.6,0-22.5-3.6-32.7-10.9c-10.3-7.3-17.1-16.5-20.7-27.8h180l0.4-11.6 | ||||
| 		c0-29.6-6-55.7-18-78.2S2333.6,428.8,2313.2,416.8z M2199.3,503.2c2.1-12.1,7.5-21.8,16.2-29.1s18.7-10.9,30-10.9 | ||||
| 		s21.2,3.6,29.8,10.9c8.6,7.2,13.9,16.9,16,29.1H2199.3z"/> | ||||
| 	<path d="M2583.6,507.7c-13.8-4.4-30.6-8.1-50.5-11.1c-15.1-2.7-26.1-5.2-32.9-7.6c-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7,6.7-10.9 | ||||
| 		c4.4-2.2,11.5-3.3,21.3-3.3c11.6,0,24.3,2.4,38.1,7.2c13.9,4.8,26.2,11,36.9,18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8 | ||||
| 		c-18.7-7.1-39.6-10.7-62.7-10.7c-33.7,0-60.2,7.6-79.3,22.7c-19.1,15.1-28.7,36.1-28.7,63.1c0,19,4.8,33.9,14.4,44.7 | ||||
| 		c9.6,10.8,21,18.5,34,22.9c13.1,4.5,28.9,8.3,47.6,11.6c14.6,2.7,25.1,5.3,31.6,7.8s9.8,6.5,9.8,11.8c0,10.4-9.7,15.6-29.3,15.6 | ||||
| 		c-13.7,0-28.5-2.3-44.7-6.9c-16.1-4.6-29.2-11.3-39.3-20.2l-33.3,60c9.2,7.4,24.6,14.7,46.2,22c21.7,7.3,45.2,10.9,70.7,10.9 | ||||
| 		c34.7,0,62.9-7.4,84.5-22.4c21.7-15,32.5-37.3,32.5-66.9c0-19.3-5-34.2-15.1-44.9S2597.4,512.1,2583.6,507.7z"/> | ||||
| 	<path d="M2883.4,575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1c-15.1-2.7-26.1-5.2-32.9-7.6 | ||||
| 		c-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7,6.7-10.9c4.4-2.2,11.5-3.3,21.3-3.3c11.6,0,24.3,2.4,38.1,7.2c13.9,4.8,26.2,11,36.9,18.4 | ||||
| 		l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8c-18.7-7.1-39.6-10.7-62.7-10.7c-33.7,0-60.2,7.6-79.3,22.7 | ||||
| 		c-19.1,15.1-28.7,36.1-28.7,63.1c0,19,4.8,33.9,14.4,44.7c9.6,10.8,21,18.5,34,22.9c13.1,4.5,28.9,8.3,47.6,11.6 | ||||
| 		c14.6,2.7,25.1,5.3,31.6,7.8s9.8,6.5,9.8,11.8c0,10.4-9.7,15.6-29.3,15.6c-13.7,0-28.5-2.3-44.7-6.9c-16.1-4.6-29.2-11.3-39.3-20.2 | ||||
| 		l-33.3,60c9.2,7.4,24.6,14.7,46.2,22c21.7,7.3,45.2,10.9,70.7,10.9c34.7,0,62.9-7.4,84.5-22.4 | ||||
| 		C2872.6,627.2,2883.4,604.9,2883.4,575.3z"/> | ||||
| 	<rect x="2460.7" y="738.7" width="59.6" height="17.2"/> | ||||
| 	<path d="M2596.5,706.4c-5.7,0-11,1-15.8,3s-9,5-12.5,8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6,2.1-15.3,6.3-20c4.2-4.7,9.5-7.1,15.9-7.1 | ||||
| 		c7.8,0,13.4,2.3,16.8,6.7c3.4,4.5,5.1,11.3,5.1,20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3 | ||||
| 		C2615.8,709.8,2607.3,706.4,2596.5,706.4z"/> | ||||
| 	<path d="M2733.8,717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7,0-16.5,2.1-23.5,6.3s-12.5,10-16.5,17.3 | ||||
| 		c-4,7.3-6,15.4-6,24.4c0,8.9,2,17.1,6,24.3c4,7.3,9.5,13,16.5,17.2s14.9,6.3,23.5,6.3c5.6,0,11-1,16.2-3.1 | ||||
| 		c5.1-2.1,9.5-4.8,13.1-8.2v24.4c0,8.5-2.5,14.8-7.6,18.7c-5,3.9-11,5.9-18,5.9c-6.7,0-12.4-1.6-17.3-4.7c-4.8-3.1-7.6-7.7-8.3-13.8 | ||||
| 		h-19.4c0.6,7.7,2.9,14.2,7.1,19.5s9.6,9.3,16.2,12c6.6,2.7,13.8,4,21.7,4c12.8,0,23.5-3.4,32-10.1c8.6-6.7,12.8-17.1,12.8-31.1 | ||||
| 		V708.9h-19.2V717.7z M2732.2,770.1c-2.5,4.7-6,8.3-10.4,11.2c-4.4,2.7-9.4,4-14.9,4c-5.7,0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4 | ||||
| 		c-2.3-4.8-3.5-9.8-3.5-15.2c0-5.5,1.1-10.6,3.5-15.3s5.8-8.5,10.2-11.3s9.5-4.2,15.2-4.2c5.5,0,10.5,1.4,14.9,4s7.9,6.3,10.4,11 | ||||
| 		s3.8,10,3.8,15.8S2734.7,765.4,2732.2,770.1z"/> | ||||
| 	<polygon points="2867.9,708.9 2846.5,708.9 2820.9,741.9 2795.5,708.9 2773.1,708.9 2809.1,755 2771.5,802.5 2792.9,802.5  | ||||
| 		2820.1,767.9 2847.2,802.6 2869.6,802.6 2832,754.4 	"/> | ||||
| 	<path d="M757.6,293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5,39.2-21.1,76.4-37.6,111.3c-9.9,20.8-21.1,40.6-33.6,59.4v207.2h88.9 | ||||
| 		V521.5h72c25.2,0,47.8-5.4,67.8-16.2s35.7-25.6,47.1-44.2c11.4-18.7,17.1-39.1,17.1-61.3c0.1-22.7-5.6-43.3-17-61.9 | ||||
| 		C793.3,319.2,777.6,304.5,757.6,293.7z M716.6,434.3c-9.3,8.9-21.6,13.3-36.7,13.3l-62.2,0.4v-92.5l62.2-0.4 | ||||
| 		c15.1,0,27.3,4.4,36.7,13.3c9.4,8.9,14,19.9,14,32.9C730.6,414.5,726,425.4,716.6,434.3z"/> | ||||
| </g> | ||||
| <path class="st0" d="M140,713.7c-3.4-16.4-10.3-49.1-11.2-49.1c-145.7-87.1-128.4-238-80.2-324.2C59,449,251.2,524,139.1,656.8 | ||||
| 	c-0.9,1.7,5.2,22.4,10.3,41.4c22.4-37.9,56-83.6,54.3-87.9C65.9,273.9,496.9,248.1,586.6,39.4c40.5,201.8-20.7,513.9-367.2,593.2 | ||||
| 	c-1.7,0.9-62.9,108.6-65.5,109.5c0-1.7-25.9-0.9-22.4-9.5C133.1,727.4,136.6,720.6,140,713.7L140,713.7z M135.7,632.6 | ||||
| 	c44-50.9-7.8-137.9-38.8-166.4C149.5,556.7,146,609.3,135.7,632.6L135.7,632.6z"/> | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" style="enable-background:new 0 0 2897.4 896.6" xml:space="preserve"> | ||||
|   <path d="M1022.3 428.7c-17.8-19.9-42.7-29.8-74.7-29.8-22.3 0-42.4 5.7-60.5 17.3-18.1 11.6-32.3 27.5-42.5 47.8s-15.3 42.9-15.3 67.8 5.1 47.5 15.3 67.8c10.3 20.3 24.4 36.2 42.5 47.8 18.1 11.5 38.3 17.3 60.5 17.3 32 0 56.9-9.9 74.7-29.8V655.5h84.5V408.3h-84.5v20.4zM1010.5 575c-10.2 11.7-23.6 17.6-40.2 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 30 5.9 40.2 17.6 15.3 26.1 15.3 43.3-5.1 31.6-15.3 43.3zM1381 416.1c-18.1-11.5-38.3-17.3-60.5-17.4-32 0-56.9 9.9-74.7 29.8v-20.4h-84.5v390.7h84.5v-164c17.8 19.9 42.7 29.8 74.7 29.8 22.3 0 42.4-5.7 60.5-17.3s32.3-27.5 42.5-47.8c10.2-20.3 15.3-42.9 15.3-67.8s-5.1-47.5-15.3-67.8c-10.3-20.3-24.4-36.2-42.5-47.8zM1337.9 575c-10.1 11.7-23.4 17.6-40 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 29.9 5.9 40 17.6 15.1 26.1 15.1 43.3-5.1 31.6-15.1 43.3zM1672.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6-20.4 11.7-36.5 27.7-48.2 48s-17.6 42.7-17.6 67.3c.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM1895.3 411.7c-11 5.6-20.3 13.7-28 24.4h-.1v-28h-84.5v247.3h84.5V536.3c0-22.6 4.7-38.1 14.2-46.5 9.5-8.5 22.7-12.7 39.6-12.7 6.2 0 13.5 1 21.8 3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9-10.6 0-21.4 2.8-32.4 8.4zM1985 277.4h84.5v377.8H1985zM2313.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6s-36.5 27.7-48.2 48c-11.7 20.3-17.6 42.7-17.6 67.3.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM2583.6 507.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9 0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7zM2883.4 575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9zM2460.7 738.7h59.6v17.2h-59.6zM2596.5 706.4c-5.7 0-11 1-15.8 3s-9 5-12.5 8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6 2.1-15.3 6.3-20 4.2-4.7 9.5-7.1 15.9-7.1 7.8 0 13.4 2.3 16.8 6.7 3.4 4.5 5.1 11.3 5.1 20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3-6.4-6.7-14.9-10.1-25.7-10.1zM2733.8 717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7 0-16.5 2.1-23.5 6.3s-12.5 10-16.5 17.3c-4 7.3-6 15.4-6 24.4 0 8.9 2 17.1 6 24.3 4 7.3 9.5 13 16.5 17.2s14.9 6.3 23.5 6.3c5.6 0 11-1 16.2-3.1 5.1-2.1 9.5-4.8 13.1-8.2v24.4c0 8.5-2.5 14.8-7.6 18.7-5 3.9-11 5.9-18 5.9-6.7 0-12.4-1.6-17.3-4.7-4.8-3.1-7.6-7.7-8.3-13.8h-19.4c.6 7.7 2.9 14.2 7.1 19.5s9.6 9.3 16.2 12c6.6 2.7 13.8 4 21.7 4 12.8 0 23.5-3.4 32-10.1 8.6-6.7 12.8-17.1 12.8-31.1V708.9h-19.2v8.8zm-1.6 52.4c-2.5 4.7-6 8.3-10.4 11.2-4.4 2.7-9.4 4-14.9 4-5.7 0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4c-2.3-4.8-3.5-9.8-3.5-15.2 0-5.5 1.1-10.6 3.5-15.3s5.8-8.5 10.2-11.3 9.5-4.2 15.2-4.2c5.5 0 10.5 1.4 14.9 4s7.9 6.3 10.4 11 3.8 10 3.8 15.8-1.3 11-3.8 15.7zM2867.9 708.9h-21.4l-25.6 33-25.4-33h-22.4l36 46.1-37.6 47.5h21.4l27.2-34.6 27.1 34.7h22.4l-37.6-48.2zM757.6 293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5 39.2-21.1 76.4-37.6 111.3-9.9 20.8-21.1 40.6-33.6 59.4v207.2h88.9V521.5h72c25.2 0 47.8-5.4 67.8-16.2s35.7-25.6 47.1-44.2c11.4-18.7 17.1-39.1 17.1-61.3.1-22.7-5.6-43.3-17-61.9-11.4-18.7-27.1-33.4-47.1-44.2zm-41 140.6c-9.3 8.9-21.6 13.3-36.7 13.3l-62.2.4v-92.5l62.2-.4c15.1 0 27.3 4.4 36.7 13.3 9.4 8.9 14 19.9 14 32.9 0 13.2-4.6 24.1-14 33z"/> | ||||
|   <path d="M140 713.7c-3.4-16.4-10.3-49.1-11.2-49.1C-16.9 577.5.4 426.6 48.6 340.4 59 449 251.2 524 139.1 656.8c-.9 1.7 5.2 22.4 10.3 41.4 22.4-37.9 56-83.6 54.3-87.9C65.9 273.9 496.9 248.1 586.6 39.4c40.5 201.8-20.7 513.9-367.2 593.2-1.7.9-62.9 108.6-65.5 109.5 0-1.7-25.9-.9-22.4-9.5 1.6-5.2 5.1-12 8.5-18.9zm-4.3-81.1c44-50.9-7.8-137.9-38.8-166.4 52.6 90.5 49.1 143.1 38.8 166.4z" style="fill:#17541f"/> | ||||
| </svg> | ||||
|   | ||||
| Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 5.4 KiB | 
							
								
								
									
										3
									
								
								src-ui/src/assets/logo-notext.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="264.567" height="318.552" viewBox="0 0 70 84.284"> | ||||
|   <path style="fill:#17541f;stroke-width:1.10017" d="M752.438 82.365C638.02 348.605 87.938 381.61 263.964 810.674c2.2 5.5-40.706 63.81-69.31 112.217-6.602-24.204-14.304-50.607-13.204-52.807C324.473 700.658 79.136 604.944 65.934 466.322 4.324 576.34-17.678 768.868 168.25 879.984c1.1 0 9.902 41.808 14.303 62.711-4.4 8.802-8.802 17.602-11.002 24.203-4.4 11.002 28.603 9.902 28.603 12.102 3.3-1.1 81.413-138.62 83.614-139.72 442.267-101.216 520.377-499.476 468.67-756.915ZM526.904 362.906c-206.831 184.828-242.036 322.35-235.435 389.46-69.31-163.926 134.22-344.353 235.435-389.46ZM127.543 626.947c39.606 36.306 105.616 147.422 49.508 212.332 13.202-29.704 17.602-96.814-49.508-212.332z" transform="matrix(.094 0 0 .094 -2.042 -7.742)" fill="#17541F"/> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 855 B | 
| @@ -1,69 +1,3 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <svg | ||||
|    xmlns:dc="http://purl.org/dc/elements/1.1/" | ||||
|    xmlns:cc="http://creativecommons.org/ns#" | ||||
|    xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||
|    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||
|    width="69.999977mm" | ||||
|    height="84.283669mm" | ||||
|    viewBox="0 0 69.999977 84.283669" | ||||
|    version="1.1" | ||||
|    id="svg4812" | ||||
|    inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)" | ||||
|    sodipodi:docname="logo-dark-notext.svg"> | ||||
|   <defs | ||||
|      id="defs4806" /> | ||||
|   <sodipodi:namedview | ||||
|      id="base" | ||||
|      pagecolor="#ffffff" | ||||
|      bordercolor="#666666" | ||||
|      borderopacity="1.0" | ||||
|      inkscape:pageopacity="0.0" | ||||
|      inkscape:pageshadow="2" | ||||
|      inkscape:zoom="0.98994949" | ||||
|      inkscape:cx="328.04904" | ||||
|      inkscape:cy="330.33332" | ||||
|      inkscape:document-units="mm" | ||||
|      inkscape:current-layer="SvgjsG1020" | ||||
|      inkscape:document-rotation="0" | ||||
|      showgrid="false" | ||||
|      inkscape:window-width="1920" | ||||
|      inkscape:window-height="1016" | ||||
|      inkscape:window-x="1280" | ||||
|      inkscape:window-y="27" | ||||
|      inkscape:window-maximized="1" /> | ||||
|   <metadata | ||||
|      id="metadata4809"> | ||||
|     <rdf:RDF> | ||||
|       <cc:Work | ||||
|          rdf:about=""> | ||||
|         <dc:format>image/svg+xml</dc:format> | ||||
|         <dc:type | ||||
|            rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> | ||||
|         <dc:title></dc:title> | ||||
|       </cc:Work> | ||||
|     </rdf:RDF> | ||||
|   </metadata> | ||||
|   <g | ||||
|      inkscape:label="Layer 1" | ||||
|      inkscape:groupmode="layer" | ||||
|      id="layer1" | ||||
|      transform="translate(-9.9999792,-10.000082)"> | ||||
|     <g | ||||
|        id="SvgjsG1020" | ||||
|        featureKey="symbol1" | ||||
|        fill="#ffffff" | ||||
|        transform="matrix(0.10341565,0,0,0.10341565,1.2287665,8.3453496)"> | ||||
|       <path | ||||
|          id="path57" | ||||
|          style="fill:#ffffff;stroke-width:1.10017" | ||||
|          d="M 752.4375,82.365234 C 638.02019,348.60552 87.938206,381.6089 263.96484,810.67383 c 2.20034,5.50083 -40.70621,63.80947 -69.31054,112.21679 -6.601,-24.20366 -14.30329,-50.6063 -13.20313,-52.80664 C 324.47281,700.65835 79.135592,604.94324 65.933594,466.32227 4.3242706,576.33891 -17.678136,768.86756 168.25,879.98438 c 1.10017,-10e-6 9.90207,41.80777 14.30273,62.71093 -4.40066,8.80133 -8.80162,17.60213 -11.00195,24.20313 -4.40066,11.00166 28.60352,9.90123 28.60352,12.10156 3.3005,-1.10017 81.41295,-138.62054 83.61328,-139.7207 C 726.0345,738.06398 804.14532,339.80419 752.4375,82.365234 Z M 526.9043,362.90625 C 320.073,547.73422 284.86775,685.25508 291.46875,752.36523 222.15826,588.44043 425.68898,408.01308 526.9043,362.90625 Z M 127.54297,626.94727 c 39.60599,36.30549 105.6163,147.4222 49.50781,212.33203 13.202,-29.7045 17.60234,-96.81455 -49.50781,-212.33203 z" | ||||
|          transform="matrix(0.90895334,0,0,0.90895334,65.06894,-58.865357)" /> | ||||
|       <defs | ||||
|          id="defs14302" /> | ||||
|     </g> | ||||
|   </g> | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="264.567" height="318.552" viewBox="0 0 70 84.284"> | ||||
|   <path style="fill:#fff;stroke-width:1.10017" d="M752.438 82.365C638.02 348.605 87.938 381.61 263.964 810.674c2.2 5.5-40.706 63.81-69.31 112.217-6.602-24.204-14.304-50.607-13.204-52.807C324.473 700.658 79.136 604.944 65.934 466.322 4.324 576.34-17.678 768.868 168.25 879.984c1.1 0 9.902 41.808 14.303 62.711-4.4 8.802-8.802 17.602-11.002 24.203-4.4 11.002 28.603 9.902 28.603 12.102 3.3-1.1 81.413-138.62 83.614-139.72 442.267-101.216 520.377-499.476 468.67-756.915ZM526.904 362.906c-206.831 184.828-242.036 322.35-235.435 389.46-69.31-163.926 134.22-344.353 235.435-389.46ZM127.543 626.947c39.606 36.306 105.616 147.422 49.508 212.332 13.202-29.704 17.602-96.814-49.508-212.332z" transform="matrix(.094 0 0 .094 -2.042 -7.742)" fill="#fff"/> | ||||
| </svg> | ||||
|   | ||||
| Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 849 B | 
| @@ -1,71 +1,4 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0)  --> | ||||
| <svg version="1.1" | ||||
| 	 id="svg9580" inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)" sodipodi:docname="logo.svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg" | ||||
| 	 xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 2897.4 896.6" | ||||
| 	 style="enable-background:new 0 0 2897.4 896.6;" xml:space="preserve"> | ||||
| <style type="text/css"> | ||||
| 	.st0{fill:#17541F;} | ||||
| </style> | ||||
| <sodipodi:namedview  bordercolor="#666666" borderopacity="1" gridtolerance="10" guidetolerance="10" id="namedview9582" inkscape:current-layer="g9578" inkscape:cx="1393.617" inkscape:cy="393.61704" inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:window-height="1016" inkscape:window-maximized="1" inkscape:window-width="1920" inkscape:window-x="1280" inkscape:window-y="27" inkscape:zoom="0.46439736" objecttolerance="10" pagecolor="#ffffff" showgrid="false"> | ||||
| 	</sodipodi:namedview> | ||||
| <g> | ||||
| 	<path d="M1022.3,428.7c-17.8-19.9-42.7-29.8-74.7-29.8c-22.3,0-42.4,5.7-60.5,17.3c-18.1,11.6-32.3,27.5-42.5,47.8 | ||||
| 		s-15.3,42.9-15.3,67.8c0,24.9,5.1,47.5,15.3,67.8c10.3,20.3,24.4,36.2,42.5,47.8c18.1,11.5,38.3,17.3,60.5,17.3 | ||||
| 		c32,0,56.9-9.9,74.7-29.8v20.4v0.2h84.5V408.3h-84.5V428.7z M1010.5,575c-10.2,11.7-23.6,17.6-40.2,17.6s-29.9-5.9-40-17.6 | ||||
| 		s-15.1-26.1-15.1-43.3c0-17.1,5-31.6,15.1-43.3s23.4-17.6,40-17.6c16.6,0,30,5.9,40.2,17.6s15.3,26.1,15.3,43.3 | ||||
| 		S1020.7,563.3,1010.5,575z"/> | ||||
| 	<path d="M1381,416.1c-18.1-11.5-38.3-17.3-60.5-17.4c-32,0-56.9,9.9-74.7,29.8v-20.4h-84.5v390.7h84.5v-164 | ||||
| 		c17.8,19.9,42.7,29.8,74.7,29.8c22.3,0,42.4-5.7,60.5-17.3s32.3-27.5,42.5-47.8c10.2-20.3,15.3-42.9,15.3-67.8s-5.1-47.5-15.3-67.8 | ||||
| 		C1413.2,443.6,1399.1,427.7,1381,416.1z M1337.9,575c-10.1,11.7-23.4,17.6-40,17.6s-29.9-5.9-40-17.6s-15.1-26.1-15.1-43.3 | ||||
| 		c0-17.1,5-31.6,15.1-43.3s23.4-17.6,40-17.6s29.9,5.9,40,17.6s15.1,26.1,15.1,43.3S1347.9,563.3,1337.9,575z"/> | ||||
| 	<path d="M1672.2,416.8c-20.5-12-43-18-67.6-18c-24.9,0-47.6,5.9-68,17.6c-20.4,11.7-36.5,27.7-48.2,48s-17.6,42.7-17.6,67.3 | ||||
| 		c0.3,25.2,6.2,47.8,17.8,68c11.5,20.2,28,36,49.3,47.6c21.3,11.5,45.9,17.3,73.8,17.3c48.6,0,86.8-14.7,114.7-44l-52.5-48.9 | ||||
| 		c-8.6,8.3-17.6,14.6-26.7,19c-9.3,4.3-21.1,6.4-35.3,6.4c-11.6,0-22.5-3.6-32.7-10.9c-10.3-7.3-17.1-16.5-20.7-27.8h180l0.4-11.6 | ||||
| 		c0-29.6-6-55.7-18-78.2S1692.6,428.8,1672.2,416.8z M1558.3,503.2c2.1-12.1,7.5-21.8,16.2-29.1s18.7-10.9,30-10.9 | ||||
| 		s21.2,3.6,29.8,10.9c8.6,7.2,13.9,16.9,16,29.1H1558.3z"/> | ||||
| 	<path d="M1895.3,411.7c-11,5.6-20.3,13.7-28,24.4h-0.1v-28h-84.5v247.3h84.5V536.3c0-22.6,4.7-38.1,14.2-46.5 | ||||
| 		c9.5-8.5,22.7-12.7,39.6-12.7c6.2,0,13.5,1,21.8,3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9C1917.1,403.3,1906.3,406.1,1895.3,411.7z" | ||||
| 		/> | ||||
| 	<rect x="1985" y="277.4" width="84.5" height="377.8"/> | ||||
| 	<path d="M2313.2,416.8c-20.5-12-43-18-67.6-18c-24.9,0-47.6,5.9-68,17.6s-36.5,27.7-48.2,48c-11.7,20.3-17.6,42.7-17.6,67.3 | ||||
| 		c0.3,25.2,6.2,47.8,17.8,68c11.5,20.2,28,36,49.3,47.6c21.3,11.5,45.9,17.3,73.8,17.3c48.6,0,86.8-14.7,114.7-44l-52.5-48.9 | ||||
| 		c-8.6,8.3-17.6,14.6-26.7,19c-9.3,4.3-21.1,6.4-35.3,6.4c-11.6,0-22.5-3.6-32.7-10.9c-10.3-7.3-17.1-16.5-20.7-27.8h180l0.4-11.6 | ||||
| 		c0-29.6-6-55.7-18-78.2S2333.6,428.8,2313.2,416.8z M2199.3,503.2c2.1-12.1,7.5-21.8,16.2-29.1s18.7-10.9,30-10.9 | ||||
| 		s21.2,3.6,29.8,10.9c8.6,7.2,13.9,16.9,16,29.1H2199.3z"/> | ||||
| 	<path d="M2583.6,507.7c-13.8-4.4-30.6-8.1-50.5-11.1c-15.1-2.7-26.1-5.2-32.9-7.6c-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7,6.7-10.9 | ||||
| 		c4.4-2.2,11.5-3.3,21.3-3.3c11.6,0,24.3,2.4,38.1,7.2c13.9,4.8,26.2,11,36.9,18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8 | ||||
| 		c-18.7-7.1-39.6-10.7-62.7-10.7c-33.7,0-60.2,7.6-79.3,22.7c-19.1,15.1-28.7,36.1-28.7,63.1c0,19,4.8,33.9,14.4,44.7 | ||||
| 		c9.6,10.8,21,18.5,34,22.9c13.1,4.5,28.9,8.3,47.6,11.6c14.6,2.7,25.1,5.3,31.6,7.8s9.8,6.5,9.8,11.8c0,10.4-9.7,15.6-29.3,15.6 | ||||
| 		c-13.7,0-28.5-2.3-44.7-6.9c-16.1-4.6-29.2-11.3-39.3-20.2l-33.3,60c9.2,7.4,24.6,14.7,46.2,22c21.7,7.3,45.2,10.9,70.7,10.9 | ||||
| 		c34.7,0,62.9-7.4,84.5-22.4c21.7-15,32.5-37.3,32.5-66.9c0-19.3-5-34.2-15.1-44.9S2597.4,512.1,2583.6,507.7z"/> | ||||
| 	<path d="M2883.4,575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1c-15.1-2.7-26.1-5.2-32.9-7.6 | ||||
| 		c-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7,6.7-10.9c4.4-2.2,11.5-3.3,21.3-3.3c11.6,0,24.3,2.4,38.1,7.2c13.9,4.8,26.2,11,36.9,18.4 | ||||
| 		l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8c-18.7-7.1-39.6-10.7-62.7-10.7c-33.7,0-60.2,7.6-79.3,22.7 | ||||
| 		c-19.1,15.1-28.7,36.1-28.7,63.1c0,19,4.8,33.9,14.4,44.7c9.6,10.8,21,18.5,34,22.9c13.1,4.5,28.9,8.3,47.6,11.6 | ||||
| 		c14.6,2.7,25.1,5.3,31.6,7.8s9.8,6.5,9.8,11.8c0,10.4-9.7,15.6-29.3,15.6c-13.7,0-28.5-2.3-44.7-6.9c-16.1-4.6-29.2-11.3-39.3-20.2 | ||||
| 		l-33.3,60c9.2,7.4,24.6,14.7,46.2,22c21.7,7.3,45.2,10.9,70.7,10.9c34.7,0,62.9-7.4,84.5-22.4 | ||||
| 		C2872.6,627.2,2883.4,604.9,2883.4,575.3z"/> | ||||
| 	<rect x="2460.7" y="738.7" width="59.6" height="17.2"/> | ||||
| 	<path d="M2596.5,706.4c-5.7,0-11,1-15.8,3s-9,5-12.5,8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6,2.1-15.3,6.3-20c4.2-4.7,9.5-7.1,15.9-7.1 | ||||
| 		c7.8,0,13.4,2.3,16.8,6.7c3.4,4.5,5.1,11.3,5.1,20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3 | ||||
| 		C2615.8,709.8,2607.3,706.4,2596.5,706.4z"/> | ||||
| 	<path d="M2733.8,717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7,0-16.5,2.1-23.5,6.3s-12.5,10-16.5,17.3 | ||||
| 		c-4,7.3-6,15.4-6,24.4c0,8.9,2,17.1,6,24.3c4,7.3,9.5,13,16.5,17.2s14.9,6.3,23.5,6.3c5.6,0,11-1,16.2-3.1 | ||||
| 		c5.1-2.1,9.5-4.8,13.1-8.2v24.4c0,8.5-2.5,14.8-7.6,18.7c-5,3.9-11,5.9-18,5.9c-6.7,0-12.4-1.6-17.3-4.7c-4.8-3.1-7.6-7.7-8.3-13.8 | ||||
| 		h-19.4c0.6,7.7,2.9,14.2,7.1,19.5s9.6,9.3,16.2,12c6.6,2.7,13.8,4,21.7,4c12.8,0,23.5-3.4,32-10.1c8.6-6.7,12.8-17.1,12.8-31.1 | ||||
| 		V708.9h-19.2V717.7z M2732.2,770.1c-2.5,4.7-6,8.3-10.4,11.2c-4.4,2.7-9.4,4-14.9,4c-5.7,0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4 | ||||
| 		c-2.3-4.8-3.5-9.8-3.5-15.2c0-5.5,1.1-10.6,3.5-15.3s5.8-8.5,10.2-11.3s9.5-4.2,15.2-4.2c5.5,0,10.5,1.4,14.9,4s7.9,6.3,10.4,11 | ||||
| 		s3.8,10,3.8,15.8S2734.7,765.4,2732.2,770.1z"/> | ||||
| 	<polygon points="2867.9,708.9 2846.5,708.9 2820.9,741.9 2795.5,708.9 2773.1,708.9 2809.1,755 2771.5,802.5 2792.9,802.5  | ||||
| 		2820.1,767.9 2847.2,802.6 2869.6,802.6 2832,754.4 	"/> | ||||
| 	<path d="M757.6,293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5,39.2-21.1,76.4-37.6,111.3c-9.9,20.8-21.1,40.6-33.6,59.4v207.2h88.9 | ||||
| 		V521.5h72c25.2,0,47.8-5.4,67.8-16.2s35.7-25.6,47.1-44.2c11.4-18.7,17.1-39.1,17.1-61.3c0.1-22.7-5.6-43.3-17-61.9 | ||||
| 		C793.3,319.2,777.6,304.5,757.6,293.7z M716.6,434.3c-9.3,8.9-21.6,13.3-36.7,13.3l-62.2,0.4v-92.5l62.2-0.4 | ||||
| 		c15.1,0,27.3,4.4,36.7,13.3c9.4,8.9,14,19.9,14,32.9C730.6,414.5,726,425.4,716.6,434.3z"/> | ||||
| </g> | ||||
| <path class="st0" d="M140,713.7c-3.4-16.4-10.3-49.1-11.2-49.1c-145.7-87.1-128.4-238-80.2-324.2C59,449,251.2,524,139.1,656.8 | ||||
| 	c-0.9,1.7,5.2,22.4,10.3,41.4c22.4-37.9,56-83.6,54.3-87.9C65.9,273.9,496.9,248.1,586.6,39.4c40.5,201.8-20.7,513.9-367.2,593.2 | ||||
| 	c-1.7,0.9-62.9,108.6-65.5,109.5c0-1.7-25.9-0.9-22.4-9.5C133.1,727.4,136.6,720.6,140,713.7L140,713.7z M135.7,632.6 | ||||
| 	c44-50.9-7.8-137.9-38.8-166.4C149.5,556.7,146,609.3,135.7,632.6L135.7,632.6z"/> | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" style="enable-background:new 0 0 2897.4 896.6" xml:space="preserve"> | ||||
|   <path d="M1022.3 428.7c-17.8-19.9-42.7-29.8-74.7-29.8-22.3 0-42.4 5.7-60.5 17.3-18.1 11.6-32.3 27.5-42.5 47.8s-15.3 42.9-15.3 67.8 5.1 47.5 15.3 67.8c10.3 20.3 24.4 36.2 42.5 47.8 18.1 11.5 38.3 17.3 60.5 17.3 32 0 56.9-9.9 74.7-29.8V655.5h84.5V408.3h-84.5v20.4zM1010.5 575c-10.2 11.7-23.6 17.6-40.2 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 30 5.9 40.2 17.6 15.3 26.1 15.3 43.3-5.1 31.6-15.3 43.3zM1381 416.1c-18.1-11.5-38.3-17.3-60.5-17.4-32 0-56.9 9.9-74.7 29.8v-20.4h-84.5v390.7h84.5v-164c17.8 19.9 42.7 29.8 74.7 29.8 22.3 0 42.4-5.7 60.5-17.3s32.3-27.5 42.5-47.8c10.2-20.3 15.3-42.9 15.3-67.8s-5.1-47.5-15.3-67.8c-10.3-20.3-24.4-36.2-42.5-47.8zM1337.9 575c-10.1 11.7-23.4 17.6-40 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 29.9 5.9 40 17.6 15.1 26.1 15.1 43.3-5.1 31.6-15.1 43.3zM1672.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6-20.4 11.7-36.5 27.7-48.2 48s-17.6 42.7-17.6 67.3c.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM1895.3 411.7c-11 5.6-20.3 13.7-28 24.4h-.1v-28h-84.5v247.3h84.5V536.3c0-22.6 4.7-38.1 14.2-46.5 9.5-8.5 22.7-12.7 39.6-12.7 6.2 0 13.5 1 21.8 3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9-10.6 0-21.4 2.8-32.4 8.4zM1985 277.4h84.5v377.8H1985zM2313.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6s-36.5 27.7-48.2 48c-11.7 20.3-17.6 42.7-17.6 67.3.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM2583.6 507.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9 0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7zM2883.4 575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9zM2460.7 738.7h59.6v17.2h-59.6zM2596.5 706.4c-5.7 0-11 1-15.8 3s-9 5-12.5 8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6 2.1-15.3 6.3-20 4.2-4.7 9.5-7.1 15.9-7.1 7.8 0 13.4 2.3 16.8 6.7 3.4 4.5 5.1 11.3 5.1 20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3-6.4-6.7-14.9-10.1-25.7-10.1zM2733.8 717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7 0-16.5 2.1-23.5 6.3s-12.5 10-16.5 17.3c-4 7.3-6 15.4-6 24.4 0 8.9 2 17.1 6 24.3 4 7.3 9.5 13 16.5 17.2s14.9 6.3 23.5 6.3c5.6 0 11-1 16.2-3.1 5.1-2.1 9.5-4.8 13.1-8.2v24.4c0 8.5-2.5 14.8-7.6 18.7-5 3.9-11 5.9-18 5.9-6.7 0-12.4-1.6-17.3-4.7-4.8-3.1-7.6-7.7-8.3-13.8h-19.4c.6 7.7 2.9 14.2 7.1 19.5s9.6 9.3 16.2 12c6.6 2.7 13.8 4 21.7 4 12.8 0 23.5-3.4 32-10.1 8.6-6.7 12.8-17.1 12.8-31.1V708.9h-19.2v8.8zm-1.6 52.4c-2.5 4.7-6 8.3-10.4 11.2-4.4 2.7-9.4 4-14.9 4-5.7 0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4c-2.3-4.8-3.5-9.8-3.5-15.2 0-5.5 1.1-10.6 3.5-15.3s5.8-8.5 10.2-11.3 9.5-4.2 15.2-4.2c5.5 0 10.5 1.4 14.9 4s7.9 6.3 10.4 11 3.8 10 3.8 15.8-1.3 11-3.8 15.7zM2867.9 708.9h-21.4l-25.6 33-25.4-33h-22.4l36 46.1-37.6 47.5h21.4l27.2-34.6 27.1 34.7h22.4l-37.6-48.2zM757.6 293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5 39.2-21.1 76.4-37.6 111.3-9.9 20.8-21.1 40.6-33.6 59.4v207.2h88.9V521.5h72c25.2 0 47.8-5.4 67.8-16.2s35.7-25.6 47.1-44.2c11.4-18.7 17.1-39.1 17.1-61.3.1-22.7-5.6-43.3-17-61.9-11.4-18.7-27.1-33.4-47.1-44.2zm-41 140.6c-9.3 8.9-21.6 13.3-36.7 13.3l-62.2.4v-92.5l62.2-.4c15.1 0 27.3 4.4 36.7 13.3 9.4 8.9 14 19.9 14 32.9 0 13.2-4.6 24.1-14 33z"/> | ||||
|   <path d="M140 713.7c-3.4-16.4-10.3-49.1-11.2-49.1C-16.9 577.5.4 426.6 48.6 340.4 59 449 251.2 524 139.1 656.8c-.9 1.7 5.2 22.4 10.3 41.4 22.4-37.9 56-83.6 54.3-87.9C65.9 273.9 496.9 248.1 586.6 39.4c40.5 201.8-20.7 513.9-367.2 593.2-1.7.9-62.9 108.6-65.5 109.5 0-1.7-25.9-.9-22.4-9.5 1.6-5.2 5.1-12 8.5-18.9zm-4.3-81.1c44-50.9-7.8-137.9-38.8-166.4 52.6 90.5 49.1 143.1 38.8 166.4z" style="fill:#17541f"/> | ||||
| </svg> | ||||
|   | ||||
| Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 5.4 KiB | 
 phail
					phail