Merge branch 'dev' of https://github.com/paperless-ngx/paperless-ngx into filtering-improvement

This commit is contained in:
Marco Breiter 2024-06-23 22:56:06 +02:00
commit abd4690fbf
246 changed files with 45005 additions and 31040 deletions

View File

@ -1,3 +1,3 @@
[codespell] [codespell]
write-changes = True write-changes = True
ignore-words-list = criterias,afterall,valeu,ureue,equest,ure ignore-words-list = criterias,afterall,valeu,ureue,equest,ure,assertIn

View File

@ -9,7 +9,7 @@ body:
### ⚠️ Please remember: issues are for *bugs* ### ⚠️ Please remember: issues are for *bugs*
That is, something you believe affects every single user of Paperless-ngx, not just you. If you're not sure, start with one of the other options below. That is, something you believe affects every single user of Paperless-ngx, not just you. If you're not sure, start with one of the other options below.
Also, note that **Paperless-ngx does not perform OCR itself**, that is handled by other tools. Problems with OCR of specific files should likely be raised 'upstream', see https://github.com/ocrmypdf/OCRmyPDF/issues or https://github.com/tesseract-ocr/tesseract/issues Also, note that **Paperless-ngx does not perform OCR or archive file creation itself**, those are handled by other tools. Problems with OCR or archive versions of specific files should likely be raised 'upstream', see https://github.com/ocrmypdf/OCRmyPDF/issues or https://github.com/tesseract-ocr/tesseract/issues
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
@ -86,6 +86,12 @@ body:
description: Note there are significant differences from the official image and linuxserver.io, please check if your issue is specific to the third-party image. description: Note there are significant differences from the official image and linuxserver.io, please check if your issue is specific to the third-party image.
validations: validations:
required: true required: true
- type: textarea
id: system-status
attributes:
label: System status
description: If available, copy & paste the system status output from Settings > System Status > Copy
render: json
- type: input - type: input
id: browser id: browser
attributes: attributes:
@ -97,11 +103,6 @@ body:
attributes: attributes:
label: Configuration changes label: Configuration changes
description: Any configuration changes you made in `docker-compose.yml`, `docker-compose.env` or `paperless.conf`. description: Any configuration changes you made in `docker-compose.yml`, `docker-compose.env` or `paperless.conf`.
- type: input
id: other
attributes:
label: Other
description: Any other relevant details.
- type: checkboxes - type: checkboxes
id: required-checks id: required-checks
attributes: attributes:

View File

@ -49,11 +49,14 @@ updates:
- "paperless-ngx/backend" - "paperless-ngx/backend"
ignore: ignore:
- dependency-name: "uvicorn" - dependency-name: "uvicorn"
- dependency-name: "djangorestframework"
versions:
- "3.15.0"
- "3.15.1"
groups: groups:
development: development:
patterns: patterns:
- "*pytest*" - "*pytest*"
- "black"
- "ruff" - "ruff"
- "mkdocs-material" - "mkdocs-material"
django: django:

View File

@ -16,7 +16,7 @@ on:
env: env:
# This is the version of pipenv all the steps will use # This is the version of pipenv all the steps will use
# If changing this, change Dockerfile # If changing this, change Dockerfile
DEFAULT_PIP_ENV_VERSION: "2023.12.1" DEFAULT_PIP_ENV_VERSION: "2024.0.1"
# This is the default version of Python to use in most steps which aren't specific # This is the default version of Python to use in most steps which aren't specific
DEFAULT_PYTHON_VERSION: "3.10" DEFAULT_PYTHON_VERSION: "3.10"

View File

@ -33,7 +33,7 @@ jobs:
- -
name: Clean temporary images name: Clean temporary images
if: "${{ env.TOKEN != '' }}" if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@v0.6.0 uses: stumpylog/image-cleaner-action/ephemeral@v0.7.0
with: with:
token: "${{ env.TOKEN }}" token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}" owner: "${{ github.repository_owner }}"
@ -61,7 +61,7 @@ jobs:
- -
name: Clean untagged images name: Clean untagged images
if: "${{ env.TOKEN != '' }}" if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@v0.6.0 uses: stumpylog/image-cleaner-action/untagged@v0.7.0
with: with:
token: "${{ env.TOKEN }}" token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}" owner: "${{ github.repository_owner }}"

View File

@ -21,7 +21,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: crowdin action - name: crowdin action
uses: crowdin/github-action@v1 uses: crowdin/github-action@v2
with: with:
upload_translations: false upload_translations: false
download_translations: true download_translations: true

View File

@ -22,7 +22,7 @@ jobs:
with: with:
days-before-stale: 7 days-before-stale: 7
days-before-close: 14 days-before-close: 14
any-of-labels: 'cant-reproduce,not a bug' any-of-labels: 'stale,cant-reproduce,not a bug'
stale-issue-label: stale stale-issue-label: stale
stale-pr-label: stale stale-pr-label: stale
stale-issue-message: > stale-issue-message: >

1
.gitignore vendored
View File

@ -22,6 +22,7 @@ var/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
/src/paperless_mail/templates/node_modules
# PyInstaller # PyInstaller
# Usually these files are written by a python script from a template # Usually these files are written by a python script from a template

View File

@ -29,7 +29,7 @@ repos:
- id: check-case-conflict - id: check-case-conflict
- id: detect-private-key - id: detect-private-key
- repo: https://github.com/codespell-project/codespell - repo: https://github.com/codespell-project/codespell
rev: v2.2.6 rev: v2.3.0
hooks: hooks:
- id: codespell - id: codespell
exclude: "(^src-ui/src/locale/)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)" exclude: "(^src-ui/src/locale/)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)"
@ -47,13 +47,10 @@ repos:
exclude: "(^Pipfile\\.lock$)" exclude: "(^Pipfile\\.lock$)"
# Python hooks # Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.4.2' rev: 'v0.4.9'
hooks: hooks:
- id: ruff - id: ruff
- repo: https://github.com/psf/black-pre-commit-mirror - id: ruff-format
rev: 24.4.2
hooks:
- id: black
# Dockerfile hooks # Dockerfile hooks
- repo: https://github.com/AleksaC/hadolint-py - repo: https://github.com/AleksaC/hadolint-py
rev: v2.12.0.3 rev: v2.12.0.3

View File

@ -5,7 +5,7 @@
We as members, contributors, and leaders pledge to make participation in our We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status, identity and expression, level of experience, education, socioeconomic status,
nationality, personal appearance, race, religion, or sexual identity nationality, personal appearance, race, religion, or sexual identity
and orientation. and orientation.

View File

@ -11,7 +11,7 @@ If you want to implement something big:
## Python ## Python
Paperless supports python 3.9 - 3.11. We format Python code with [Black](https://github.com/psf/black). Paperless supports python 3.9 - 3.11 at this time. We format Python code with [ruff](https://docs.astral.sh/ruff/formatter/).
## Branches ## Branches

View File

@ -21,7 +21,7 @@ RUN set -eux \
# Comments: # Comments:
# - pipenv dependencies are not left in the final image # - pipenv dependencies are not left in the final image
# - pipenv can't touch the final image somehow # - pipenv can't touch the final image somehow
FROM --platform=$BUILDPLATFORM docker.io/python:3.11-alpine as pipenv-base FROM --platform=$BUILDPLATFORM docker.io/python:3.11-alpine AS pipenv-base
WORKDIR /usr/src/pipenv WORKDIR /usr/src/pipenv
@ -29,7 +29,7 @@ COPY Pipfile* ./
RUN set -eux \ RUN set -eux \
&& echo "Installing pipenv" \ && echo "Installing pipenv" \
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2023.12.1 \ && python3 -m pip install --no-cache-dir --upgrade pipenv==2024.0.1 \
&& echo "Generating requirement.txt" \ && echo "Generating requirement.txt" \
&& pipenv requirements > requirements.txt && pipenv requirements > requirements.txt
@ -37,7 +37,7 @@ RUN set -eux \
# Purpose: The final image # Purpose: The final image
# Comments: # Comments:
# - Don't leave anything extra in here # - Don't leave anything extra in here
FROM docker.io/python:3.11-slim-bookworm as main-app FROM docker.io/python:3.11-slim-bookworm AS main-app
LABEL org.opencontainers.image.authors="paperless-ngx team <hello@paperless-ngx.com>" LABEL org.opencontainers.image.authors="paperless-ngx team <hello@paperless-ngx.com>"
LABEL org.opencontainers.image.documentation="https://docs.paperless-ngx.com/" LABEL org.opencontainers.image.documentation="https://docs.paperless-ngx.com/"
@ -53,7 +53,7 @@ ARG TARGETARCH
# Can be workflow provided, defaults set for manual building # Can be workflow provided, defaults set for manual building
ARG JBIG2ENC_VERSION=0.29 ARG JBIG2ENC_VERSION=0.29
ARG QPDF_VERSION=11.9.0 ARG QPDF_VERSION=11.9.0
ARG GS_VERSION=10.02.1 ARG GS_VERSION=10.03.1
# Set Python environment variables # Set Python environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
@ -83,7 +83,6 @@ ARG RUNTIME_PACKAGES="\
icc-profiles-free \ icc-profiles-free \
imagemagick \ imagemagick \
# PostgreSQL # PostgreSQL
libpq5 \
postgresql-client \ postgresql-client \
# MySQL / MariaDB # MySQL / MariaDB
mariadb-client \ mariadb-client \
@ -129,17 +128,17 @@ RUN set -eux \
&& dpkg --install ./qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \ && dpkg --install ./qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
&& echo "Installing Ghostscript ${GS_VERSION}" \ && echo "Installing Ghostscript ${GS_VERSION}" \
&& curl --fail --silent --show-error --location \ && curl --fail --silent --show-error --location \
--output libgs10_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \ --output libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \ https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
&& curl --fail --silent --show-error --location \ && curl --fail --silent --show-error --location \
--output ghostscript_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \ --output ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \ https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
&& curl --fail --silent --show-error --location \ && curl --fail --silent --show-error --location \
--output libgs10-common_${GS_VERSION}.dfsg-2_all.deb \ --output libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg-1_all.deb \ https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
&& dpkg --install ./libgs10-common_${GS_VERSION}.dfsg-2_all.deb \ && dpkg --install ./libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
&& dpkg --install ./libgs10_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \ && dpkg --install ./libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
&& dpkg --install ./ghostscript_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \ && dpkg --install ./ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
&& echo "Installing jbig2enc" \ && echo "Installing jbig2enc" \
&& curl --fail --silent --show-error --location \ && curl --fail --silent --show-error --location \
--output jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ --output jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
@ -223,7 +222,13 @@ RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \
&& apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \ && apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \
&& python3 -m pip install --no-cache-dir --upgrade wheel \ && python3 -m pip install --no-cache-dir --upgrade wheel \
&& echo "Installing Python requirements" \ && echo "Installing Python requirements" \
&& python3 -m pip install --default-timeout=1000 --requirement requirements.txt \ && curl --fail --silent --show-error --location \
--output psycopg_c-3.1.19-cp311-cp311-linux_x86_64.whl \
https://github.com/paperless-ngx/builder/releases/download/psycopg-3.1.19/psycopg_c-3.1.19-cp311-cp311-linux_x86_64.whl \
&& curl --fail --silent --show-error --location \
--output psycopg_c-3.1.19-cp311-cp311-linux_aarch64.whl \
https://github.com/paperless-ngx/builder/releases/download/psycopg-3.1.19/psycopg_c-3.1.19-cp311-cp311-linux_aarch64.whl \
&& python3 -m pip install --default-timeout=1000 --find-links . --requirement requirements.txt \
&& echo "Patching whitenoise for compression speedup" \ && echo "Patching whitenoise for compression speedup" \
&& curl --fail --silent --show-error --location --output 484.patch https://github.com/evansd/whitenoise/pull/484.patch \ && curl --fail --silent --show-error --location --output 484.patch https://github.com/evansd/whitenoise/pull/484.patch \
&& patch -d /usr/local/lib/python3.11/site-packages --verbose -p2 < 484.patch \ && patch -d /usr/local/lib/python3.11/site-packages --verbose -p2 < 484.patch \
@ -236,6 +241,7 @@ RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \
&& apt-get --yes purge ${BUILD_PACKAGES} \ && apt-get --yes purge ${BUILD_PACKAGES} \
&& apt-get --yes autoremove --purge \ && apt-get --yes autoremove --purge \
&& apt-get clean --yes \ && apt-get clean --yes \
&& rm --recursive --force --verbose *.whl \
&& rm --recursive --force --verbose /var/lib/apt/lists/* \ && rm --recursive --force --verbose /var/lib/apt/lists/* \
&& rm --recursive --force --verbose /tmp/* \ && rm --recursive --force --verbose /tmp/* \
&& rm --recursive --force --verbose /var/tmp/* \ && rm --recursive --force --verbose /var/tmp/* \

13
Pipfile
View File

@ -7,7 +7,7 @@ name = "pypi"
dateparser = "~=1.2" dateparser = "~=1.2"
# WARNING: django does not use semver. # WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes. # Only patch versions are guaranteed to not introduce breaking changes.
django = "~=4.2.11" django = "~=4.2.13"
django-allauth = {extras = ["socialaccount"], version = "*"} django-allauth = {extras = ["socialaccount"], version = "*"}
django-auditlog = "*" django-auditlog = "*"
django-celery-results = "*" django-celery-results = "*"
@ -17,6 +17,7 @@ django-extensions = "*"
django-filter = "~=24.2" django-filter = "~=24.2"
django-guardian = "*" django-guardian = "*"
django-multiselectfield = "*" django-multiselectfield = "*"
django-soft-delete = "*"
djangorestframework = "==3.14.0" djangorestframework = "==3.14.0"
djangorestframework-guardian = "*" djangorestframework-guardian = "*"
drf-writable-nested = "*" drf-writable-nested = "*"
@ -37,7 +38,7 @@ nltk = "*"
ocrmypdf = "~=15.4" ocrmypdf = "~=15.4"
pathvalidate = "*" pathvalidate = "*"
pdf2image = "*" pdf2image = "*"
psycopg2 = "*" psycopg = {version = "*", extras = ["c"]}
python-dateutil = "*" python-dateutil = "*"
python-dotenv = "*" python-dotenv = "*"
python-gnupg = "*" python-gnupg = "*"
@ -46,23 +47,19 @@ python-magic = "*"
pyzbar = "*" pyzbar = "*"
rapidfuzz = "*" rapidfuzz = "*"
redis = {extras = ["hiredis"], version = "*"} redis = {extras = ["hiredis"], version = "*"}
scikit-learn = "~=1.4" scikit-learn = "~=1.5"
setproctitle = "*" setproctitle = "*"
tika-client = "*" tika-client = "*"
tqdm = "*" tqdm = "*"
# See https://github.com/paperless-ngx/paperless-ngx/issues/5494
uvicorn = {extras = ["standard"], version = "==0.25.0"} uvicorn = {extras = ["standard"], version = "==0.25.0"}
watchdog = "~=4.0" watchdog = "~=4.0"
whitenoise = "~=6.6" whitenoise = "~=6.6"
whoosh="~=2.7" whoosh="~=2.7"
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"} zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
# Locked for issues
# See https://github.com/paperless-ngx/paperless-ngx/discussions/6610 & https://bugs.launchpad.net/lxml/+bug/2059910
lxml = "==5.1.1"
[dev-packages] [dev-packages]
# Linting # Linting
black = "*"
pre-commit = "*" pre-commit = "*"
ruff = "*" ruff = "*"
# Testing # Testing

1974
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,6 @@
# Can be used locally or by the CI to start the necessary containers with the # Can be used locally or by the CI to start the necessary containers with the
# correct networking for the tests # correct networking for the tests
version: "3.7"
services: services:
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:7.10 image: docker.io/gotenberg/gotenberg:7.10
@ -20,7 +19,7 @@ services:
- "--log-level=warn" - "--log-level=warn"
- "--log-format=text" - "--log-format=text"
tika: tika:
image: ghcr.io/paperless-ngx/tika:latest image: docker.io/apache/tika:latest
hostname: tika hostname: tika
container_name: tika container_name: tika
network_mode: host network_mode: host

View File

@ -30,7 +30,6 @@
# For more extensive installation and update instructions, refer to the # For more extensive installation and update instructions, refer to the
# documentation. # documentation.
version: "3.4"
services: services:
broker: broker:
image: docker.io/library/redis:7 image: docker.io/library/redis:7
@ -88,7 +87,7 @@ services:
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=file:///tmp/.*"
tika: tika:
image: ghcr.io/paperless-ngx/tika:latest image: docker.io/apache/tika:latest
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View File

@ -26,7 +26,6 @@
# For more extensive installation and update instructions, refer to the # For more extensive installation and update instructions, refer to the
# documentation. # documentation.
version: "3.4"
services: services:
broker: broker:
image: docker.io/library/redis:7 image: docker.io/library/redis:7

View File

@ -28,7 +28,6 @@
# For more extensive installation and update instructions, refer to the # For more extensive installation and update instructions, refer to the
# documentation. # documentation.
version: "3.4"
services: services:
broker: broker:
image: docker.io/library/redis:7 image: docker.io/library/redis:7

View File

@ -30,7 +30,6 @@
# For more extensive installation and update instructions, refer to the # For more extensive installation and update instructions, refer to the
# documentation. # documentation.
version: "3.4"
services: services:
broker: broker:
image: docker.io/library/redis:7 image: docker.io/library/redis:7
@ -83,7 +82,7 @@ services:
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=file:///tmp/.*"
tika: tika:
image: ghcr.io/paperless-ngx/tika:latest image: docker.io/apache/tika:latest
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View File

@ -26,7 +26,6 @@
# For more extensive installation and update instructions, refer to the # For more extensive installation and update instructions, refer to the
# documentation. # documentation.
version: "3.4"
services: services:
broker: broker:
image: docker.io/library/redis:7 image: docker.io/library/redis:7

View File

@ -30,7 +30,6 @@
# For more extensive installation and update instructions, refer to the # For more extensive installation and update instructions, refer to the
# documentation. # documentation.
version: "3.4"
services: services:
broker: broker:
image: docker.io/library/redis:7 image: docker.io/library/redis:7
@ -71,7 +70,7 @@ services:
- "--chromium-allow-list=file:///tmp/.*" - "--chromium-allow-list=file:///tmp/.*"
tika: tika:
image: ghcr.io/paperless-ngx/tika:latest image: docker.io/apache/tika:latest
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View File

@ -23,7 +23,6 @@
# For more extensive installation and update instructions, refer to the # For more extensive installation and update instructions, refer to the
# documentation. # documentation.
version: "3.4"
services: services:
broker: broker:
image: docker.io/library/redis:7 image: docker.io/library/redis:7

View File

@ -4,6 +4,7 @@ Simple script which attempts to ping the Redis broker as set in the environment
a certain number of times, waiting a little bit in between a certain number of times, waiting a little bit in between
""" """
import os import os
import sys import sys
import time import time

View File

@ -185,34 +185,12 @@ For PostgreSQL, refer to [Upgrading a PostgreSQL Cluster](https://www.postgresql
For MariaDB, refer to [Upgrading MariaDB](https://mariadb.com/kb/en/upgrading/) For MariaDB, refer to [Upgrading MariaDB](https://mariadb.com/kb/en/upgrading/)
## Downgrading Paperless {#downgrade-paperless} You may also use the exporter and importer with the `--data-only` flag, after creating a new database with the updated version of PostgreSQL or MariaDB.
Downgrades are possible. However, some updates also contain database !!! warning
migrations (these change the layout of the database and may move data).
In order to move back from a version that applied database migrations,
you'll have to revert the database migration _before_ downgrading, and
then downgrade paperless.
This table lists the compatible versions for each database migration You should not change any settings, especially paths, when doing this or there is a
number. risk of data loss
| Migration number | Version range |
| ---------------- | --------------- |
| 1011 | 1.0.0 |
| 1012 | 1.1.0 - 1.2.1 |
| 1014 | 1.3.0 - 1.3.1 |
| 1016 | 1.3.2 - current |
Execute the following management command to migrate your database:
```shell-session
$ python3 manage.py migrate documents <migration number>
```
!!! note
Some migrations cannot be undone. The command will issue errors if that
happens.
## Management utilities {#management-commands} ## Management utilities {#management-commands}
@ -269,6 +247,8 @@ optional arguments:
-sm, --split-manifest -sm, --split-manifest
-z, --zip -z, --zip
-zn, --zip-name -zn, --zip-name
--data-only
--passphrase
``` ```
`target` is a folder to which the data gets written. This includes `target` is a folder to which the data gets written. This includes
@ -327,6 +307,12 @@ If `-z` or `--zip` is provided, the export will be a zip file
in the target directory, named according to the current local date or the in the target directory, named according to the current local date or the
value set in `-zn` or `--zip-name`. value set in `-zn` or `--zip-name`.
If `--data-only` is provided, only the database will be exported. This option is intended
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
If `--passphrase` is provided, it will be used to encrypt certain fields in the export. This value
must be provided to import. If this value is lost, the export cannot be imported.
!!! warning !!! warning
If exporting with the file name format, there may be errors due to If exporting with the file name format, there may be errors due to
@ -341,15 +327,22 @@ exporter](#exporter) and imports it into paperless.
The importer works just like the exporter. You point it at a directory, The importer works just like the exporter. You point it at a directory,
and the script does the rest of the work: and the script does the rest of the work:
``` ```shell
document_importer source document_importer source
``` ```
| Option | Required | Default | Description |
| -------------- | -------- | ------- | ------------------------------------------------------------------------- |
| source | Yes | N/A | The directory containing an export |
| `--data-only` | No | False | If provided, only import data, do not import document files or thumbnails |
| `--passphrase` | No | N/A | If your export was encrypted with a passphrase, must be provided |
When you use the provided docker compose script, put the export inside When you use the provided docker compose script, put the export inside
the `export` folder in your paperless source directory. Specify the `export` folder in your paperless source directory. Specify
`../export` as the `source`. `../export` as the `source`.
Note that .zip files (as can be generated from the exporter) are not supported. Note that .zip files (as can be generated from the exporter) are not supported. You must unzip them into
the target directory first.
!!! note !!! note
@ -359,6 +352,7 @@ Note that .zip files (as can be generated from the exporter) are not supported.
!!! warning !!! warning
The importer should be run against a completely empty installation (database and directories) of Paperless-ngx. The importer should be run against a completely empty installation (database and directories) of Paperless-ngx.
If using a data only import, only the database must be empty.
### Document retagger {#retagger} ### Document retagger {#retagger}
@ -586,7 +580,7 @@ Enabling encryption is no longer supported.
Basic usage to disable encryption of your document store: Basic usage to disable encryption of your document store:
(Note: If [`PAPERLESS_PASSPHRASE`](configuration.md#PAPERLESS_PASSPHRASE) isn't set already, you need to specify (Note: If `PAPERLESS_PASSPHRASE` isn't set already, you need to specify
it here) it here)
``` ```

View File

@ -11,7 +11,7 @@ The API provides the following main endpoints:
- `/api/correspondents/`: Full CRUD support. - `/api/correspondents/`: Full CRUD support.
- `/api/custom_fields/`: Full CRUD support. - `/api/custom_fields/`: Full CRUD support.
- `/api/documents/`: Full CRUD support, except POSTing new documents. - `/api/documents/`: Full CRUD support, except POSTing new documents.
See [below](#posting-documents-file-uploads). See [below](#file-uploads).
- `/api/document_types/`: Full CRUD support. - `/api/document_types/`: Full CRUD support.
- `/api/groups/`: Full CRUD support. - `/api/groups/`: Full CRUD support.
- `/api/logs/`: Read-Only. - `/api/logs/`: Read-Only.
@ -403,7 +403,7 @@ The following methods are supported:
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and / or `{ "remove_tags": [LIST_OF_TAG_IDS] }` - Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and / or `{ "remove_tags": [LIST_OF_TAG_IDS] }`
- `delete` - `delete`
- No `parameters` required - No `parameters` required
- `redo_ocr` - `reprocess`
- No `parameters` required - No `parameters` required
- `set_permissions` - `set_permissions`
- Requires `parameters`: - Requires `parameters`:
@ -417,13 +417,22 @@ The following methods are supported:
- The ordering of the merged document is determined by the list of IDs. - The ordering of the merged document is determined by the list of IDs.
- Optional `parameters`: - Optional `parameters`:
- `"metadata_document_id": DOC_ID` apply metadata (tags, correspondent, etc.) from this document to the merged document. - `"metadata_document_id": DOC_ID` apply metadata (tags, correspondent, etc.) from this document to the merged document.
- `"delete_originals": true` to delete the original documents. This requires the calling user being the owner of
all documents that are merged.
- `split` - `split`
- Requires `parameters`: - Requires `parameters`:
- `"pages": [..]` The list should be a list of pages and/or a ranges, separated by commas e.g. `"[1,2-3,4,5-7]"` - `"pages": [..]` The list should be a list of pages and/or a ranges, separated by commas e.g. `"[1,2-3,4,5-7]"`
- Optional `parameters`:
- `"delete_originals": true` to delete the original document after consumption. This requires the calling user being the owner of
the document.
- The split operation only accepts a single document. - The split operation only accepts a single document.
- `rotate` - `rotate`
- Requires `parameters`: - Requires `parameters`:
- `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270 - `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270
- `delete_pages`
- Requires `parameters`:
- `"pages": [..]` The list should be a list of integers e.g. `"[2,3,4]"`
- The delete_pages operation only accepts a single document.
### Objects ### Objects

View File

@ -1,5 +1,239 @@
# Changelog # Changelog
## paperless-ngx 2.10.1
### Bug Fixes
- Fix: dont require admin perms to view trash on frontend @shamoon ([#7028](https://github.com/paperless-ngx/paperless-ngx/pull/7028))
## paperless-ngx 2.10.0
### Features
- Feature: documents trash aka soft delete [@shamoon](https://github.com/shamoon) ([#6944](https://github.com/paperless-ngx/paperless-ngx/pull/6944))
- Enhancement: better boolean custom field display [@shamoon](https://github.com/shamoon) ([#7001](https://github.com/paperless-ngx/paperless-ngx/pull/7001))
- Feature: Allow encrypting sensitive fields in export [@stumpylog](https://github.com/stumpylog) ([#6927](https://github.com/paperless-ngx/paperless-ngx/pull/6927))
- Enhancement: allow consumption of odg files [@daniel-boehme](https://github.com/daniel-boehme) ([#6940](https://github.com/paperless-ngx/paperless-ngx/pull/6940))
### Bug Fixes
- Fix: Document history could include extra fields [@stumpylog](https://github.com/stumpylog) ([#6989](https://github.com/paperless-ngx/paperless-ngx/pull/6989))
- Fix: use local pdf worker js [@shamoon](https://github.com/shamoon) ([#6990](https://github.com/paperless-ngx/paperless-ngx/pull/6990))
- Fix: Revert masking the content field from auditlog [@tribut](https://github.com/tribut) ([#6981](https://github.com/paperless-ngx/paperless-ngx/pull/6981))
- Fix: respect model permissions for tasks API endpoint [@shamoon](https://github.com/shamoon) ([#6958](https://github.com/paperless-ngx/paperless-ngx/pull/6958))
- Fix: Make the logging of an email message to be something useful [@stumpylog](https://github.com/stumpylog) ([#6901](https://github.com/paperless-ngx/paperless-ngx/pull/6901))
### Documentation
- Documentation: Corrections and clarifications for Python support [@stumpylog](https://github.com/stumpylog) ([#6995](https://github.com/paperless-ngx/paperless-ngx/pull/6995))
### Maintenance
- Chore(deps): Bump stumpylog/image-cleaner-action from 0.6.0 to 0.7.0 in the actions group [@dependabot](https://github.com/dependabot) ([#6968](https://github.com/paperless-ngx/paperless-ngx/pull/6968))
- Chore: Configures dependabot to ignore djangorestframework [@stumpylog](https://github.com/stumpylog) ([#6967](https://github.com/paperless-ngx/paperless-ngx/pull/6967))
### Dependencies
<details>
<summary>10 changes</summary>
- Chore(deps): Bump pipenv from 2023.12.1 to 2024.0.1 [@stumpylog](https://github.com/stumpylog) ([#7019](https://github.com/paperless-ngx/paperless-ngx/pull/7019))
- Chore(deps): Bump the small-changes group with 2 updates [@dependabot](https://github.com/dependabot) ([#7013](https://github.com/paperless-ngx/paperless-ngx/pull/7013))
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#7012](https://github.com/paperless-ngx/paperless-ngx/pull/7012))
- Chore(deps-dev): Bump ws from 8.15.1 to 8.17.1 in /src-ui [@dependabot](https://github.com/dependabot) ([#7015](https://github.com/paperless-ngx/paperless-ngx/pull/7015))
- Chore(deps): Bump urllib3 from 2.2.1 to 2.2.2 [@dependabot](https://github.com/dependabot) ([#7014](https://github.com/paperless-ngx/paperless-ngx/pull/7014))
- Chore: update packages used by mail parser html template [@shamoon](https://github.com/shamoon) ([#6970](https://github.com/paperless-ngx/paperless-ngx/pull/6970))
- Chore(deps): Bump stumpylog/image-cleaner-action from 0.6.0 to 0.7.0 in the actions group [@dependabot](https://github.com/dependabot) ([#6968](https://github.com/paperless-ngx/paperless-ngx/pull/6968))
- Chore(deps-dev): Bump the development group with 3 updates [@dependabot](https://github.com/dependabot) ([#6953](https://github.com/paperless-ngx/paperless-ngx/pull/6953))
- Chore: Updates to latest Trixie version of Ghostscript 10.03.1 [@stumpylog](https://github.com/stumpylog) ([#6956](https://github.com/paperless-ngx/paperless-ngx/pull/6956))
- Chore(deps): Bump tornado from 6.4 to 6.4.1 [@dependabot](https://github.com/dependabot) ([#6930](https://github.com/paperless-ngx/paperless-ngx/pull/6930))
</details>
### All App Changes
<details>
<summary>17 changes</summary>
- Chore(deps): Bump the small-changes group with 2 updates [@dependabot](https://github.com/dependabot) ([#7013](https://github.com/paperless-ngx/paperless-ngx/pull/7013))
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#7012](https://github.com/paperless-ngx/paperless-ngx/pull/7012))
- Chore(deps-dev): Bump ws from 8.15.1 to 8.17.1 in /src-ui [@dependabot](https://github.com/dependabot) ([#7015](https://github.com/paperless-ngx/paperless-ngx/pull/7015))
- Feature: documents trash aka soft delete [@shamoon](https://github.com/shamoon) ([#6944](https://github.com/paperless-ngx/paperless-ngx/pull/6944))
- Enhancement: better boolean custom field display [@shamoon](https://github.com/shamoon) ([#7001](https://github.com/paperless-ngx/paperless-ngx/pull/7001))
- Fix: default order of documents gets lost in QuerySet pipeline [@madduck](https://github.com/madduck) ([#6982](https://github.com/paperless-ngx/paperless-ngx/pull/6982))
- Fix: Document history could include extra fields [@stumpylog](https://github.com/stumpylog) ([#6989](https://github.com/paperless-ngx/paperless-ngx/pull/6989))
- Fix: use local pdf worker js [@shamoon](https://github.com/shamoon) ([#6990](https://github.com/paperless-ngx/paperless-ngx/pull/6990))
- Fix: Revert masking the content field from auditlog [@tribut](https://github.com/tribut) ([#6981](https://github.com/paperless-ngx/paperless-ngx/pull/6981))
- Chore: update packages used by mail parser html template [@shamoon](https://github.com/shamoon) ([#6970](https://github.com/paperless-ngx/paperless-ngx/pull/6970))
- Chore(deps-dev): Bump the development group with 3 updates [@dependabot](https://github.com/dependabot) ([#6953](https://github.com/paperless-ngx/paperless-ngx/pull/6953))
- Fix: respect model permissions for tasks API endpoint [@shamoon](https://github.com/shamoon) ([#6958](https://github.com/paperless-ngx/paperless-ngx/pull/6958))
- Feature: Allow encrypting sensitive fields in export [@stumpylog](https://github.com/stumpylog) ([#6927](https://github.com/paperless-ngx/paperless-ngx/pull/6927))
- Enhancement: allow consumption of odg files [@daniel-boehme](https://github.com/daniel-boehme) ([#6940](https://github.com/paperless-ngx/paperless-ngx/pull/6940))
- Enhancement: use note model permissions for notes [@shamoon](https://github.com/shamoon) ([#6913](https://github.com/paperless-ngx/paperless-ngx/pull/6913))
- Chore: Resolves test issues with Python 3.12 [@stumpylog](https://github.com/stumpylog) ([#6902](https://github.com/paperless-ngx/paperless-ngx/pull/6902))
- Fix: Make the logging of an email message to be something useful [@stumpylog](https://github.com/stumpylog) ([#6901](https://github.com/paperless-ngx/paperless-ngx/pull/6901))
</details>
## paperless-ngx 2.9.0
### Features
- Feature: Allow a data only export/import cycle [@stumpylog](https://github.com/stumpylog) ([#6871](https://github.com/paperless-ngx/paperless-ngx/pull/6871))
- Change: rename 'redo OCR' to 'reprocess' to clarify behavior [@shamoon](https://github.com/shamoon) ([#6866](https://github.com/paperless-ngx/paperless-ngx/pull/6866))
- Enhancement: Support custom path for the classification file [@lino-b](https://github.com/lino-b) ([#6858](https://github.com/paperless-ngx/paperless-ngx/pull/6858))
- Enhancement: default to title/content search, allow choosing full search link from global search [@shamoon](https://github.com/shamoon) ([#6805](https://github.com/paperless-ngx/paperless-ngx/pull/6805))
- Enhancement: only include correspondent 'last_correspondence' if requested [@shamoon](https://github.com/shamoon) ([#6792](https://github.com/paperless-ngx/paperless-ngx/pull/6792))
- Enhancement: delete pages PDF action [@shamoon](https://github.com/shamoon) ([#6772](https://github.com/paperless-ngx/paperless-ngx/pull/6772))
- Enhancement: support custom logo / title on login page [@shamoon](https://github.com/shamoon) ([#6775](https://github.com/paperless-ngx/paperless-ngx/pull/6775))
### Bug Fixes
- Fix: including ordering param for id\_\_in retrievals [@shamoon](https://github.com/shamoon) ([#6875](https://github.com/paperless-ngx/paperless-ngx/pull/6875))
- Fix: Don't allow the workflow save to override other process updates [@stumpylog](https://github.com/stumpylog) ([#6849](https://github.com/paperless-ngx/paperless-ngx/pull/6849))
- Fix: consistently use created_date for doc display [@shamoon](https://github.com/shamoon) ([#6758](https://github.com/paperless-ngx/paperless-ngx/pull/6758))
### Maintenance
- Chore: Change the code formatter to Ruff [@stumpylog](https://github.com/stumpylog) ([#6756](https://github.com/paperless-ngx/paperless-ngx/pull/6756))
- Chore: Backend updates [@stumpylog](https://github.com/stumpylog) ([#6755](https://github.com/paperless-ngx/paperless-ngx/pull/6755))
- Chore(deps): Bump crowdin/github-action from 1 to 2 in the actions group [@dependabot](https://github.com/dependabot) ([#6881](https://github.com/paperless-ngx/paperless-ngx/pull/6881))
### Dependencies
<details>
<summary>12 changes</summary>
- Chore(deps-dev): Bump jest-preset-angular from 14.0.4 to 14.1.0 in /src-ui in the frontend-jest-dependencies group [@dependabot](https://github.com/dependabot) ([#6879](https://github.com/paperless-ngx/paperless-ngx/pull/6879))
- Chore: Backend dependencies update [@stumpylog](https://github.com/stumpylog) ([#6892](https://github.com/paperless-ngx/paperless-ngx/pull/6892))
- Chore(deps): Bump crowdin/github-action from 1 to 2 in the actions group [@dependabot](https://github.com/dependabot) ([#6881](https://github.com/paperless-ngx/paperless-ngx/pull/6881))
- Chore: Updates Ghostscript to 10.03.1 [@stumpylog](https://github.com/stumpylog) ([#6854](https://github.com/paperless-ngx/paperless-ngx/pull/6854))
- Chore(deps-dev): Bump the development group across 1 directory with 2 updates [@dependabot](https://github.com/dependabot) ([#6851](https://github.com/paperless-ngx/paperless-ngx/pull/6851))
- Chore(deps): Bump the small-changes group with 3 updates [@dependabot](https://github.com/dependabot) ([#6843](https://github.com/paperless-ngx/paperless-ngx/pull/6843))
- Chore(deps): Use psycopg as recommended [@stumpylog](https://github.com/stumpylog) ([#6811](https://github.com/paperless-ngx/paperless-ngx/pull/6811))
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#6793](https://github.com/paperless-ngx/paperless-ngx/pull/6793))
- Chore(deps): Bump requests from 2.31.0 to 2.32.0 [@dependabot](https://github.com/dependabot) ([#6795](https://github.com/paperless-ngx/paperless-ngx/pull/6795))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 19 updates [@dependabot](https://github.com/dependabot) ([#6761](https://github.com/paperless-ngx/paperless-ngx/pull/6761))
- Chore: Backend updates [@stumpylog](https://github.com/stumpylog) ([#6755](https://github.com/paperless-ngx/paperless-ngx/pull/6755))
- Chore: revert pngx pdf viewer to third party package [@shamoon](https://github.com/shamoon) ([#6741](https://github.com/paperless-ngx/paperless-ngx/pull/6741))
</details>
### All App Changes
<details>
<summary>19 changes</summary>
- Chore(deps-dev): Bump jest-preset-angular from 14.0.4 to 14.1.0 in /src-ui in the frontend-jest-dependencies group [@dependabot](https://github.com/dependabot) ([#6879](https://github.com/paperless-ngx/paperless-ngx/pull/6879))
- Fix: including ordering param for id\_\_in retrievals [@shamoon](https://github.com/shamoon) ([#6875](https://github.com/paperless-ngx/paperless-ngx/pull/6875))
- Feature: Allow a data only export/import cycle [@stumpylog](https://github.com/stumpylog) ([#6871](https://github.com/paperless-ngx/paperless-ngx/pull/6871))
- Change: rename 'redo OCR' to 'reprocess' to clarify behavior [@shamoon](https://github.com/shamoon) ([#6866](https://github.com/paperless-ngx/paperless-ngx/pull/6866))
- Enhancement: Support custom path for the classification file [@lino-b](https://github.com/lino-b) ([#6858](https://github.com/paperless-ngx/paperless-ngx/pull/6858))
- Chore(deps-dev): Bump the development group across 1 directory with 2 updates [@dependabot](https://github.com/dependabot) ([#6851](https://github.com/paperless-ngx/paperless-ngx/pull/6851))
- Chore(deps): Bump the small-changes group with 3 updates [@dependabot](https://github.com/dependabot) ([#6843](https://github.com/paperless-ngx/paperless-ngx/pull/6843))
- Fix: Don't allow the workflow save to override other process updates [@stumpylog](https://github.com/stumpylog) ([#6849](https://github.com/paperless-ngx/paperless-ngx/pull/6849))
- Chore(deps): Use psycopg as recommended [@stumpylog](https://github.com/stumpylog) ([#6811](https://github.com/paperless-ngx/paperless-ngx/pull/6811))
- Enhancement: default to title/content search, allow choosing full search link from global search [@shamoon](https://github.com/shamoon) ([#6805](https://github.com/paperless-ngx/paperless-ngx/pull/6805))
- Enhancement: only include correspondent 'last_correspondence' if requested [@shamoon](https://github.com/shamoon) ([#6792](https://github.com/paperless-ngx/paperless-ngx/pull/6792))
- Enhancement: accessibility improvements for tags, doc links, dashboard views [@shamoon](https://github.com/shamoon) ([#6786](https://github.com/paperless-ngx/paperless-ngx/pull/6786))
- Enhancement: delete pages PDF action [@shamoon](https://github.com/shamoon) ([#6772](https://github.com/paperless-ngx/paperless-ngx/pull/6772))
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#6793](https://github.com/paperless-ngx/paperless-ngx/pull/6793))
- Enhancement: support custom logo / title on login page [@shamoon](https://github.com/shamoon) ([#6775](https://github.com/paperless-ngx/paperless-ngx/pull/6775))
- Chore: Change the code formatter to Ruff [@stumpylog](https://github.com/stumpylog) ([#6756](https://github.com/paperless-ngx/paperless-ngx/pull/6756))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 19 updates [@dependabot](https://github.com/dependabot) ([#6761](https://github.com/paperless-ngx/paperless-ngx/pull/6761))
- Fix: consistently use created_date for doc display [@shamoon](https://github.com/shamoon) ([#6758](https://github.com/paperless-ngx/paperless-ngx/pull/6758))
- Chore: revert pngx pdf viewer to third party package [@shamoon](https://github.com/shamoon) ([#6741](https://github.com/paperless-ngx/paperless-ngx/pull/6741))
</details>
## paperless-ngx 2.8.6
### Bug Fixes
- Security: disallow API remote-user auth if disabled [@shamoon](https://github.com/shamoon) ([#6739](https://github.com/paperless-ngx/paperless-ngx/pull/6739))
- Fix: retain sort field from global search filtering, use FILTER_HAS_TAGS_ALL [@shamoon](https://github.com/shamoon) ([#6737](https://github.com/paperless-ngx/paperless-ngx/pull/6737))
### All App Changes
<details>
<summary>2 changes</summary>
- Security: disallow API remote-user auth if disabled [@shamoon](https://github.com/shamoon) ([#6739](https://github.com/paperless-ngx/paperless-ngx/pull/6739))
- Fix: retain sort field from global search filtering, use FILTER_HAS_TAGS_ALL [@shamoon](https://github.com/shamoon) ([#6737](https://github.com/paperless-ngx/paperless-ngx/pull/6737))
</details>
## paperless-ngx 2.8.5
### Bug Fixes
- Fix: restore search highlighting on large cards results [@shamoon](https://github.com/shamoon) ([#6728](https://github.com/paperless-ngx/paperless-ngx/pull/6728))
- Fix: global search filtering links broken in 2.8.4 [@shamoon](https://github.com/shamoon) ([#6726](https://github.com/paperless-ngx/paperless-ngx/pull/6726))
- Fix: some buttons incorrectly aligned in 2.8.4 [@shamoon](https://github.com/shamoon) ([#6715](https://github.com/paperless-ngx/paperless-ngx/pull/6715))
- Fix: don't format ASN as number on dashboard [@shamoon](https://github.com/shamoon) ([#6708](https://github.com/paperless-ngx/paperless-ngx/pull/6708))
### All App Changes
<details>
<summary>4 changes</summary>
- Fix: restore search highlighting on large cards results [@shamoon](https://github.com/shamoon) ([#6728](https://github.com/paperless-ngx/paperless-ngx/pull/6728))
- Fix: global search filtering links broken in 2.8.4 [@shamoon](https://github.com/shamoon) ([#6726](https://github.com/paperless-ngx/paperless-ngx/pull/6726))
- Fix: some buttons incorrectly aligned in 2.8.4 [@shamoon](https://github.com/shamoon) ([#6715](https://github.com/paperless-ngx/paperless-ngx/pull/6715))
- Fix: don't format ASN as number on dashboard [@shamoon](https://github.com/shamoon) ([#6708](https://github.com/paperless-ngx/paperless-ngx/pull/6708))
</details>
## paperless-ngx 2.8.4
### Features
- Enhancement: display current ASN in statistics [@darmiel](https://github.com/darmiel) ([#6692](https://github.com/paperless-ngx/paperless-ngx/pull/6692))
- Enhancement: global search tweaks [@shamoon](https://github.com/shamoon) ([#6674](https://github.com/paperless-ngx/paperless-ngx/pull/6674))
### Bug Fixes
- Security: Correctly disable in pdfjs [@shamoon](https://github.com/shamoon) ([#6702](https://github.com/paperless-ngx/paperless-ngx/pull/6702))
- Fix: history timestamp tooltip illegible in dark mode [@shamoon](https://github.com/shamoon) ([#6696](https://github.com/paperless-ngx/paperless-ngx/pull/6696))
- Fix: only count inbox documents from inbox tags with permissions [@shamoon](https://github.com/shamoon) ([#6670](https://github.com/paperless-ngx/paperless-ngx/pull/6670))
### All App Changes
<details>
<summary>5 changes</summary>
- Enhancement: global search tweaks [@shamoon](https://github.com/shamoon) ([#6674](https://github.com/paperless-ngx/paperless-ngx/pull/6674))
- Security: Correctly disable in pdfjs [@shamoon](https://github.com/shamoon) ([#6702](https://github.com/paperless-ngx/paperless-ngx/pull/6702))
- Fix: history timestamp tooltip illegible in dark mode [@shamoon](https://github.com/shamoon) ([#6696](https://github.com/paperless-ngx/paperless-ngx/pull/6696))
- Enhancement: display current ASN in statistics [@darmiel](https://github.com/darmiel) ([#6692](https://github.com/paperless-ngx/paperless-ngx/pull/6692))
- Fix: only count inbox documents from inbox tags with permissions [@shamoon](https://github.com/shamoon) ([#6670](https://github.com/paperless-ngx/paperless-ngx/pull/6670))
</details>
## paperless-ngx 2.8.3
### Bug Fixes
- Fix: respect superuser for document history [@shamoon](https://github.com/shamoon) ([#6661](https://github.com/paperless-ngx/paperless-ngx/pull/6661))
- Fix: allow 0 in monetary field [@shamoon](https://github.com/shamoon) ([#6658](https://github.com/paperless-ngx/paperless-ngx/pull/6658))
- Fix: custom field removal doesn't always trigger change detection [@shamoon](https://github.com/shamoon) ([#6653](https://github.com/paperless-ngx/paperless-ngx/pull/6653))
- Fix: Downgrade and lock lxml [@stumpylog](https://github.com/stumpylog) ([#6655](https://github.com/paperless-ngx/paperless-ngx/pull/6655))
- Fix: correctly handle global search esc key when open and button foucsed [@shamoon](https://github.com/shamoon) ([#6644](https://github.com/paperless-ngx/paperless-ngx/pull/6644))
- Fix: consistent monetary field display in list and cards [@shamoon](https://github.com/shamoon) ([#6645](https://github.com/paperless-ngx/paperless-ngx/pull/6645))
- Fix: doc links and more illegible in light mode [@shamoon](https://github.com/shamoon) ([#6643](https://github.com/paperless-ngx/paperless-ngx/pull/6643))
- Fix: Allow auditlog to be disabled [@stumpylog](https://github.com/stumpylog) ([#6638](https://github.com/paperless-ngx/paperless-ngx/pull/6638))
### Documentation
- Chore(docs): Update the sample Compose file to latest database [@stumpylog](https://github.com/stumpylog) ([#6639](https://github.com/paperless-ngx/paperless-ngx/pull/6639))
### All App Changes
<details>
<summary>7 changes</summary>
- Fix: respect superuser for document history [@shamoon](https://github.com/shamoon) ([#6661](https://github.com/paperless-ngx/paperless-ngx/pull/6661))
- Fix: allow 0 in monetary field [@shamoon](https://github.com/shamoon) ([#6658](https://github.com/paperless-ngx/paperless-ngx/pull/6658))
- Fix: custom field removal doesn't always trigger change detection [@shamoon](https://github.com/shamoon) ([#6653](https://github.com/paperless-ngx/paperless-ngx/pull/6653))
- Fix: correctly handle global search esc key when open and button foucsed [@shamoon](https://github.com/shamoon) ([#6644](https://github.com/paperless-ngx/paperless-ngx/pull/6644))
- Fix: consistent monetary field display in list and cards [@shamoon](https://github.com/shamoon) ([#6645](https://github.com/paperless-ngx/paperless-ngx/pull/6645))
- Fix: doc links and more illegible in light mode [@shamoon](https://github.com/shamoon) ([#6643](https://github.com/paperless-ngx/paperless-ngx/pull/6643))
- Fix: Allow auditlog to be disabled [@stumpylog](https://github.com/stumpylog) ([#6638](https://github.com/paperless-ngx/paperless-ngx/pull/6638))
</details>
## paperless-ngx 2.8.2 ## paperless-ngx 2.8.2
### Bug Fixes ### Bug Fixes

View File

@ -219,10 +219,10 @@ database, classification model, etc).
Defaults to "../data/", relative to the "src" directory. Defaults to "../data/", relative to the "src" directory.
#### [`PAPERLESS_TRASH_DIR=<path>`](#PAPERLESS_TRASH_DIR) {#PAPERLESS_TRASH_DIR} #### [`PAPERLESS_EMPTY_TRASH_DIR=<path>`](#PAPERLESS_EMPTY_TRASH_DIR) {#PAPERLESS_EMPTY_TRASH_DIR}
: Instead of removing deleted documents, they are moved to this : When documents are deleted (e.g. after emptying the trash) the original files will be moved here
directory. instead of being removed from the filesystem. Only the original version is kept.
This must be writeable by the user running paperless. When running This must be writeable by the user running paperless. When running
inside docker, ensure that this path is within a permanent volume inside docker, ensure that this path is within a permanent volume
@ -230,7 +230,9 @@ directory.
Note that the directory must exist prior to using this setting. Note that the directory must exist prior to using this setting.
Defaults to empty (i.e. really delete documents). Defaults to empty (i.e. really delete files).
This setting was previously named PAPERLESS_TRASH_DIR.
#### [`PAPERLESS_MEDIA_ROOT=<path>`](#PAPERLESS_MEDIA_ROOT) {#PAPERLESS_MEDIA_ROOT} #### [`PAPERLESS_MEDIA_ROOT=<path>`](#PAPERLESS_MEDIA_ROOT) {#PAPERLESS_MEDIA_ROOT}
@ -288,6 +290,12 @@ this folder is no longer needed and can be removed manually.
Defaults to `/usr/share/nltk_data` Defaults to `/usr/share/nltk_data`
#### [`PAPERLESS_MODEL_FILE=<path>`](#PAPERLESS_MODEL_FILE) {#PAPERLESS_MODEL_FILE}
: This is where paperless will store the classification model.
Defaults to `PAPERLESS_DATA_DIR/classification_model.pickle`.
## Logging ## Logging
#### [`PAPERLESS_LOGROTATE_MAX_SIZE=<num>`](#PAPERLESS_LOGROTATE_MAX_SIZE) {#PAPERLESS_LOGROTATE_MAX_SIZE} #### [`PAPERLESS_LOGROTATE_MAX_SIZE=<num>`](#PAPERLESS_LOGROTATE_MAX_SIZE) {#PAPERLESS_LOGROTATE_MAX_SIZE}
@ -616,6 +624,8 @@ parsing documents.
Keep in mind that Tesseract uses much more CPU time with multiple Keep in mind that Tesseract uses much more CPU time with multiple
languages enabled. languages enabled.
If you are including languages that are not installed by default, you will need to also set [`PAPERLESS_OCR_LANGUAGES`](configuration.md#PAPERLESS_OCR_LANGUAGES) for docker deployments or install the tesseract language packages manually for bare metal installations.
Defaults to "eng". Defaults to "eng".
!!! note !!! note
@ -1354,6 +1364,20 @@ processing. This only has an effect if
Defaults to false. Defaults to false.
## Trash
#### [`EMPTY_TRASH_DELAY=<num>`](#EMPTY_TRASH_DELAY) {#EMPTY_TRASH_DELAY}
: Sets how long in days documents remain in the 'trash' before they are permanently deleted.
Defaults to 30 days, minimum of 1 day.
#### [`PAPERLESS_EMPTY_TRASH_TASK_CRON=<cron expression>`](#PAPERLESS_EMPTY_TRASH_TASK_CRON) {#PAPERLESS_EMPTY_TRASH_TASK_CRON}
: Configures the schedule to empty the trash of expired deleted documents.
Defaults to `0 1 * * *`, once per day.
## Binaries ## Binaries
There are a few external software packages that Paperless expects to There are a few external software packages that Paperless expects to

View File

@ -47,7 +47,7 @@ early on.
Once installed, hooks will run when you commit. If the formatting isn't Once installed, hooks will run when you commit. If the formatting isn't
quite right or a linter catches something, the commit will be rejected. quite right or a linter catches something, the commit will be rejected.
You'll need to look at the output and fix the issue. Some hooks, such You'll need to look at the output and fix the issue. Some hooks, such
as the Python formatting tool `black`, will format failing as the Python linting and formatting tool `ruff`, will format failing
files, so all you need to do is `git add` those files again files, so all you need to do is `git add` those files again
and retry your commit. and retry your commit.
@ -81,10 +81,6 @@ first-time setup.
!!! note !!! note
Using a virtual environment is highly recommended. You can spawn one via `pipenv shell`. Using a virtual environment is highly recommended. You can spawn one via `pipenv shell`.
Make sure you're using Python 3.10.x or lower. Otherwise you might
get issues with building dependencies. You can use
[pyenv](https://github.com/pyenv/pyenv) to install a specific
Python version.
5. Install pre-commit hooks: 5. Install pre-commit hooks:

View File

@ -250,9 +250,14 @@ a minimal installation of Debian/Buster, which is the current stable
release at the time of writing. Windows is not and will never be release at the time of writing. Windows is not and will never be
supported. supported.
Paperless requires Python 3. At this time, 3.9 - 3.11 are tested versions.
Newer versions may work, but some dependencies may not fully support newer versions.
Support for older Python versions may be dropped as they reach end of life or as newer versions
are released, dependency support is confirmed, etc.
1. Install dependencies. Paperless requires the following packages. 1. Install dependencies. Paperless requires the following packages.
- `python3` - 3.9 - 3.11 are supported - `python3`
- `python3-pip` - `python3-pip`
- `python3-dev` - `python3-dev`
- `default-libmysqlclient-dev` for MariaDB - `default-libmysqlclient-dev` for MariaDB
@ -300,8 +305,17 @@ supported.
- `libatlas-base-dev` - `libatlas-base-dev`
- `libxslt1-dev` - `libxslt1-dev`
You will also need `build-essential`, `python3-setuptools` and You will also need these for installing some of the python dependencies:
`python3-wheel` for installing some of the python dependencies.
- `build-essential`
- `python3-setuptools`
- `python3-wheel`
Use this list for your preferred package management:
```
build-essential python3-setuptools python3-wheel
```
2. Install `redis` >= 6.0 and configure it to start automatically. 2. Install `redis` >= 6.0 and configure it to start automatically.
@ -401,8 +415,7 @@ supported.
sudo chown paperless:paperless /opt/paperless/consume sudo chown paperless:paperless /opt/paperless/consume
``` ```
8. Install python requirements from the `requirements.txt` file. It is 8. Install python requirements from the `requirements.txt` file.
up to you if you wish to use a virtual environment or not. First you should update your pip, so it gets the actual packages.
```shell-session ```shell-session
sudo -Hu paperless pip3 install -r requirements.txt sudo -Hu paperless pip3 install -r requirements.txt
@ -411,6 +424,12 @@ supported.
This will install all python dependencies in the home directory of This will install all python dependencies in the home directory of
the new paperless user. the new paperless user.
!!! tip
It is up to you if you wish to use a virtual environment or not for the Python
dependencies. This is an alternative to the above and may require adjusting
the example scripts to utilize the virtual environment paths
9. Go to `/opt/paperless/src`, and execute the following commands: 9. Go to `/opt/paperless/src`, and execute the following commands:
```bash ```bash
@ -667,24 +686,37 @@ commands as well.
1. Stop and remove the paperless container 1. Stop and remove the paperless container
2. If using an external database, stop the container 2. If using an external database, stop the container
3. Update Redis configuration 3. Update Redis configuration
a) If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
and continue to step 4. 1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
b) Otherwise, in the `docker-compose.yml` add a new service for and continue to step 4.
Redis, following [the example compose
files](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose) 1. Otherwise, in the `docker-compose.yml` add a new service for
c) Set the environment variable [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) so it points to Redis, following [the example compose
the new Redis container files](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
1. Set the environment variable [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) so it points to
the new Redis container
4. Update user mapping 4. Update user mapping
a) If set, change the environment variable `PUID` to `USERMAP_UID`
b) If set, change the environment variable `PGID` to `USERMAP_GID` 1. If set, change the environment variable `PUID` to `USERMAP_UID`
1. If set, change the environment variable `PGID` to `USERMAP_GID`
5. Update configuration paths 5. Update configuration paths
a) Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`
1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`
6. Update media paths 6. Update media paths
a) Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
`/data/media` 1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
`/data/media`
7. Update timezone 7. Update timezone
a) Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
value as `TZ` 1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
value as `TZ`
8. Modify the `image:` to point to 8. Modify the `image:` to point to
`ghcr.io/paperless-ngx/paperless-ngx:latest` or a specific version `ghcr.io/paperless-ngx/paperless-ngx:latest` or a specific version
if preferred. if preferred.

View File

@ -421,13 +421,12 @@ to optionally attach data to documents which does not fit in the existing set of
Paperless-ngx provides. Paperless-ngx provides.
1. First, create a custom field (under "Manage"), with a given name and data type. This could be something like "Invoice Number" or "Date Paid", with a data type of "Number", "Date", "String", etc. 1. First, create a custom field (under "Manage"), with a given name and data type. This could be something like "Invoice Number" or "Date Paid", with a data type of "Number", "Date", "String", etc.
2. Once created, a field can be used with documents and data stored. To do so, use the "Custom Fields" menu on the document detail page, choose your existing field and click "Add". Once the field is visible in the form you can enter the appropriate 2. Once created, a field can be used with documents and data stored. To do so, use the "Custom Fields" menu on the document detail page, choose your existing field from the dropdown. Once the field is visible in the form you can enter the appropriate data which will be validated according to the custom field "data type".
data which will be validated according to the custom field "data type".
3. Fields can be removed by hovering over the field name revealing a "Remove" button. 3. Fields can be removed by hovering over the field name revealing a "Remove" button.
!!! important !!! important
Added / removed fields, as well as any data is not saved to the document until you Added / removed fields, as well as any data, is not saved to the document until you
actually hit the "Save" button, similar to other changes on the document details page. actually hit the "Save" button, similar to other changes on the document details page.
!!! note !!! note
@ -462,15 +461,16 @@ Paperless-ngx added the ability to create shareable links to files in version 2.
## PDF Actions ## PDF Actions
Paperless-ngx supports 3 basic editing operations for PDFs (these operations cannot be performed on non-PDF files): Paperless-ngx supports four basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files):
- Merging documents: available when selecting multiple documents for 'bulk editing' - Merging documents: available when selecting multiple documents for 'bulk editing'.
- Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page. - Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page.
- Splitting documents: available from an individual document's details page - Splitting documents: available from an individual document's details page.
- Deleting pages: available from an individual document's details page.
!!! important !!! important
Note that rotation alters the Paperless-ngx _original_ file, which would, for example, invalidate a digital signature. Note that rotation and deleting pages alter the Paperless-ngx _original_ file, which would, for example, invalidate a digital signature.
## Document History ## Document History
@ -478,6 +478,15 @@ As of version 2.7, Paperless-ngx automatically records all changes to a document
Changes to documents are visible under the "History" tab. Note that certain changes such as those made by workflows, record the 'actor' Changes to documents are visible under the "History" tab. Note that certain changes such as those made by workflows, record the 'actor'
as "System". as "System".
## Document Trash
When you first delete a document it is moved to the 'trash' until either it is explicitly deleted or it is automatically removed after a set amount of time has passed.
You can set how long documents remain in the trash before being automatically deleted with [`EMPTY_TRASH_DELAY`](configuration.md#EMPTY_TRASH_DELAY), which defaults
to 30 days. Until the file is actually deleted (e.g. the trash is emptied), all files and database content remains intact and can be restored at any point up until that time.
Additionally you may configure a directory where deleted files are moved to when they the trash is emptied with [`PAPERLESS_EMPTY_TRASH_DIR`](configuration.md#PAPERLESS_EMPTY_TRASH_DIR).
Note that the empty trash directory only stores the original file, the archive file and all database information is permanently removed once a document is fully deleted.
## Best practices {#basic-searching} ## Best practices {#basic-searching}
Paperless offers a couple tools that help you organize your document Paperless offers a couple tools that help you organize your document

View File

@ -31,6 +31,11 @@
"**/.venv": true, "**/.venv": true,
"**/.coverage": true, "**/.coverage": true,
"**/coverage.json": true "**/coverage.json": true
} },
"python.defaultInterpreterPath": ".venv/bin/python3",
},
"extensions": {
"recommendations": ["ms-python.python", "charliermarsh.ruff", "editorconfig.editorconfig"],
"unwantedRecommendations": ["ms-python.black-formatter"]
} }
} }

View File

@ -19,7 +19,7 @@
#PAPERLESS_CONSUMPTION_DIR=../consume #PAPERLESS_CONSUMPTION_DIR=../consume
#PAPERLESS_DATA_DIR=../data #PAPERLESS_DATA_DIR=../data
#PAPERLESS_TRASH_DIR= #PAPERLESS_EMPTY_TRASH_DIR=
#PAPERLESS_MEDIA_ROOT=../media #PAPERLESS_MEDIA_ROOT=../media
#PAPERLESS_STATICDIR=../static #PAPERLESS_STATICDIR=../static
#PAPERLESS_FILENAME_FORMAT= #PAPERLESS_FILENAME_FORMAT=

View File

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

View File

@ -76,8 +76,7 @@
], ],
"scripts": [], "scripts": [],
"allowedCommonJsDependencies": [ "allowedCommonJsDependencies": [
"pdfjs-dist", "ng2-pdf-viewer",
"pdfjs-dist/web/pdf_viewer",
"filesize", "filesize",
"file-saver" "file-saver"
], ],

View File

@ -71,7 +71,7 @@ test('should show a mobile preview', async ({ page }) => {
await page.setViewportSize({ width: 400, height: 1000 }) await page.setViewportSize({ width: 400, height: 1000 })
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible() await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
await page.getByRole('tab', { name: 'Preview' }).click() await page.getByRole('tab', { name: 'Preview' }).click()
await page.waitForSelector('pngx-pdf-viewer') await page.waitForSelector('pdf-viewer')
}) })
test('should show a list of notes', async ({ page }) => { test('should show a list of notes', async ({ page }) => {

View File

@ -7,7 +7,6 @@ module.exports = {
'abstract-name-filter-service', 'abstract-name-filter-service',
'abstract-paperless-service', 'abstract-paperless-service',
], ],
coveragePathIgnorePatterns: ['/src/app/components/common/pdf-viewer/*'],
transformIgnorePatterns: [`<rootDir>/node_modules/(?!.*\\.mjs$|lodash-es)`], transformIgnorePatterns: [`<rootDir>/node_modules/(?!.*\\.mjs$|lodash-es)`],
moduleNameMapper: { moduleNameMapper: {
'^src/(.*)': '<rootDir>/src/$1', '^src/(.*)': '<rootDir>/src/$1',

File diff suppressed because it is too large Load Diff

770
src-ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,15 +11,15 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^17.3.6", "@angular/cdk": "^17.3.10",
"@angular/common": "~17.3.7", "@angular/common": "~17.3.9",
"@angular/compiler": "~17.3.7", "@angular/compiler": "~17.3.9",
"@angular/core": "~17.3.7", "@angular/core": "~17.3.9",
"@angular/forms": "~17.3.7", "@angular/forms": "~17.3.9",
"@angular/localize": "~17.3.7", "@angular/localize": "~17.3.9",
"@angular/platform-browser": "~17.3.7", "@angular/platform-browser": "~17.3.9",
"@angular/platform-browser-dynamic": "~17.3.7", "@angular/platform-browser-dynamic": "~17.3.9",
"@angular/router": "~17.3.7", "@angular/router": "~17.3.9",
"@ng-bootstrap/ng-bootstrap": "^16.0.0", "@ng-bootstrap/ng-bootstrap": "^16.0.0",
"@ng-select/ng-select": "^12.0.7", "@ng-select/ng-select": "^12.0.7",
"@ngneat/dirty-check-forms": "^3.0.3", "@ngneat/dirty-check-forms": "^3.0.3",
@ -27,13 +27,13 @@
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"mime-names": "^1.0.0", "mime-names": "^1.0.0",
"ng2-pdf-viewer": "^10.2.2",
"ngx-bootstrap-icons": "^1.9.3", "ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^9.0.0", "ngx-color": "^9.0.0",
"ngx-cookie-service": "^17.1.0", "ngx-cookie-service": "^17.1.0",
"ngx-file-drop": "^16.0.0", "ngx-file-drop": "^16.0.0",
"ngx-filesize": "^3.0.3", "ngx-filesize": "^3.0.3",
"ngx-ui-tour-ng-bootstrap": "^14.0.2", "ngx-ui-tour-ng-bootstrap": "^14.0.3",
"pdfjs-dist": "^3.11.174",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"tslib": "^2.6.2", "tslib": "^2.6.2",
"uuid": "^9.0.1", "uuid": "^9.0.1",
@ -41,13 +41,13 @@
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/jest": "17.0.3", "@angular-builders/jest": "17.0.3",
"@angular-devkit/build-angular": "~17.3.6", "@angular-devkit/build-angular": "~17.3.7",
"@angular-eslint/builder": "17.3.0", "@angular-eslint/builder": "17.4.1",
"@angular-eslint/eslint-plugin": "17.3.0", "@angular-eslint/eslint-plugin": "17.4.1",
"@angular-eslint/eslint-plugin-template": "17.3.0", "@angular-eslint/eslint-plugin-template": "17.4.1",
"@angular-eslint/schematics": "17.3.0", "@angular-eslint/schematics": "17.4.1",
"@angular-eslint/template-parser": "17.3.0", "@angular-eslint/template-parser": "17.4.1",
"@angular/cli": "~17.3.6", "@angular/cli": "~17.3.7",
"@angular/compiler-cli": "~17.3.2", "@angular/compiler-cli": "~17.3.2",
"@playwright/test": "^1.42.1", "@playwright/test": "^1.42.1",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
@ -58,7 +58,7 @@
"eslint": "^8.57.0", "eslint": "^8.57.0",
"jest": "29.7.0", "jest": "29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-preset-angular": "^14.0.0", "jest-preset-angular": "^14.1.0",
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"ts-node": "~10.9.1", "ts-node": "~10.9.1",

View File

@ -26,6 +26,7 @@ import { MailComponent } from './components/manage/mail/mail.component'
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component' import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component' import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
import { ConfigComponent } from './components/admin/config/config.component' import { ConfigComponent } from './components/admin/config/config.component'
import { TrashComponent } from './components/admin/trash/trash.component'
export const routes: Routes = [ export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
@ -144,6 +145,17 @@ export const routes: Routes = [
requireAdmin: true, requireAdmin: true,
}, },
}, },
{
path: 'trash',
component: TrashComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.Delete,
type: PermissionType.Document,
},
},
},
// redirect old paths // redirect old paths
{ {
path: 'settings/mail', path: 'settings/mail',

View File

@ -35,6 +35,8 @@ export class AppComponent implements OnInit, OnDestroy {
private permissionsService: PermissionsService, private permissionsService: PermissionsService,
private hotKeyService: HotKeyService private hotKeyService: HotKeyService
) { ) {
let anyWindow = window as any
anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js'
this.settings.updateAppearanceSettings() this.settings.updateAppearanceSettings()
} }

View File

@ -105,7 +105,7 @@ import { CustomFieldsComponent } from './components/manage/custom-fields/custom-
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component' import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component' import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
import { PdfViewerComponent } from './components/common/pdf-viewer/pdf-viewer.component' import { PdfViewerModule } from 'ng2-pdf-viewer'
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component' import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
import { PreviewPopupComponent } from './components/common/preview-popup/preview-popup.component' import { PreviewPopupComponent } from './components/common/preview-popup/preview-popup.component'
import { SwitchComponent } from './components/common/input/switch/switch.component' import { SwitchComponent } from './components/common/input/switch/switch.component'
@ -124,6 +124,8 @@ import { DragDropSelectComponent } from './components/common/input/drag-drop-sel
import { CustomFieldDisplayComponent } from './components/common/custom-field-display/custom-field-display.component' import { CustomFieldDisplayComponent } from './components/common/custom-field-display/custom-field-display.component'
import { GlobalSearchComponent } from './components/app-frame/global-search/global-search.component' import { GlobalSearchComponent } from './components/app-frame/global-search/global-search.component'
import { HotkeyDialogComponent } from './components/common/hotkey-dialog/hotkey-dialog.component' import { HotkeyDialogComponent } from './components/common/hotkey-dialog/hotkey-dialog.component'
import { DeletePagesConfirmDialogComponent } from './components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
import { TrashComponent } from './components/admin/trash/trash.component'
import { import {
airplane, airplane,
archive, archive,
@ -160,6 +162,7 @@ import {
clipboardCheckFill, clipboardCheckFill,
clipboardFill, clipboardFill,
dash, dash,
dashCircle,
diagram3, diagram3,
dice5, dice5,
doorOpen, doorOpen,
@ -174,6 +177,7 @@ import {
fileEarmarkCheck, fileEarmarkCheck,
fileEarmarkFill, fileEarmarkFill,
fileEarmarkLock, fileEarmarkLock,
fileEarmarkMinus,
files, files,
fileText, fileText,
filter, filter,
@ -259,6 +263,7 @@ const icons = {
clipboardCheckFill, clipboardCheckFill,
clipboardFill, clipboardFill,
dash, dash,
dashCircle,
diagram3, diagram3,
dice5, dice5,
doorOpen, doorOpen,
@ -273,6 +278,7 @@ const icons = {
fileEarmarkCheck, fileEarmarkCheck,
fileEarmarkFill, fileEarmarkFill,
fileEarmarkLock, fileEarmarkLock,
fileEarmarkMinus,
files, files,
fileText, fileText,
filter, filter,
@ -475,7 +481,6 @@ function initializeApp(settings: SettingsService) {
CustomFieldEditDialogComponent, CustomFieldEditDialogComponent,
CustomFieldsDropdownComponent, CustomFieldsDropdownComponent,
ProfileEditDialogComponent, ProfileEditDialogComponent,
PdfViewerComponent,
DocumentLinkComponent, DocumentLinkComponent,
PreviewPopupComponent, PreviewPopupComponent,
SwitchComponent, SwitchComponent,
@ -492,6 +497,8 @@ function initializeApp(settings: SettingsService) {
CustomFieldDisplayComponent, CustomFieldDisplayComponent,
GlobalSearchComponent, GlobalSearchComponent,
HotkeyDialogComponent, HotkeyDialogComponent,
DeletePagesConfirmDialogComponent,
TrashComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,
@ -500,6 +507,7 @@ function initializeApp(settings: SettingsService) {
HttpClientModule, HttpClientModule,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
PdfViewerModule,
NgxFileDropModule, NgxFileDropModule,
NgSelectModule, NgSelectModule,
ColorSliderModule, ColorSliderModule,

View File

@ -11,7 +11,7 @@
<ul ngbNav #nav="ngbNav" class="nav-tabs"> <ul ngbNav #nav="ngbNav" class="nav-tabs">
@for (category of optionCategories; track category) { @for (category of optionCategories; track category) {
<li [ngbNavItem]="category"> <li [ngbNavItem]="category">
<a ngbNavLink i18n>{{category}}</a> <a ngbNavLink>{{category}}</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="p-3"> <div class="p-3">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2"> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">

View File

@ -192,7 +192,7 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="offset-md-3 col"> <div class="offset-md-3 col">
<pngx-input-check i18n-title title="Show confirmation dialogs" formControlName="bulkEditConfirmationDialogs" i18n-hint hint="Deleting documents will always ask for confirmation."></pngx-input-check> <pngx-input-check i18n-title title="Show confirmation dialogs" formControlName="bulkEditConfirmationDialogs"></pngx-input-check>
<pngx-input-check i18n-title title="Apply on close" formControlName="bulkEditApplyOnClose"></pngx-input-check> <pngx-input-check i18n-title title="Apply on close" formControlName="bulkEditApplyOnClose"></pngx-input-check>
</div> </div>
</div> </div>
@ -201,7 +201,23 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="offset-md-3 col"> <div class="offset-md-3 col">
<pngx-input-check i18n-title title="Search database only (do not include advanced search results)" formControlName="searchDbOnly"></pngx-input-check> <pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check>
</div>
</div>
<div class="row mb-3">
<div class="offset-md-3 col">
<div class="row">
<div class="col-md-2 col-form-label pt-0">
<span i18n>Full search links to</span>
</div>
<div class="col">
<select class="form-select" formControlName="searchLink">
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
</select>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -309,7 +309,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(26) expect(setSpy).toHaveBeenCalledTimes(27)
// succeed // succeed
storeSpy.mockReturnValueOnce(of(true)) storeSpy.mockReturnValueOnce(of(true))

View File

@ -27,7 +27,7 @@ import {
} from 'rxjs' } from 'rxjs'
import { Group } from 'src/app/data/group' import { Group } from 'src/app/data/group'
import { SavedView } from 'src/app/data/saved-view' import { SavedView } from 'src/app/data/saved-view'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user' import { User } from 'src/app/data/user'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { import {
@ -101,6 +101,7 @@ export class SettingsComponent
defaultPermsEditGroups: new FormControl(null), defaultPermsEditGroups: new FormControl(null),
documentEditingRemoveInboxTags: new FormControl(null), documentEditingRemoveInboxTags: new FormControl(null),
searchDbOnly: new FormControl(null), searchDbOnly: new FormControl(null),
searchLink: new FormControl(null),
notificationsConsumerNewDocument: new FormControl(null), notificationsConsumerNewDocument: new FormControl(null),
notificationsConsumerSuccess: new FormControl(null), notificationsConsumerSuccess: new FormControl(null),
@ -129,6 +130,8 @@ export class SettingsComponent
public systemStatus: SystemStatus public systemStatus: SystemStatus
public readonly GlobalSearchType = GlobalSearchType
get systemStatusHasErrors(): boolean { get systemStatusHasErrors(): boolean {
return ( return (
this.systemStatus.database.status === SystemStatusItemStatus.ERROR || this.systemStatus.database.status === SystemStatusItemStatus.ERROR ||
@ -306,6 +309,7 @@ export class SettingsComponent
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
), ),
searchDbOnly: this.settings.get(SETTINGS_KEYS.SEARCH_DB_ONLY), searchDbOnly: this.settings.get(SETTINGS_KEYS.SEARCH_DB_ONLY),
searchLink: this.settings.get(SETTINGS_KEYS.SEARCH_FULL_TYPE),
savedViews: {}, savedViews: {},
} }
} }
@ -539,6 +543,10 @@ export class SettingsComponent
SETTINGS_KEYS.SEARCH_DB_ONLY, SETTINGS_KEYS.SEARCH_DB_ONLY,
this.settingsForm.value.searchDbOnly this.settingsForm.value.searchDbOnly
) )
this.settings.set(
SETTINGS_KEYS.SEARCH_FULL_TYPE,
this.settingsForm.value.searchLink
)
this.settings.setLanguage(this.settingsForm.value.displayLanguage) this.settings.setLanguage(this.settingsForm.value.displayLanguage)
this.settings this.settings
.storeSettings() .storeSettings()

View File

@ -0,0 +1,98 @@
<pngx-page-header
title="Trash"
i18n-title
info="Manage trashed documents that are pending deletion."
i18n-info
infoLink="usage/#document-trash">
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedDocuments.size === 0">
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Clear selection</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="restoreAll(selectedDocuments)" [disabled]="selectedDocuments.size === 0">
<i-bs name="arrow-counterclockwise"></i-bs>&nbsp;<ng-container i18n>Restore selected</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash(selectedDocuments)" [disabled]="selectedDocuments.size === 0">
<i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete selected</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash()" [disabled]="documentsInTrash.length === 0">
<i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Empty trash</ng-container>
</button>
</pngx-page-header>
<div class="row mb-3">
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="totalDocuments" [(page)]="page" [maxSize]="5" (pageChange)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
</div>
<div class="card border table-responsive mb-3">
<table class="table table-striped align-middle shadow-sm mb-0">
<thead>
<tr>
<th scope="col">
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="allToggled" [disabled]="documentsInTrash.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<label class="form-check-label" for="all-objects"></label>
</div>
</th>
<th scope="col" class="fw-normal" i18n>Name</th>
<th scope="col" class="fw-normal d-none d-sm-table-cell" i18n>Remaining</th>
<th scope="col" class="fw-normal" i18n>Actions</th>
</tr>
</thead>
<tbody>
@if (isLoading) {
<tr>
<td colspan="5">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</td>
</tr>
}
@for (document of documentsInTrash; track document.id) {
<tr (click)="toggleSelected(document); $event.stopPropagation();">
<td>
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments.has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">
<label class="form-check-label" for="{{document.id}}"></label>
</div>
</td>
<td scope="row">{{ document.title }}</td>
<td scope="row" i18n>{{ getDaysRemaining(document) }} days</td>
<td scope="row">
<div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
<i-bs name="three-dots-vertical"></i-bs>
</button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
<button (click)="restore(document)" ngbDropdownItem i18n>Restore</button>
<button (click)="delete(document)" ngbDropdownItem i18n>Delete</button>
</div>
</div>
</div>
<div class="btn-group d-none d-sm-block">
<button class="btn btn-sm btn-outline-secondary" (click)="restore(document); $event.stopPropagation();">
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>&nbsp;<ng-container i18n>Restore</ng-container>
</button>
<button class="btn btn-sm btn-outline-danger" (click)="delete(document); $event.stopPropagation();">
<i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
@if (!isLoading) {
<div class="d-flex mb-2">
<div>
<ng-container i18n>{totalDocuments, plural, =1 {One document in trash} other {{{totalDocuments || 0}} total documents in trash}}</ng-container>
@if (selectedDocuments.size > 0) {
&nbsp;({{selectedDocuments.size}} selected)
}
</div>
@if (documentsInTrash.length > 20) {
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="totalDocuments" [(page)]="page" [maxSize]="5" (pageChange)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
}
</div>
}

View File

@ -0,0 +1,163 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { TrashComponent } from './trash.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import {
NgbModal,
NgbPaginationModule,
NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { TrashService } from 'src/app/services/trash.service'
import { of } from 'rxjs'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { By } from '@angular/platform-browser'
const documentsInTrash = [
{
id: 1,
name: 'test1',
created: new Date('2023-03-01T10:26:03.093116Z'),
deleted_at: new Date('2023-03-01T10:26:03.093116Z'),
},
{
id: 2,
name: 'test2',
created: new Date('2023-03-01T10:26:03.093116Z'),
deleted_at: new Date('2023-03-01T10:26:03.093116Z'),
},
]
describe('TrashComponent', () => {
let component: TrashComponent
let fixture: ComponentFixture<TrashComponent>
let trashService: TrashService
let modalService: NgbModal
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [
TrashComponent,
PageHeaderComponent,
ConfirmDialogComponent,
],
imports: [
HttpClientTestingModule,
FormsModule,
ReactiveFormsModule,
NgbPopoverModule,
NgbPaginationModule,
NgxBootstrapIconsModule.pick(allIcons),
],
}).compileComponents()
fixture = TestBed.createComponent(TrashComponent)
trashService = TestBed.inject(TrashService)
modalService = TestBed.inject(NgbModal)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should call correct service method on reload', () => {
const trashSpy = jest.spyOn(trashService, 'getTrash')
trashSpy.mockReturnValue(
of({
count: 2,
all: documentsInTrash.map((d) => d.id),
results: documentsInTrash,
})
)
component.reload()
expect(trashSpy).toHaveBeenCalled()
expect(component.documentsInTrash).toEqual(documentsInTrash)
})
it('should support delete document', () => {
const trashSpy = jest.spyOn(trashService, 'emptyTrash')
let modal
modalService.activeInstances.subscribe((instances) => {
modal = instances[0]
})
trashSpy.mockReturnValue(of('OK'))
component.delete(documentsInTrash[0])
expect(modal).toBeDefined()
modal.componentInstance.confirmClicked.next()
expect(trashSpy).toHaveBeenCalled()
})
it('should support empty trash', () => {
const trashSpy = jest.spyOn(trashService, 'emptyTrash')
let modal
modalService.activeInstances.subscribe((instances) => {
modal = instances[instances.length - 1]
})
trashSpy.mockReturnValue(of('OK'))
component.emptyTrash()
expect(modal).toBeDefined()
modal.componentInstance.confirmClicked.next()
expect(trashSpy).toHaveBeenCalled()
modal.close()
component.emptyTrash(new Set([1, 2]))
modal.componentInstance.confirmClicked.next()
expect(trashSpy).toHaveBeenCalledWith([1, 2])
})
it('should support restore document', () => {
const restoreSpy = jest.spyOn(trashService, 'restoreDocuments')
const reloadSpy = jest.spyOn(component, 'reload')
restoreSpy.mockReturnValue(of('OK'))
component.restore(documentsInTrash[0])
expect(restoreSpy).toHaveBeenCalledWith([documentsInTrash[0].id])
expect(reloadSpy).toHaveBeenCalled()
})
it('should support restore all documents', () => {
const restoreSpy = jest.spyOn(trashService, 'restoreDocuments')
const reloadSpy = jest.spyOn(component, 'reload')
restoreSpy.mockReturnValue(of('OK'))
component.restoreAll()
expect(restoreSpy).toHaveBeenCalled()
expect(reloadSpy).toHaveBeenCalled()
component.restoreAll(new Set([1, 2]))
expect(restoreSpy).toHaveBeenCalledWith([1, 2])
})
it('should support toggle all items in view', () => {
component.documentsInTrash = documentsInTrash
expect(component.selectedDocuments.size).toEqual(0)
const toggleAllSpy = jest.spyOn(component, 'toggleAll')
const checkButton = fixture.debugElement.queryAll(
By.css('input.form-check-input')
)[0]
checkButton.nativeElement.dispatchEvent(new Event('click'))
checkButton.nativeElement.checked = true
checkButton.nativeElement.dispatchEvent(new Event('click'))
expect(toggleAllSpy).toHaveBeenCalled()
expect(component.selectedDocuments.size).toEqual(documentsInTrash.length)
})
it('should support toggle item', () => {
component.selectedDocuments = new Set([1])
component.toggleSelected(documentsInTrash[0])
expect(component.selectedDocuments.size).toEqual(0)
component.toggleSelected(documentsInTrash[0])
expect(component.selectedDocuments.size).toEqual(1)
})
it('should support clear selection', () => {
component.selectedDocuments = new Set([1])
component.clearSelection()
expect(component.selectedDocuments.size).toEqual(0)
})
it('should correctly display days remaining', () => {
expect(component.getDaysRemaining(documentsInTrash[0])).toBeLessThan(0)
const tenDaysAgo = new Date()
tenDaysAgo.setDate(tenDaysAgo.getDate() - 10)
expect(
component.getDaysRemaining({ deleted_at: tenDaysAgo })
).toBeGreaterThan(0) // 10 days ago but depends on month
})
})

View File

@ -0,0 +1,137 @@
import { Component, OnDestroy } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Document } from 'src/app/data/document'
import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { Subject, takeUntil } from 'rxjs'
import { SettingsService } from 'src/app/services/settings.service'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
@Component({
selector: 'pngx-trash',
templateUrl: './trash.component.html',
styleUrl: './trash.component.scss',
})
export class TrashComponent implements OnDestroy {
public documentsInTrash: Document[] = []
public selectedDocuments: Set<number> = new Set()
public allToggled: boolean = false
public page: number = 1
public totalDocuments: number
public isLoading: boolean = false
unsubscribeNotifier: Subject<void> = new Subject()
constructor(
private trashService: TrashService,
private toastService: ToastService,
private modalService: NgbModal,
private settingsService: SettingsService
) {
this.reload()
}
ngOnDestroy() {
this.unsubscribeNotifier.next()
this.unsubscribeNotifier.complete()
}
reload() {
this.isLoading = true
this.trashService.getTrash(this.page).subscribe((r) => {
this.documentsInTrash = r.results
this.totalDocuments = r.count
this.isLoading = false
this.selectedDocuments.clear()
})
}
delete(document: Document) {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm delete`
modal.componentInstance.messageBold = $localize`This operation will permanently delete this document.`
modal.componentInstance.message = $localize`This operation cannot be undone.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Delete`
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.trashService.emptyTrash([document.id]).subscribe(() => {
this.toastService.showInfo($localize`Document deleted`)
modal.close()
this.reload()
})
})
}
emptyTrash(documents?: Set<number>) {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm delete`
modal.componentInstance.messageBold = documents
? $localize`This operation will permanently delete the selected documents.`
: $localize`This operation will permanently delete all documents in the trash.`
modal.componentInstance.message = $localize`This operation cannot be undone.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Delete`
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.trashService
.emptyTrash(documents ? Array.from(documents) : null)
.subscribe(() => {
this.toastService.showInfo($localize`Document(s) deleted`)
this.allToggled = false
modal.close()
this.reload()
})
})
}
restore(document: Document) {
this.trashService.restoreDocuments([document.id]).subscribe(() => {
this.toastService.showInfo($localize`Document restored`)
this.reload()
})
}
restoreAll(documents: Set<number> = null) {
this.trashService
.restoreDocuments(documents ? Array.from(documents) : null)
.subscribe(() => {
this.toastService.showInfo($localize`Document(s) restored`)
this.allToggled = false
this.reload()
})
}
toggleAll(event: PointerEvent) {
if ((event.target as HTMLInputElement).checked) {
this.selectedDocuments = new Set(this.documentsInTrash.map((t) => t.id))
} else {
this.clearSelection()
}
}
toggleSelected(object: Document) {
this.selectedDocuments.has(object.id)
? this.selectedDocuments.delete(object.id)
: this.selectedDocuments.add(object.id)
}
clearSelection() {
this.allToggled = false
this.selectedDocuments.clear()
}
getDaysRemaining(document: Document): number {
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
const days = Math.ceil(diff / (1000 * 3600 * 24))
return delay - days
}
}

View File

@ -267,6 +267,13 @@
</a> </a>
</li> </li>
} }
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="trash"></i-bs><span>&nbsp;<ng-container i18n>Trash</ng-container></span>
</a>
</li>
<li class="nav-item mt-2" tourAnchor="tour.outro"> <li class="nav-item mt-2" tourAnchor="tour.outro">
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none" <a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation" target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"

View File

@ -6,17 +6,25 @@
<div class="form-control form-control-sm"> <div class="form-control form-control-sm">
<input class="bg-transparent border-0 w-100 h-100" #searchInput type="text" name="query" <input class="bg-transparent border-0 w-100 h-100" #searchInput type="text" name="query"
placeholder="Search" aria-label="Search" i18n-placeholder placeholder="Search" aria-label="Search" i18n-placeholder
autocomplete="off" spellcheck="false" autocomplete="off"
[(ngModel)]="query" (ngModelChange)="this.queryDebounce.next($event)" (keydown)="searchInputKeyDown($event)"> spellcheck="false"
[(ngModel)]="query"
(ngModelChange)="this.queryDebounce.next($event)"
(keydown)="searchInputKeyDown($event)"
ngbDropdownAnchor>
<div class="position-absolute top-50 end-0 translate-middle"> <div class="position-absolute top-50 end-0 translate-middle">
@if (loading) { @if (loading) {
<div class="spinner-border spinner-border-sm text-muted mt-1"></div> <div class="spinner-border spinner-border-sm text-muted mt-1"></div>
} }
</div> </div>
</div> </div>
@if (query && (searchResults?.documents.length === searchService.searchResultObjectLimit || searchService.searchDbOnly)) { @if (query) {
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="runAdvanedSearch()"> <button class="btn btn-sm btn-outline-secondary" type="button" (click)="runFullSearch()">
<ng-container i18n>Advanced search</ng-container> @if (useAdvancedForFullSearch) {
<ng-container i18n>Advanced search</ng-container>
} @else {
<ng-container i18n>Search</ng-container>
}
<i-bs width="1em" height="1em" name="arrow-right-short"></i-bs> <i-bs width="1em" height="1em" name="arrow-right-short"></i-bs>
</button> </button>
} }
@ -25,7 +33,7 @@
<ng-template #resultItemTemplate let-item="item" let-nameProp="nameProp" let-type="type" let-icon="icon" let-date="date"> <ng-template #resultItemTemplate let-item="item" let-nameProp="nameProp" let-type="type" let-icon="icon" let-date="date">
<div #resultItem ngbDropdownItem class="py-2 d-flex align-items-center focus-ring border-0 cursor-pointer" tabindex="-1" <div #resultItem ngbDropdownItem class="py-2 d-flex align-items-center focus-ring border-0 cursor-pointer" tabindex="-1"
(click)="primaryAction(type, item)" (click)="primaryAction(type, item, $event)"
(mouseenter)="onItemHover($event)"> (mouseenter)="onItemHover($event)">
<i-bs width="1.2em" height="1.2em" name="{{icon}}" class="me-2 text-muted"></i-bs> <i-bs width="1.2em" height="1.2em" name="{{icon}}" class="me-2 text-muted"></i-bs>
<div class="text-truncate"> <div class="text-truncate">
@ -36,7 +44,7 @@
</div> </div>
<div class="btn-group ms-auto"> <div class="btn-group ms-auto">
<button #primaryButton type="button" class="btn btn-sm btn-outline-primary d-flex" <button #primaryButton type="button" class="btn btn-sm btn-outline-primary d-flex"
(click)="primaryAction(type, item); $event.stopImmediatePropagation()" (click)="primaryAction(type, item, $event); $event.stopImmediatePropagation()"
(keydown)="onButtonKeyDown($event)" (keydown)="onButtonKeyDown($event)"
[disabled]="disablePrimaryButton(type, item)" [disabled]="disablePrimaryButton(type, item)"
(mouseenter)="onButtonHover($event)"> (mouseenter)="onButtonHover($event)">
@ -56,7 +64,7 @@
</button> </button>
@if (type !== DataType.SavedView && type !== DataType.Workflow && type !== DataType.CustomField && type !== DataType.Group && type !== DataType.User && type !== DataType.MailAccount && type !== DataType.MailRule) { @if (type !== DataType.SavedView && type !== DataType.Workflow && type !== DataType.CustomField && type !== DataType.Group && type !== DataType.User && type !== DataType.MailAccount && type !== DataType.MailRule) {
<button #secondaryButton type="button" class="btn btn-sm btn-outline-primary d-flex" <button #secondaryButton type="button" class="btn btn-sm btn-outline-primary d-flex"
(click)="secondaryAction(type, item); $event.stopImmediatePropagation()" (click)="secondaryAction(type, item, $event); $event.stopImmediatePropagation()"
(keydown)="onButtonKeyDown($event)" (keydown)="onButtonKeyDown($event)"
[disabled]="disableSecondaryButton(type, item)" [disabled]="disableSecondaryButton(type, item)"
(mouseenter)="onButtonHover($event)"> (mouseenter)="onButtonHover($event)">

View File

@ -31,6 +31,9 @@ form {
.input-group .btn { .input-group .btn {
border-color: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.2);
color: var(--pngx-primary-text-contrast); color: var(--pngx-primary-text-contrast);
padding-top: .15rem;
padding-bottom: .15rem;
min-height: calc(1.3em + 0.5rem + calc(var(--bs-border-width) * 2)) !important;
} }
.form-control { .form-control {

View File

@ -24,7 +24,8 @@ import {
FILTER_HAS_CORRESPONDENT_ANY, FILTER_HAS_CORRESPONDENT_ANY,
FILTER_HAS_DOCUMENT_TYPE_ANY, FILTER_HAS_DOCUMENT_TYPE_ANY,
FILTER_HAS_STORAGE_PATH_ANY, FILTER_HAS_STORAGE_PATH_ANY,
FILTER_HAS_TAGS_ANY, FILTER_HAS_TAGS_ALL,
FILTER_TITLE_CONTENT,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
@ -36,6 +37,9 @@ import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-e
import { ElementRef } from '@angular/core' import { ElementRef } from '@angular/core'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { DataType } from 'src/app/data/datatype' import { DataType } from 'src/app/data/datatype'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { SettingsService } from 'src/app/services/settings.service'
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
const searchResults = { const searchResults = {
total: 11, total: 11,
@ -129,6 +133,7 @@ describe('GlobalSearchComponent', () => {
let documentService: DocumentService let documentService: DocumentService
let documentListViewService: DocumentListViewService let documentListViewService: DocumentListViewService
let toastService: ToastService let toastService: ToastService
let settingsService: SettingsService
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
@ -149,6 +154,7 @@ describe('GlobalSearchComponent', () => {
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
documentListViewService = TestBed.inject(DocumentListViewService) documentListViewService = TestBed.inject(DocumentListViewService)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
settingsService = TestBed.inject(SettingsService)
fixture = TestBed.createComponent(GlobalSearchComponent) fixture = TestBed.createComponent(GlobalSearchComponent)
component = fixture.componentInstance component = fixture.componentInstance
@ -248,10 +254,7 @@ describe('GlobalSearchComponent', () => {
expect(blurSpy).toHaveBeenCalled() expect(blurSpy).toHaveBeenCalled()
component.searchResults = { total: 1 } as any component.searchResults = { total: 1 } as any
component.resultsDropdown.close() component.resultsDropdown.open()
const openSpy = jest.spyOn(component.resultsDropdown, 'open')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(openSpy).toHaveBeenCalled()
component.searchInputKeyDown( component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' }) new KeyboardEvent('keydown', { key: 'ArrowDown' })
@ -260,6 +263,13 @@ describe('GlobalSearchComponent', () => {
const closeSpy = jest.spyOn(component.resultsDropdown, 'close') const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' })) component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }))
expect(closeSpy).toHaveBeenCalled() expect(closeSpy).toHaveBeenCalled()
component.searchResults = searchResults as any
component.resultsDropdown.open()
component.query = 'test'
const advancedSearchSpy = jest.spyOn(component, 'runFullSearch')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(advancedSearchSpy).toHaveBeenCalled()
}) })
it('should search on query debounce', fakeAsync(() => { it('should search on query debounce', fakeAsync(() => {
@ -276,37 +286,81 @@ describe('GlobalSearchComponent', () => {
it('should support primary action', () => { it('should support primary action', () => {
const object = { id: 1 } const object = { id: 1 }
const routerSpy = jest.spyOn(router, 'navigate') const routerSpy = jest.spyOn(router, 'navigate')
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
const modalSpy = jest.spyOn(modalService, 'open') const modalSpy = jest.spyOn(modalService, 'open')
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
component.primaryAction(DataType.Document, object) component.primaryAction(DataType.Document, object)
expect(routerSpy).toHaveBeenCalledWith(['/documents', object.id]) expect(routerSpy).toHaveBeenCalledWith(['/documents', object.id], {})
component.primaryAction(DataType.SavedView, object) component.primaryAction(DataType.SavedView, object)
expect(routerSpy).toHaveBeenCalledWith(['/view', object.id]) expect(routerSpy).toHaveBeenCalledWith(['/view', object.id], {})
component.primaryAction(DataType.Correspondent, object) component.primaryAction(DataType.Correspondent, object)
expect(qfSpy).toHaveBeenCalledWith([ expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
{ rule_type: FILTER_HAS_CORRESPONDENT_ANY, value: object.id.toString() }, queryParams: Object.assign(
]) {
page: 1,
reverse: 1,
sort: 'created',
},
queryParamsFromFilterRules([
{
rule_type: FILTER_HAS_CORRESPONDENT_ANY,
value: object.id.toString(),
},
])
),
})
component.primaryAction(DataType.DocumentType, object) component.primaryAction(DataType.DocumentType, object)
expect(qfSpy).toHaveBeenCalledWith([ expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
{ rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY, value: object.id.toString() }, queryParams: Object.assign(
]) {
page: 1,
reverse: 1,
sort: 'created',
},
queryParamsFromFilterRules([
{
rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY,
value: object.id.toString(),
},
])
),
})
component.primaryAction(DataType.StoragePath, object) component.primaryAction(DataType.StoragePath, object)
expect(qfSpy).toHaveBeenCalledWith([ expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
{ rule_type: FILTER_HAS_STORAGE_PATH_ANY, value: object.id.toString() }, queryParams: Object.assign(
]) {
page: 1,
reverse: 1,
sort: 'created',
},
queryParamsFromFilterRules([
{
rule_type: FILTER_HAS_STORAGE_PATH_ANY,
value: object.id.toString(),
},
])
),
})
component.primaryAction(DataType.Tag, object) component.primaryAction(DataType.Tag, object)
expect(qfSpy).toHaveBeenCalledWith([ expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
{ rule_type: FILTER_HAS_TAGS_ANY, value: object.id.toString() }, queryParams: Object.assign(
]) {
page: 1,
reverse: 1,
sort: 'created',
},
queryParamsFromFilterRules([
{ rule_type: FILTER_HAS_TAGS_ALL, value: object.id.toString() },
])
),
})
component.primaryAction(DataType.User, object) component.primaryAction(DataType.User, object)
expect(modalSpy).toHaveBeenCalledWith(UserEditDialogComponent, { expect(modalSpy).toHaveBeenCalledWith(UserEditDialogComponent, {
@ -450,17 +504,41 @@ describe('GlobalSearchComponent', () => {
expect(focusSpy).toHaveBeenCalled() expect(focusSpy).toHaveBeenCalled()
}) })
it('should prevent event propagation for keyboard events on buttons that are not arrows', () => { it('should support open in new window', () => {
const event = { stopImmediatePropagation: jest.fn(), key: 'Enter' } const openSpy = jest.spyOn(window, 'open')
const stopPropagationSpy = jest.spyOn(event, 'stopImmediatePropagation') const event = new Event('click')
component.onButtonKeyDown(event as any) event['ctrlKey'] = true
expect(stopPropagationSpy).toHaveBeenCalled() component.primaryAction(DataType.Document, { id: 2 }, event as any)
expect(openSpy).toHaveBeenCalledWith('/documents/2', '_blank')
component.searchResults = searchResults as any
component.resultsDropdown.open()
fixture.detectChanges()
const button = component.primaryButtons.get(0).nativeElement
const keyboardEvent = new KeyboardEvent('keydown', {
key: 'Enter',
ctrlKey: true,
})
const dispatchSpy = jest.spyOn(button, 'dispatchEvent')
button.dispatchEvent(keyboardEvent)
expect(dispatchSpy).toHaveBeenCalledTimes(2) // once for keydown, second for click
}) })
it('should support explicit advanced search', () => { it('should support title content search and advanced search', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.query = 'test' component.query = 'test'
component.runAdvanedSearch() component.runFullSearch()
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_TITLE_CONTENT, value: 'test' },
])
settingsService.set(
SETTINGS_KEYS.SEARCH_FULL_TYPE,
GlobalSearchType.ADVANCED
)
component.query = 'test'
component.runFullSearch()
expect(qfSpy).toHaveBeenCalledWith([ expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' }, { rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
]) ])

View File

@ -14,7 +14,8 @@ import {
FILTER_HAS_CORRESPONDENT_ANY, FILTER_HAS_CORRESPONDENT_ANY,
FILTER_HAS_DOCUMENT_TYPE_ANY, FILTER_HAS_DOCUMENT_TYPE_ANY,
FILTER_HAS_STORAGE_PATH_ANY, FILTER_HAS_STORAGE_PATH_ANY,
FILTER_HAS_TAGS_ANY, FILTER_HAS_TAGS_ALL,
FILTER_TITLE_CONTENT,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { DataType } from 'src/app/data/datatype' import { DataType } from 'src/app/data/datatype'
import { ObjectWithId } from 'src/app/data/object-with-id' import { ObjectWithId } from 'src/app/data/object-with-id'
@ -41,6 +42,9 @@ import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog
import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component' import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component' import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
import { HotKeyService } from 'src/app/services/hot-key.service' import { HotKeyService } from 'src/app/services/hot-key.service'
import { paramsFromViewState } from 'src/app/utils/query-params'
import { SettingsService } from 'src/app/services/settings.service'
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
@Component({ @Component({
selector: 'pngx-global-search', selector: 'pngx-global-search',
@ -62,6 +66,13 @@ export class GlobalSearchComponent implements OnInit {
@ViewChildren('primaryButton') primaryButtons: QueryList<ElementRef> @ViewChildren('primaryButton') primaryButtons: QueryList<ElementRef>
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef> @ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
get useAdvancedForFullSearch(): boolean {
return (
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
GlobalSearchType.ADVANCED
)
}
constructor( constructor(
public searchService: SearchService, public searchService: SearchService,
private router: Router, private router: Router,
@ -70,7 +81,8 @@ export class GlobalSearchComponent implements OnInit {
private documentListViewService: DocumentListViewService, private documentListViewService: DocumentListViewService,
private permissionsService: PermissionsService, private permissionsService: PermissionsService,
private toastService: ToastService, private toastService: ToastService,
private hotkeyService: HotKeyService private hotkeyService: HotKeyService,
private settingsService: SettingsService
) { ) {
this.queryDebounce = new Subject<string>() this.queryDebounce = new Subject<string>()
@ -87,7 +99,7 @@ export class GlobalSearchComponent implements OnInit {
}) })
} }
ngOnInit() { public ngOnInit() {
this.hotkeyService this.hotkeyService
.addShortcut({ keys: '/', description: $localize`Global search` }) .addShortcut({ keys: '/', description: $localize`Global search` })
.subscribe(() => { .subscribe(() => {
@ -104,17 +116,22 @@ export class GlobalSearchComponent implements OnInit {
}) })
} }
public primaryAction(type: string, object: ObjectWithId) { public primaryAction(
type: string,
object: ObjectWithId,
event: PointerEvent = null
) {
const newWindow = event?.metaKey || event?.ctrlKey
this.reset(true) this.reset(true)
let filterRuleType: number let filterRuleType: number
let editDialogComponent: any let editDialogComponent: any
let size: string = 'md' let size: string = 'md'
switch (type) { switch (type) {
case DataType.Document: case DataType.Document:
this.router.navigate(['/documents', object.id]) this.navigateOrOpenInNewWindow(['/documents', object.id], newWindow)
return return
case DataType.SavedView: case DataType.SavedView:
this.router.navigate(['/view', object.id]) this.navigateOrOpenInNewWindow(['/view', object.id], newWindow)
return return
case DataType.Correspondent: case DataType.Correspondent:
filterRuleType = FILTER_HAS_CORRESPONDENT_ANY filterRuleType = FILTER_HAS_CORRESPONDENT_ANY
@ -126,7 +143,7 @@ export class GlobalSearchComponent implements OnInit {
filterRuleType = FILTER_HAS_STORAGE_PATH_ANY filterRuleType = FILTER_HAS_STORAGE_PATH_ANY
break break
case DataType.Tag: case DataType.Tag:
filterRuleType = FILTER_HAS_TAGS_ANY filterRuleType = FILTER_HAS_TAGS_ALL
break break
case DataType.User: case DataType.User:
editDialogComponent = UserEditDialogComponent editDialogComponent = UserEditDialogComponent
@ -154,9 +171,17 @@ export class GlobalSearchComponent implements OnInit {
} }
if (filterRuleType) { if (filterRuleType) {
this.documentListViewService.quickFilter([ let params = paramsFromViewState({
{ rule_type: filterRuleType, value: object.id.toString() }, filterRules: [
]) { rule_type: filterRuleType, value: object.id.toString() },
],
currentPage: 1,
sortField: this.documentListViewService.sortField ?? 'created',
sortReverse: this.documentListViewService.sortReverse,
})
this.navigateOrOpenInNewWindow(['/documents'], newWindow, {
queryParams: params,
})
} else if (editDialogComponent) { } else if (editDialogComponent) {
const modalRef: NgbModalRef = this.modalService.open( const modalRef: NgbModalRef = this.modalService.open(
editDialogComponent, editDialogComponent,
@ -213,6 +238,7 @@ export class GlobalSearchComponent implements OnInit {
private reset(close: boolean = false) { private reset(close: boolean = false) {
this.queryDebounce.next(null) this.queryDebounce.next(null)
this.query = null
this.searchResults = null this.searchResults = null
this.currentItemIndex = -1 this.currentItemIndex = -1
if (close) { if (close) {
@ -233,7 +259,7 @@ export class GlobalSearchComponent implements OnInit {
item.nativeElement.focus() item.nativeElement.focus()
} }
onItemHover(event: MouseEvent) { public onItemHover(event: MouseEvent) {
const item: ElementRef = this.resultItems const item: ElementRef = this.resultItems
.toArray() .toArray()
.find((item) => item.nativeElement === event.currentTarget) .find((item) => item.nativeElement === event.currentTarget)
@ -241,7 +267,7 @@ export class GlobalSearchComponent implements OnInit {
this.setCurrentItem() this.setCurrentItem()
} }
onButtonHover(event: MouseEvent) { public onButtonHover(event: MouseEvent) {
;(event.currentTarget as HTMLElement).focus() ;(event.currentTarget as HTMLElement).focus()
} }
@ -262,19 +288,14 @@ export class GlobalSearchComponent implements OnInit {
event.preventDefault() event.preventDefault()
this.currentItemIndex = this.searchResults.total - 1 this.currentItemIndex = this.searchResults.total - 1
this.setCurrentItem() this.setCurrentItem()
} else if ( } else if (event.key === 'Enter') {
event.key === 'Enter' && if (this.searchResults?.total === 1 && this.resultsDropdown.isOpen()) {
this.searchResults?.total === 1 && this.primaryButtons.first.nativeElement.click()
this.resultsDropdown.isOpen() this.searchInput.nativeElement.blur()
) { } else if (this.query?.length) {
this.primaryButtons.first.nativeElement.click() this.runFullSearch()
this.searchInput.nativeElement.blur() this.reset(true)
} else if ( }
event.key === 'Enter' &&
this.searchResults?.total &&
!this.resultsDropdown.isOpen()
) {
this.resultsDropdown.open()
} else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) { } else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
if (this.query?.length) { if (this.query?.length) {
this.reset(true) this.reset(true)
@ -284,7 +305,7 @@ export class GlobalSearchComponent implements OnInit {
} }
} }
dropdownKeyDown(event: KeyboardEvent) { public dropdownKeyDown(event: KeyboardEvent) {
if ( if (
this.searchResults?.total && this.searchResults?.total &&
this.resultsDropdown.isOpen() && this.resultsDropdown.isOpen() &&
@ -327,14 +348,9 @@ export class GlobalSearchComponent implements OnInit {
} }
} }
onButtonKeyDown(event: KeyboardEvent) { public onButtonKeyDown(event: KeyboardEvent) {
// prevents ngBootstrap issue with keydown events if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
if ( event.target.dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
!['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft', 'Escape'].includes(
event.key
)
) {
event.stopImmediatePropagation()
} }
} }
@ -373,10 +389,28 @@ export class GlobalSearchComponent implements OnInit {
) )
} }
runAdvanedSearch() { public runFullSearch() {
const ruleType = this.useAdvancedForFullSearch
? FILTER_FULLTEXT_QUERY
: FILTER_TITLE_CONTENT
this.documentListViewService.quickFilter([ this.documentListViewService.quickFilter([
{ rule_type: FILTER_FULLTEXT_QUERY, value: this.query }, { rule_type: ruleType, value: this.query },
]) ])
this.reset(true) this.reset(true)
} }
private navigateOrOpenInNewWindow(
commands: any,
newWindow: boolean = false,
extras: Object = {}
) {
if (newWindow) {
const url = this.router.serializeUrl(
this.router.createUrlTree(commands, extras)
)
window.open(url, '_blank')
} else {
this.router.navigate(commands, extras)
}
}
} }

View File

@ -86,14 +86,4 @@ describe('ConfirmDialogComponent', () => {
expect(closeModalSpy).toHaveBeenCalled() expect(closeModalSpy).toHaveBeenCalled()
expect(confirmSubjectResult).toBeFalsy() expect(confirmSubjectResult).toBeFalsy()
}) })
it('should support delay confirm', fakeAsync(() => {
component.confirmButtonEnabled = false
component.delayConfirm(1)
expect(component.confirmButtonEnabled).toBeFalsy()
tick(1500)
fixture.detectChanges()
expect(component.confirmButtonEnabled).toBeTruthy()
discardPeriodicTasks()
}))
}) })

View File

@ -54,26 +54,6 @@ export class ConfirmDialogComponent {
confirmSubject: Subject<boolean> confirmSubject: Subject<boolean>
alternativeSubject: Subject<boolean> alternativeSubject: Subject<boolean>
delayConfirm(seconds: number) {
const refreshInterval = 0.15 // s
this.secondsTotal = seconds
this.seconds = seconds
interval(refreshInterval * 1000)
.pipe(
take(this.secondsTotal / refreshInterval + 2) // need 2 more for animation to complete after 0
)
.subscribe((count) => {
this.seconds = Math.max(
0,
this.secondsTotal - refreshInterval * (count + 1)
)
this.confirmButtonEnabled =
this.secondsTotal - refreshInterval * count < 0
})
}
cancel() { cancel() {
this.confirmSubject?.next(false) this.confirmSubject?.next(false)
this.confirmSubject?.complete() this.confirmSubject?.complete()

View File

@ -0,0 +1,54 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<div class="row">
<div class="col">
<div class="btn-toolbar flex-nowrap">
<div class="input-group input-group-sm">
<div class="input-group-text" i18n>Page</div>
<input class="form-control mw-60" type="number" min="1" [(ngModel)]="currentPage" />
<div class="input-group-text" i18n>of {{totalPages}}</div>
</div>
<div class="input-group input-group-sm ms-auto">
<span class="input-group-text" i18n>Pages to remove</span>
<input [ngModel]="pagesString" class="form-control" disabled />
</div>
</div>
<div class="pdf-viewer-container w-100 mt-3">
<pdf-viewer #pdfViewer [src]="pdfSrc" [(page)]="currentPage"
[original-size]="false"
[zoom]="1"
zoom-scale="page-fit"
[render-text]="false"
(pagerendered)="pageRendered($event)"
(after-load-complete)="pdfPreviewLoaded($event)">
</pdf-viewer>
</div>
</div>
</div>
</div>
<div class="modal-footer flex-nowrap">
<div>
@if (message) {
<p [innerHTML]="message | safeHtml"></p>
}
@if (messageBold) {
<p class="mb-0 small"><b [innerHTML]="messageBold | safeHtml"></b></p>
}
</div>
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>
<ng-template #pageCheckOverlay let-page="page" let-pages="pages">
<div class="position-absolute top-0 start-0 w-100 h-100 p-2" (click)="pageCheckChanged(page)">
<input type="checkbox" class="form-check-input" />
</div>
</ng-template>

View File

@ -0,0 +1,28 @@
.pdf-viewer-container {
background-color: gray;
height: 350px;
pdf-viewer {
width: 100%;
height: 100%;
}
}
.mw-60 {
max-width: 60px;
}
div.position-absolute:has(.form-check-input:checked) {
background-color: rgba(var(--bs-dark-rgb), 0.4);
}
.form-check-input {
&:checked {
background-color: var(--bs-danger);
border-color: var(--bs-danger);
}
&:focus {
box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), var(--pngx-focus-alpha));
border-color: var(--bs-danger);
}
}

View File

@ -0,0 +1,55 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { DeletePagesConfirmDialogComponent } from './delete-pages-confirm-dialog.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { PdfViewerComponent } from 'ng2-pdf-viewer'
describe('DeletePagesConfirmDialogComponent', () => {
let component: DeletePagesConfirmDialogComponent
let fixture: ComponentFixture<DeletePagesConfirmDialogComponent>
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [DeletePagesConfirmDialogComponent, PdfViewerComponent],
providers: [NgbActiveModal, SafeHtmlPipe],
imports: [
HttpClientTestingModule,
NgxBootstrapIconsModule.pick(allIcons),
FormsModule,
ReactiveFormsModule,
],
}).compileComponents()
fixture = TestBed.createComponent(DeletePagesConfirmDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should return a string with comma-separated pages', () => {
component.pages = [1, 2, 3, 4]
expect(component.pagesString).toEqual('1, 2, 3, 4')
})
it('should update totalPages when pdf is loaded', () => {
component.pdfPreviewLoaded({ numPages: 5 } as any)
expect(component.totalPages).toEqual(5)
})
it('should update checks when page is rendered', () => {
const event = {
target: document.createElement('div'),
detail: { pageNumber: 1 },
} as any
component.pageRendered(event)
expect(component['checks'].length).toEqual(1)
})
it('should update pages when page check is changed', () => {
component.pageCheckChanged(1)
expect(component.pages).toEqual([1])
component.pageCheckChanged(1)
expect(component.pages).toEqual([])
})
})

View File

@ -0,0 +1,64 @@
import { Component, TemplateRef, ViewChild } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
import { PDFDocumentProxy, PdfViewerComponent } from 'ng2-pdf-viewer'
@Component({
selector: 'pngx-delete-pages-confirm-dialog',
templateUrl: './delete-pages-confirm-dialog.component.html',
styleUrl: './delete-pages-confirm-dialog.component.scss',
})
export class DeletePagesConfirmDialogComponent extends ConfirmDialogComponent {
public documentID: number
public pages: number[] = []
public currentPage: number = 1
public totalPages: number
@ViewChild('pdfViewer') pdfViewer: PdfViewerComponent
@ViewChild('pageCheckOverlay') pageCheckOverlay!: TemplateRef<any>
private checks: HTMLElement[] = []
public get pagesString(): string {
return this.pages.join(', ')
}
public get pdfSrc(): string {
return this.documentService.getPreviewUrl(this.documentID)
}
constructor(
activeModal: NgbActiveModal,
private documentService: DocumentService
) {
super(activeModal)
}
public pdfPreviewLoaded(pdf: PDFDocumentProxy) {
this.totalPages = pdf.numPages
}
pageRendered(event: CustomEvent) {
const pageDiv = event.target as HTMLDivElement
const check = this.pageCheckOverlay.createEmbeddedView({
page: event.detail.pageNumber,
})
this.checks[event.detail.pageNumber - 1] = check.rootNodes[0]
pageDiv?.insertBefore(check.rootNodes[0], pageDiv.firstChild)
this.updateChecks()
}
pageCheckChanged(pageNumber: number) {
if (!this.pages.includes(pageNumber)) this.pages.push(pageNumber)
else if (this.pages.includes(pageNumber))
this.pages.splice(this.pages.indexOf(pageNumber), 1)
this.updateChecks()
}
private updateChecks() {
this.checks.forEach((check, i) => {
const input = check.getElementsByTagName('input')[0]
input.checked = this.pages.includes(i + 1)
})
}
}

View File

@ -27,6 +27,10 @@
} }
</select> </select>
</div> </div>
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalsSwitch" [(ngModel)]="deleteOriginals" [disabled]="!userOwnsAllDocuments">
<label class="form-check-label" for="deleteOriginalsSwitch" i18n>Delete original documents after successful merge</label>
</div>
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p> <p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core'
import { ConfirmDialogComponent } from '../confirm-dialog.component' import { ConfirmDialogComponent } from '../confirm-dialog.component'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop' import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'
import { Subject, takeUntil } from 'rxjs' import { Subject, takeUntil } from 'rxjs'
import { Document } from 'src/app/data/document' import { Document } from 'src/app/data/document'
@ -16,6 +17,7 @@ export class MergeConfirmDialogComponent
implements OnInit implements OnInit
{ {
public documentIDs: number[] = [] public documentIDs: number[] = []
public deleteOriginals: boolean = false
private _documents: Document[] = [] private _documents: Document[] = []
get documents(): Document[] { get documents(): Document[] {
return this._documents return this._documents
@ -27,7 +29,8 @@ export class MergeConfirmDialogComponent
constructor( constructor(
activeModal: NgbActiveModal, activeModal: NgbActiveModal,
private documentService: DocumentService private documentService: DocumentService,
private permissionService: PermissionsService
) { ) {
super(activeModal) super(activeModal)
} }
@ -48,4 +51,10 @@ export class MergeConfirmDialogComponent
getDocument(documentID: number): Document { getDocument(documentID: number): Document {
return this.documents.find((d) => d.id === documentID) return this.documents.find((d) => d.id === documentID)
} }
get userOwnsAllDocuments(): boolean {
return this.documents.every((d) =>
this.permissionService.currentUserOwnsObject(d)
)
}
} }

View File

@ -21,21 +21,19 @@
</button> </button>
</div> </div>
</div> </div>
<div class="row mt-4">
<div class="col">
@if (messageBold) {
<p><b>{{messageBold}}</b></p>
}
@if (message) {
<p class="mb-0" [innerHTML]="message | safeHtml"></p>
}
</div>
</div>
@if (showPDFNote) { @if (showPDFNote) {
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be rotated.</p> <p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be rotated.</p>
} }
</div> </div>
<div class="modal-footer"> <div class="modal-footer flex-nowrap">
<div class="col">
@if (message) {
<p [innerHTML]="message | safeHtml"></p>
}
@if (messageBold) {
<p class="mb-0 small"><b [innerHTML]="messageBold | safeHtml"></b></p>
}
</div>
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled"> <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span> <span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button> </button>

View File

@ -13,12 +13,12 @@
<div class="input-group-text" i18n>of {{totalPages}}</div> <div class="input-group-text" i18n>of {{totalPages}}</div>
</div> </div>
<div class="pdf-viewer-container w-100 mt-3"> <div class="pdf-viewer-container w-100 mt-3">
<pngx-pdf-viewer [src]="pdfSrc" [(page)]="page" <pdf-viewer [src]="pdfSrc" [(page)]="page"
[original-size]="false" [original-size]="false"
[zoom]="1" [zoom]="1"
zoom-scale="page-fit" zoom-scale="page-fit"
(after-load-complete)="pdfPreviewLoaded($event)"> (after-load-complete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer> </pdf-viewer>
</div> </div>
</div> </div>
<div class="col-4"> <div class="col-4">
@ -44,6 +44,10 @@
</ul> </ul>
</div> </div>
</div> </div>
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalSwitch" [(ngModel)]="deleteOriginal" [disabled]="!userOwnsDocument">
<label class="form-check-label" for="deleteOriginalSwitch" i18n>Delete original document after successful split</label>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled"> <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">

View File

@ -2,7 +2,7 @@
background-color: gray; background-color: gray;
height: 350px; height: 350px;
pngx-pdf-viewer { pdf-viewer {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }

View File

@ -6,7 +6,8 @@ import { ReactiveFormsModule, FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { PdfViewerComponent } from '../../pdf-viewer/pdf-viewer.component' import { PdfViewerModule } from 'ng2-pdf-viewer'
import { of } from 'rxjs'
describe('SplitConfirmDialogComponent', () => { describe('SplitConfirmDialogComponent', () => {
let component: SplitConfirmDialogComponent let component: SplitConfirmDialogComponent
@ -15,13 +16,14 @@ describe('SplitConfirmDialogComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
declarations: [SplitConfirmDialogComponent, PdfViewerComponent], declarations: [SplitConfirmDialogComponent],
providers: [NgbActiveModal], providers: [NgbActiveModal],
imports: [ imports: [
HttpClientTestingModule, HttpClientTestingModule,
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
ReactiveFormsModule, ReactiveFormsModule,
FormsModule, FormsModule,
PdfViewerModule,
], ],
}).compileComponents() }).compileComponents()
@ -31,6 +33,14 @@ describe('SplitConfirmDialogComponent', () => {
fixture.detectChanges() fixture.detectChanges()
}) })
it('should load document on init', () => {
const getSpy = jest.spyOn(documentService, 'get')
component.documentID = 1
getSpy.mockReturnValue(of({ id: 1 } as any))
component.ngOnInit()
expect(documentService.get).toHaveBeenCalledWith(1)
})
it('should update pagesString when pages are added', () => { it('should update pagesString when pages are added', () => {
component.totalPages = 5 component.totalPages = 5
component.page = 2 component.page = 2

View File

@ -1,15 +1,20 @@
import { Component } from '@angular/core' import { Component, OnInit } from '@angular/core'
import { ConfirmDialogComponent } from '../confirm-dialog.component' import { ConfirmDialogComponent } from '../confirm-dialog.component'
import { Document } from 'src/app/data/document'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { PDFDocumentProxy } from '../../pdf-viewer/typings' import { PermissionsService } from 'src/app/services/permissions.service'
import { PDFDocumentProxy } from 'ng2-pdf-viewer'
@Component({ @Component({
selector: 'pngx-split-confirm-dialog', selector: 'pngx-split-confirm-dialog',
templateUrl: './split-confirm-dialog.component.html', templateUrl: './split-confirm-dialog.component.html',
styleUrl: './split-confirm-dialog.component.scss', styleUrl: './split-confirm-dialog.component.scss',
}) })
export class SplitConfirmDialogComponent extends ConfirmDialogComponent { export class SplitConfirmDialogComponent
extends ConfirmDialogComponent
implements OnInit
{
public get pagesString(): string { public get pagesString(): string {
let pagesStr = '' let pagesStr = ''
@ -32,8 +37,10 @@ export class SplitConfirmDialogComponent extends ConfirmDialogComponent {
private pages: Set<number> = new Set() private pages: Set<number> = new Set()
public documentID: number public documentID: number
private document: Document
public page: number = 1 public page: number = 1
public totalPages: number public totalPages: number
public deleteOriginal: boolean = false
public get pdfSrc(): string { public get pdfSrc(): string {
return this.documentService.getPreviewUrl(this.documentID) return this.documentService.getPreviewUrl(this.documentID)
@ -41,12 +48,19 @@ export class SplitConfirmDialogComponent extends ConfirmDialogComponent {
constructor( constructor(
activeModal: NgbActiveModal, activeModal: NgbActiveModal,
private documentService: DocumentService private documentService: DocumentService,
private permissionService: PermissionsService
) { ) {
super(activeModal) super(activeModal)
this.confirmButtonEnabled = this.pages.size > 0 this.confirmButtonEnabled = this.pages.size > 0
} }
ngOnInit(): void {
this.documentService.get(this.documentID).subscribe((r) => {
this.document = r
})
}
pdfPreviewLoaded(pdf: PDFDocumentProxy) { pdfPreviewLoaded(pdf: PDFDocumentProxy) {
this.totalPages = pdf.numPages this.totalPages = pdf.numPages
} }
@ -63,4 +77,8 @@ export class SplitConfirmDialogComponent extends ConfirmDialogComponent {
this.pages.delete(page) this.pages.delete(page)
this.confirmButtonEnabled = this.pages.size > 0 this.confirmButtonEnabled = this.pages.size > 0
} }
get userOwnsDocument(): boolean {
return this.permissionService.currentUserOwnsObject(this.document)
}
} }

View File

@ -24,11 +24,17 @@
} }
</div> </div>
} }
@case (CustomFieldDataType.Boolean) {
<div class="d-flex flex-row align-items-center">
<span>{{field.name}}:</span>
<input type="checkbox" id="{{field.name}}" name="{{field.name}}" [checked]="value" value="" class="form-check-input ms-2 mt-0 pe-none">
</div>
}
@default { @default {
<span [ngbTooltip]="nameTooltip">{{value}}</span> <span [ngbTooltip]="nameTooltip">{{value}}</span>
} }
} }
} @else if (showNameIfEmpty) { } @else if (showNameIfEmpty) {
<span class="fst-italic text-muted" i18n>{{field.name}}</span> <span class="fst-italic text-muted">{{field.name}}</span>
} }
} }

View File

@ -18,7 +18,7 @@ const document: Document = {
custom_fields: [ custom_fields: [
{ field: 1, document: 1, created: null, value: 'Text value' }, { field: 1, document: 1, created: null, value: 'Text value' },
{ field: 2, document: 1, created: null, value: 'USD100' }, { field: 2, document: 1, created: null, value: 'USD100' },
{ field: 3, document: 1, created: null, value: '1,2,3' }, { field: 3, document: 1, created: null, value: [1, 2, 3] },
], ],
} }

View File

@ -105,7 +105,9 @@ export class CustomFieldDisplayComponent implements OnInit, OnDestroy {
.getFew(this.value, { fields: 'id,title' }) .getFew(this.value, { fields: 'id,title' })
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((result: Results<Document>) => { .subscribe((result: Results<Document>) => {
this.docLinkDocuments = result.results this.docLinkDocuments = this.value.map((id) =>
result.results.find((d) => d.id === id)
)
}) })
} }

View File

@ -320,10 +320,7 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
} }
} }
itemsSorted( itemsSorted(items: MatchingModel[]): MatchingModel[] {
items: MatchingModel[],
isEditModeActive: boolean = true
): MatchingModel[] {
const isUnassignedElement = (a) => a.id == null const isUnassignedElement = (a) => a.id == null
const getSelectionCount = (a) => const getSelectionCount = (a) =>
this._documentCounts?.find((c) => c.id === a.id)?.document_count || 0 this._documentCounts?.find((c) => c.id === a.id)?.document_count || 0
@ -368,15 +365,7 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
} }
} }
}) })
.filter( .filter((a: SelectionDataItem) => true)
(a: SelectionDataItem) =>
isEditModeActive ||
getSelectionCount(a) ||
isUnassignedElement(a) ||
this.selectionModel.getNonTemporary(a.id) !=
ToggleableItemState.NotSelected ||
this.selectionModel.temporarySelectionStates.get(a.id)
)
} }
get items(): MatchingModel[] { get items(): MatchingModel[] {

View File

@ -27,8 +27,8 @@
(change)="onChange(selectedDocuments)"> (change)="onChange(selectedDocuments)">
<ng-template ng-label-tmp let-document="item"> <ng-template ng-label-tmp let-document="item">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<i-bs (click)="unselect(document)" name="x"></i-bs> <button class="btn p-0 lh-1" (click)="unselect(document)" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
<a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();"> <a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title>
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs>&nbsp;<span>{{document.title}}</span> <i-bs width="0.9em" height="0.9em" name="file-text"></i-bs>&nbsp;<span>{{document.title}}</span>
</a> </a>
</div> </div>

View File

@ -66,6 +66,20 @@ describe('DocumentLinkComponent', () => {
expect(getSpy).toHaveBeenCalled() expect(getSpy).toHaveBeenCalled()
}) })
it('shoud maintain ordering of selected documents', () => {
const getSpy = jest.spyOn(documentService, 'getFew')
getSpy.mockImplementation((ids) => {
const docs = documents.filter((d) => ids.includes(d.id))
return of({
count: docs.length,
all: docs.map((d) => d.id),
results: docs,
})
})
component.writeValue([12, 1])
expect(component.selectedDocuments).toEqual([documents[1], documents[0]])
})
it('should search API on select text input', () => { it('should search API on select text input', () => {
const listSpy = jest.spyOn(documentService, 'listFiltered') const listSpy = jest.spyOn(documentService, 'listFiltered')
listSpy.mockImplementation( listSpy.mockImplementation(

View File

@ -65,7 +65,9 @@ export class DocumentLinkComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((documentResults) => { .subscribe((documentResults) => {
this.loading = false this.loading = false
this.selectedDocuments = documentResults.results this.selectedDocuments = documentIDs.map((id) =>
documentResults.results.find((d) => d.id === id)
)
super.writeValue(documentIDs) super.writeValue(documentIDs)
}) })
} }

View File

@ -9,7 +9,7 @@
<div class="badge bg-primary" cdkDrag>{{item.name}}</div> <div class="badge bg-primary" cdkDrag>{{item.name}}</div>
} }
@if (selectedItems.length === 0) { @if (selectedItems.length === 0) {
<div class="badge bg-light text-secondary fst-italic" i18n>{{emptyText}}</div> <div class="badge bg-light text-secondary fst-italic">{{emptyText}}</div>
} }
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0"> <div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0">
<div class="row"> <div class="row">
<div class="d-flex align-items-center" [class.col-md-3]="horizontal"> <div class="d-flex align-items-center" [class.col-md-3]="horizontal">
<label class="form-label" [class.mb-md-0]="horizontal" for="tags" i18n>{{title}}</label> <label class="form-label" [class.mb-md-0]="horizontal" for="tags">{{title}}</label>
</div> </div>
<div class="position-relative" [class.col-md-9]="horizontal"> <div class="position-relative" [class.col-md-9]="horizontal">
<div class="input-group flex-nowrap"> <div class="input-group flex-nowrap">
@ -17,12 +17,12 @@
(change)="onChange(value)"> (change)="onChange(value)">
<ng-template ng-label-tmp let-item="item"> <ng-template ng-label-tmp let-item="item">
<span class="tag-wrap tag-wrap-delete" (mousedown)="removeTag($event, item.id)"> <button class="tag-wrap btn p-0" (click)="removeTag($event, item.id)" title="Remove tag" i18n-title>
<i-bs name="x"></i-bs> <i-bs name="x" style="margin-inline-end: 1px;"></i-bs>
@if (item.id && tags) { @if (item.id && tags) {
<pngx-tag style="background-color: none;" [tag]="getTag(item.id)"></pngx-tag> <pngx-tag style="background-color: none;" [tag]="getTag(item.id)"></pngx-tag>
} }
</span> </button>
</ng-template> </ng-template>
<ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm"> <ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
<div class="tag-wrap"> <div class="tag-wrap">

View File

@ -7,10 +7,6 @@
font-size: 1rem; font-size: 1rem;
} }
.tag-wrap-delete {
cursor: pointer;
}
.paperless-input-select.disabled { .paperless-input-select.disabled {
.input-group { .input-group {
cursor: not-allowed; cursor: not-allowed;

View File

@ -1,3 +0,0 @@
<div #pdfViewerContainer class="pngx-pdf-viewer-container">
<div class="pdfViewer"></div>
</div>

View File

@ -1,600 +0,0 @@
/**
* This file is taken and modified from https://github.com/VadimDez/ng2-pdf-viewer/blob/10.0.0/src/app/pdf-viewer/pdf-viewer.component.ts
* Created by vadimdez on 21/06/16.
*/
import {
Component,
Input,
Output,
ElementRef,
EventEmitter,
OnChanges,
SimpleChanges,
OnInit,
OnDestroy,
ViewChild,
AfterViewChecked,
NgZone,
} from '@angular/core'
import { from, fromEvent, Subject } from 'rxjs'
import { debounceTime, filter, takeUntil } from 'rxjs/operators'
import * as PDFJS from 'pdfjs-dist'
import * as PDFJSViewer from 'pdfjs-dist/web/pdf_viewer'
import { createEventBus } from './utils/event-bus-utils'
import type {
PDFSource,
PDFPageProxy,
PDFProgressData,
PDFDocumentProxy,
PDFDocumentLoadingTask,
PDFViewerOptions,
ZoomScale,
} from './typings'
import { PDFSinglePageViewer } from 'pdfjs-dist/web/pdf_viewer'
PDFJS['verbosity'] = PDFJS.VerbosityLevel.ERRORS
PDFJS['isEvalSupported'] = false
export enum RenderTextMode {
DISABLED,
ENABLED,
ENHANCED,
}
@Component({
selector: 'pngx-pdf-viewer',
templateUrl: './pdf-viewer.component.html',
styleUrls: ['./pdf-viewer.component.scss'],
})
export class PdfViewerComponent
implements OnChanges, OnInit, OnDestroy, AfterViewChecked
{
static CSS_UNITS = 96.0 / 72.0
static BORDER_WIDTH = 9
@ViewChild('pdfViewerContainer')
pdfViewerContainer!: ElementRef<HTMLDivElement>
public eventBus!: PDFJSViewer.EventBus
public pdfLinkService!: PDFJSViewer.PDFLinkService
public pdfViewer!: PDFJSViewer.PDFViewer | PDFSinglePageViewer
private isVisible = false
private _cMapsUrl =
typeof PDFJS !== 'undefined'
? `https://unpkg.com/pdfjs-dist@${(PDFJS as any).version}/cmaps/`
: null
private _imageResourcesPath =
typeof PDFJS !== 'undefined'
? `https://unpkg.com/pdfjs-dist@${(PDFJS as any).version}/web/images/`
: undefined
private _renderText = true
private _renderTextMode: RenderTextMode = RenderTextMode.ENABLED
private _stickToPage = false
private _originalSize = true
private _pdf: PDFDocumentProxy | undefined
private _page = 1
private _zoom = 1
private _zoomScale: ZoomScale = 'page-width'
private _rotation = 0
private _showAll = true
private _canAutoResize = true
private _fitToPage = false
private _externalLinkTarget = 'blank'
private _showBorders = false
private lastLoaded!: string | Uint8Array | PDFSource | null
private _latestScrolledPage!: number
private resizeTimeout: number | null = null
private pageScrollTimeout: number | null = null
private isInitialized = false
private loadingTask?: PDFDocumentLoadingTask | null
private destroy$ = new Subject<void>()
@Output('after-load-complete') afterLoadComplete =
new EventEmitter<PDFDocumentProxy>()
@Output('page-rendered') pageRendered = new EventEmitter<CustomEvent>()
@Output('pages-initialized') pageInitialized = new EventEmitter<CustomEvent>()
@Output('text-layer-rendered') textLayerRendered =
new EventEmitter<CustomEvent>()
@Output('error') onError = new EventEmitter<any>()
@Output('on-progress') onProgress = new EventEmitter<PDFProgressData>()
@Output() pageChange: EventEmitter<number> = new EventEmitter<number>(true)
@Input() src?: string | Uint8Array | PDFSource
@Input('c-maps-url')
set cMapsUrl(cMapsUrl: string) {
this._cMapsUrl = cMapsUrl
}
@Input('page')
set page(_page: number | string | any) {
_page = parseInt(_page, 10) || 1
const originalPage = _page
if (this._pdf) {
_page = this.getValidPageNumber(_page)
}
this._page = _page
if (originalPage !== _page) {
this.pageChange.emit(_page)
}
}
@Input('render-text')
set renderText(renderText: boolean) {
this._renderText = renderText
}
@Input('render-text-mode')
set renderTextMode(renderTextMode: RenderTextMode) {
this._renderTextMode = renderTextMode
}
@Input('original-size')
set originalSize(originalSize: boolean) {
this._originalSize = originalSize
}
@Input('show-all')
set showAll(value: boolean) {
this._showAll = value
}
@Input('stick-to-page')
set stickToPage(value: boolean) {
this._stickToPage = value
}
@Input('zoom')
set zoom(value: number) {
if (value <= 0) {
return
}
this._zoom = value
}
get zoom() {
return this._zoom
}
@Input('zoom-scale')
set zoomScale(value: ZoomScale) {
this._zoomScale = value
}
get zoomScale() {
return this._zoomScale
}
@Input('rotation')
set rotation(value: number) {
if (!(typeof value === 'number' && value % 90 === 0)) {
console.warn('Invalid pages rotation angle.')
return
}
this._rotation = value
}
@Input('external-link-target')
set externalLinkTarget(value: string) {
this._externalLinkTarget = value
}
@Input('autoresize')
set autoresize(value: boolean) {
this._canAutoResize = Boolean(value)
}
@Input('fit-to-page')
set fitToPage(value: boolean) {
this._fitToPage = Boolean(value)
}
@Input('show-borders')
set showBorders(value: boolean) {
this._showBorders = Boolean(value)
}
static getLinkTarget(type: string) {
switch (type) {
case 'blank':
return (PDFJSViewer as any).LinkTarget.BLANK
case 'none':
return (PDFJSViewer as any).LinkTarget.NONE
case 'self':
return (PDFJSViewer as any).LinkTarget.SELF
case 'parent':
return (PDFJSViewer as any).LinkTarget.PARENT
case 'top':
return (PDFJSViewer as any).LinkTarget.TOP
}
return null
}
constructor(
private element: ElementRef<HTMLElement>,
private ngZone: NgZone
) {
PDFJS.GlobalWorkerOptions['workerSrc'] = 'assets/js/pdf.worker.min.js'
}
ngAfterViewChecked(): void {
if (this.isInitialized) {
return
}
const offset = this.pdfViewerContainer.nativeElement.offsetParent
if (this.isVisible === true && offset == null) {
this.isVisible = false
return
}
if (this.isVisible === false && offset != null) {
this.isVisible = true
setTimeout(() => {
this.initialize()
this.ngOnChanges({ src: this.src } as any)
})
}
}
ngOnInit() {
this.initialize()
this.setupResizeListener()
}
ngOnDestroy() {
this.clear()
this.destroy$.next()
this.loadingTask = null
}
ngOnChanges(changes: SimpleChanges) {
if (!this.isVisible) {
return
}
if ('src' in changes) {
this.loadPDF()
} else if (this._pdf) {
if ('renderText' in changes || 'showAll' in changes) {
this.setupViewer()
this.resetPdfDocument()
}
if ('page' in changes) {
const { page } = changes
if (page.currentValue === this._latestScrolledPage) {
return
}
// New form of page changing: The viewer will now jump to the specified page when it is changed.
// This behavior is introduced by using the PDFSinglePageViewer
this.pdfViewer.scrollPageIntoView({ pageNumber: this._page })
}
this.update()
}
}
public updateSize() {
from(
this._pdf!.getPage(
this.pdfViewer.currentPageNumber
) as unknown as Promise<PDFPageProxy>
)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (page: PDFPageProxy) => {
const rotation = this._rotation + page.rotate
const viewportWidth =
(page as any).getViewport({
scale: this._zoom,
rotation,
}).width * PdfViewerComponent.CSS_UNITS
let scale = this._zoom
let stickToPage = true
// Scale the document when it shouldn't be in original size or doesn't fit into the viewport
if (
!this._originalSize ||
(this._fitToPage &&
viewportWidth > this.pdfViewerContainer.nativeElement.clientWidth)
) {
const viewPort = (page as any).getViewport({ scale: 1, rotation })
scale = this.getScale(viewPort.width, viewPort.height)
stickToPage = !this._stickToPage
}
setTimeout(() => {
this.pdfViewer.currentScale = scale
})
},
})
}
public clear() {
if (this.loadingTask && !this.loadingTask.destroyed) {
this.loadingTask.destroy()
}
if (this._pdf) {
this._latestScrolledPage = 0
this._pdf.destroy()
this._pdf = undefined
}
}
private getPDFLinkServiceConfig() {
const linkTarget = PdfViewerComponent.getLinkTarget(
this._externalLinkTarget
)
if (linkTarget) {
return { externalLinkTarget: linkTarget }
}
return {}
}
private initEventBus() {
this.eventBus = createEventBus(PDFJSViewer, this.destroy$)
fromEvent<CustomEvent>(this.eventBus, 'pagerendered')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
this.pageRendered.emit(event)
})
fromEvent<CustomEvent>(this.eventBus, 'pagesinit')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
this.pageInitialized.emit(event)
})
fromEvent(this.eventBus, 'pagechanging')
.pipe(takeUntil(this.destroy$))
.subscribe(({ pageNumber }: any) => {
if (this.pageScrollTimeout) {
clearTimeout(this.pageScrollTimeout)
}
this.pageScrollTimeout = window.setTimeout(() => {
this._latestScrolledPage = pageNumber
this.pageChange.emit(pageNumber)
}, 100)
})
fromEvent<CustomEvent>(this.eventBus, 'textlayerrendered')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
this.textLayerRendered.emit(event)
})
}
private initPDFServices() {
this.pdfLinkService = new PDFJSViewer.PDFLinkService({
eventBus: this.eventBus,
...this.getPDFLinkServiceConfig(),
})
}
private getPDFOptions(): PDFViewerOptions {
return {
eventBus: this.eventBus,
container: this.element.nativeElement.querySelector('div')!,
removePageBorders: !this._showBorders,
linkService: this.pdfLinkService,
textLayerMode: this._renderText
? this._renderTextMode
: RenderTextMode.DISABLED,
imageResourcesPath: this._imageResourcesPath,
}
}
private setupViewer() {
PDFJS['disableTextLayer'] = !this._renderText
this.initPDFServices()
if (this._showAll) {
this.pdfViewer = new PDFJSViewer.PDFViewer(this.getPDFOptions())
} else {
this.pdfViewer = new PDFJSViewer.PDFSinglePageViewer(this.getPDFOptions())
}
this.pdfLinkService.setViewer(this.pdfViewer)
this.pdfViewer._currentPageNumber = this._page
}
private getValidPageNumber(page: number): number {
if (page < 1) {
return 1
}
if (page > this._pdf!.numPages) {
return this._pdf!.numPages
}
return page
}
private getDocumentParams() {
const srcType = typeof this.src
if (!this._cMapsUrl) {
return this.src
}
const params: any = {
cMapUrl: this._cMapsUrl,
cMapPacked: true,
enableXfa: true,
}
if (srcType === 'string') {
params.url = this.src
} else if (srcType === 'object') {
if ((this.src as any).byteLength !== undefined) {
params.data = this.src
} else {
Object.assign(params, this.src)
}
}
return params
}
private loadPDF() {
if (!this.src) {
return
}
if (this.lastLoaded === this.src) {
this.update()
return
}
this.clear()
if (this.pdfViewer) {
this.pdfViewer._resetView()
this.pdfViewer = null
}
this.setupViewer()
try {
this.loadingTask = PDFJS.getDocument(this.getDocumentParams())
this.loadingTask!.onProgress = (progressData: PDFProgressData) => {
this.onProgress.emit(progressData)
}
const src = this.src
from(this.loadingTask!.promise as Promise<PDFDocumentProxy>)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (pdf) => {
this._pdf = pdf
this.lastLoaded = src
this.afterLoadComplete.emit(pdf)
this.resetPdfDocument()
this.update()
},
error: (error) => {
this.lastLoaded = null
this.onError.emit(error)
},
})
} catch (e) {
this.onError.emit(e)
}
}
private update() {
this.page = this._page
this.render()
}
private render() {
this._page = this.getValidPageNumber(this._page)
if (
this._rotation !== 0 ||
this.pdfViewer.pagesRotation !== this._rotation
) {
setTimeout(() => {
this.pdfViewer.pagesRotation = this._rotation
})
}
if (this._stickToPage) {
setTimeout(() => {
this.pdfViewer.currentPageNumber = this._page
})
}
this.updateSize()
}
private getScale(viewportWidth: number, viewportHeight: number) {
const borderSize = this._showBorders
? 2 * PdfViewerComponent.BORDER_WIDTH
: 0
const pdfContainerWidth =
this.pdfViewerContainer.nativeElement.clientWidth - borderSize
const pdfContainerHeight =
this.pdfViewerContainer.nativeElement.clientHeight - borderSize
if (
pdfContainerHeight === 0 ||
viewportHeight === 0 ||
pdfContainerWidth === 0 ||
viewportWidth === 0
) {
return 1
}
let ratio = 1
switch (this._zoomScale) {
case 'page-fit':
ratio = Math.min(
pdfContainerHeight / viewportHeight,
pdfContainerWidth / viewportWidth
)
break
case 'page-height':
ratio = pdfContainerHeight / viewportHeight
break
case 'page-width':
default:
ratio = pdfContainerWidth / viewportWidth
break
}
return (this._zoom * ratio) / PdfViewerComponent.CSS_UNITS
}
private resetPdfDocument() {
this.pdfLinkService.setDocument(this._pdf, null)
this.pdfViewer.setDocument(this._pdf!)
}
private initialize(): void {
if (!this.isVisible) {
return
}
this.isInitialized = true
this.initEventBus()
this.setupViewer()
}
private setupResizeListener(): void {
this.ngZone.runOutsideAngular(() => {
fromEvent(window, 'resize')
.pipe(
debounceTime(100),
filter(() => this._canAutoResize && !!this._pdf),
takeUntil(this.destroy$)
)
.subscribe(() => {
this.updateSize()
})
})
}
}

View File

@ -1,17 +0,0 @@
export type PDFPageProxy =
import('pdfjs-dist/types/src/display/api').PDFPageProxy
export type PDFSource =
import('pdfjs-dist/types/src/display/api').DocumentInitParameters
export type PDFDocumentProxy =
import('pdfjs-dist/types/src/display/api').PDFDocumentProxy
export type PDFDocumentLoadingTask =
import('pdfjs-dist/types/src/display/api').PDFDocumentLoadingTask
export type PDFViewerOptions =
import('pdfjs-dist/types/web/pdf_viewer').PDFViewerOptions
export interface PDFProgressData {
loaded: number
total: number
}
export type ZoomScale = 'page-height' | 'page-fit' | 'page-width'

View File

@ -1,182 +0,0 @@
/**
* This file is taken and modified from https://github.com/VadimDez/ng2-pdf-viewer/blob/10.0.0/src/app/pdf-viewer/utils/event-bus-utils.ts
* Created by vadimdez on 21/06/16.
*/
import { fromEvent, Subject } from 'rxjs'
import { takeUntil } from 'rxjs/operators'
import type { EventBus } from 'pdfjs-dist/web/pdf_viewer'
// interface EventBus {
// on(eventName: string, listener: Function): void;
// off(eventName: string, listener: Function): void;
// _listeners: any;
// dispatch(eventName: string, data: Object): void;
// _on(eventName: any, listener: any, options?: null): void;
// _off(eventName: any, listener: any, options?: null): void;
// }
export function createEventBus(pdfJsViewer: any, destroy$: Subject<void>) {
const globalEventBus: EventBus = new pdfJsViewer.EventBus()
attachDOMEventsToEventBus(globalEventBus, destroy$)
return globalEventBus
}
function attachDOMEventsToEventBus(
eventBus: EventBus,
destroy$: Subject<void>
): void {
fromEvent(eventBus, 'documentload')
.pipe(takeUntil(destroy$))
.subscribe(() => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('documentload', true, true, {})
window.dispatchEvent(event)
})
fromEvent(eventBus, 'pagerendered')
.pipe(takeUntil(destroy$))
.subscribe(({ pageNumber, cssTransform, source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('pagerendered', true, true, {
pageNumber,
cssTransform,
})
source.div.dispatchEvent(event)
})
fromEvent(eventBus, 'textlayerrendered')
.pipe(takeUntil(destroy$))
.subscribe(({ pageNumber, source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('textlayerrendered', true, true, { pageNumber })
source.textLayerDiv?.dispatchEvent(event)
})
fromEvent(eventBus, 'pagechanging')
.pipe(takeUntil(destroy$))
.subscribe(({ pageNumber, source }: any) => {
const event = document.createEvent('UIEvents') as any
event.initEvent('pagechanging', true, true)
/* tslint:disable:no-string-literal */
event['pageNumber'] = pageNumber
source.container.dispatchEvent(event)
})
fromEvent(eventBus, 'pagesinit')
.pipe(takeUntil(destroy$))
.subscribe(({ source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('pagesinit', true, true, null)
source.container.dispatchEvent(event)
})
fromEvent(eventBus, 'pagesloaded')
.pipe(takeUntil(destroy$))
.subscribe(({ pagesCount, source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('pagesloaded', true, true, { pagesCount })
source.container.dispatchEvent(event)
})
fromEvent(eventBus, 'scalechange')
.pipe(takeUntil(destroy$))
.subscribe(({ scale, presetValue, source }: any) => {
const event = document.createEvent('UIEvents') as any
event.initEvent('scalechange', true, true)
/* tslint:disable:no-string-literal */
event['scale'] = scale
/* tslint:disable:no-string-literal */
event['presetValue'] = presetValue
source.container.dispatchEvent(event)
})
fromEvent(eventBus, 'updateviewarea')
.pipe(takeUntil(destroy$))
.subscribe(({ location, source }: any) => {
const event = document.createEvent('UIEvents') as any
event.initEvent('updateviewarea', true, true)
event['location'] = location
source.container.dispatchEvent(event)
})
fromEvent(eventBus, 'find')
.pipe(takeUntil(destroy$))
.subscribe(
({
source,
type,
query,
phraseSearch,
caseSensitive,
highlightAll,
findPrevious,
}: any) => {
if (source === window) {
return // event comes from FirefoxCom, no need to replicate
}
const event = document.createEvent('CustomEvent')
event.initCustomEvent('find' + type, true, true, {
query,
phraseSearch,
caseSensitive,
highlightAll,
findPrevious,
})
window.dispatchEvent(event)
}
)
fromEvent(eventBus, 'attachmentsloaded')
.pipe(takeUntil(destroy$))
.subscribe(({ attachmentsCount, source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('attachmentsloaded', true, true, {
attachmentsCount,
})
source.container.dispatchEvent(event)
})
fromEvent(eventBus, 'sidebarviewchanged')
.pipe(takeUntil(destroy$))
.subscribe(({ view, source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('sidebarviewchanged', true, true, { view })
source.outerContainer.dispatchEvent(event)
})
fromEvent(eventBus, 'pagemode')
.pipe(takeUntil(destroy$))
.subscribe(({ mode, source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('pagemode', true, true, { mode })
source.pdfViewer.container.dispatchEvent(event)
})
fromEvent(eventBus, 'namedaction')
.pipe(takeUntil(destroy$))
.subscribe(({ action, source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('namedaction', true, true, { action })
source.pdfViewer.container.dispatchEvent(event)
})
fromEvent(eventBus, 'presentationmodechanged')
.pipe(takeUntil(destroy$))
.subscribe(({ active, switchInProgress }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('presentationmodechanged', true, true, {
active,
switchInProgress,
})
window.dispatchEvent(event)
})
fromEvent(eventBus, 'outlineloaded')
.pipe(takeUntil(destroy$))
.subscribe(({ outlineCount, source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('outlineloaded', true, true, { outlineCount })
source.container.dispatchEvent(event)
})
}

View File

@ -19,7 +19,7 @@
@for (action of PermissionAction | keyvalue; track action) { @for (action of PermissionAction | keyvalue; track action) {
<div class="col form-check form-check-inline" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type, action.key)" placement="left" triggers="mouseenter:mouseleave"> <div class="col form-check form-check-inline" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type, action.key)" placement="left" triggers="mouseenter:mouseleave">
<input type="checkbox" class="form-check-input" id="{{type}}_{{action.key}}" formControlName="{{action.key}}"> <input type="checkbox" class="form-check-input" id="{{type}}_{{action.key}}" formControlName="{{action.key}}">
<label class="form-check-label visually-hidden" for="{{type}}_{{action.key}}" i18n>{{action.key}}</label> <label class="form-check-label visually-hidden" for="{{type}}_{{action.key}}">{{action.key}}</label>
</div> </div>
} }
</li> </li>

View File

@ -13,13 +13,13 @@
</div> </div>
} }
@if (!requiresPassword) { @if (!requiresPassword) {
<pngx-pdf-viewer <pdf-viewer
[src]="previewURL" [src]="previewURL"
[original-size]="false" [original-size]="false"
[show-borders]="true" [show-borders]="false"
[show-all]="true" [show-all]="true"
(error)="onError($event)"> (error)="onError($event)">
</pngx-pdf-viewer> </pdf-viewer>
} }
} }
} }

View File

@ -1,7 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { PreviewPopupComponent } from './preview-popup.component' import { PreviewPopupComponent } from './preview-popup.component'
import { PdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe' import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
@ -9,6 +8,7 @@ import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { HttpClientTestingModule } from '@angular/common/http/testing' import { HttpClientTestingModule } from '@angular/common/http/testing'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { PdfViewerModule } from 'ng2-pdf-viewer'
const doc = { const doc = {
id: 10, id: 10,
@ -25,10 +25,11 @@ describe('PreviewPopupComponent', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [PreviewPopupComponent, PdfViewerComponent, SafeUrlPipe], declarations: [PreviewPopupComponent, SafeUrlPipe],
imports: [ imports: [
HttpClientTestingModule, HttpClientTestingModule,
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
PdfViewerModule,
], ],
}) })
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
@ -69,7 +70,7 @@ describe('PreviewPopupComponent', () => {
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false) settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
fixture.detectChanges() fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).toBeNull() expect(fixture.debugElement.query(By.css('object'))).toBeNull()
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull() expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
}) })
it('should show lock icon on password error', () => { it('should show lock icon on password error', () => {

View File

@ -87,7 +87,7 @@ describe('SystemStatusDialogComponent', () => {
jest.spyOn(clipboard, 'copy') jest.spyOn(clipboard, 'copy')
component.copy() component.copy()
expect(clipboard.copy).toHaveBeenCalledWith( expect(clipboard.copy).toHaveBeenCalledWith(
JSON.stringify(component.status) JSON.stringify(component.status, null, 4)
) )
expect(component.copied).toBeTruthy() expect(component.copied).toBeTruthy()
tick(3000) tick(3000)

View File

@ -28,7 +28,7 @@ export class SystemStatusDialogComponent {
} }
public copy() { public copy() {
this.clipboard.copy(JSON.stringify(this.status)) this.clipboard.copy(JSON.stringify(this.status, null, 4))
this.copied = true this.copied = true
setTimeout(() => { setTimeout(() => {
this.copied = false this.copied = false

View File

@ -34,32 +34,32 @@
<td class="py-2 py-md-3 position-relative" [ngClass]="{ 'd-none d-md-table-cell': i > 1 }"> <td class="py-2 py-md-3 position-relative" [ngClass]="{ 'd-none d-md-table-cell': i > 1 }">
@switch (field) { @switch (field) {
@case (DisplayField.ADDED) { @case (DisplayField.ADDED) {
<a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.added | customDate}}</a> <a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3" title="Open document" i18n-title>{{doc.added | customDate}}</a>
} }
@case (DisplayField.CREATED) { @case (DisplayField.CREATED) {
<a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.created_date | customDate}}</a> <a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3" title="Open document" i18n-title>{{doc.created_date | customDate}}</a>
} }
@case (DisplayField.TITLE) { @case (DisplayField.TITLE) {
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a> <a routerLink="/documents/{{doc.id}}" title="Open document" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
} }
@case (DisplayField.CORRESPONDENT) { @case (DisplayField.CORRESPONDENT) {
@if (doc.correspondent) { @if (doc.correspondent) {
<a class="btn-link text-dark text-decoration-none" type="button" (click)="clickCorrespondent(doc.correspondent, $event)">{{(doc.correspondent$ | async)?.name}}</a> <a class="btn-link text-dark text-decoration-none" type="button" (click)="clickCorrespondent(doc.correspondent, $event)" title="Filter by correspondent" i18n-title>{{(doc.correspondent$ | async)?.name}}</a>
} }
} }
@case (DisplayField.TAGS) { @case (DisplayField.TAGS) {
@for (t of doc.tags$ | async; track t) { @for (t of doc.tags$ | async; track t) {
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t.id, $event)"></pngx-tag> <pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t.id, $event)" [clickable]="true" linkTitle="Filter by tag" i18n-title></pngx-tag>
} }
} }
@case (DisplayField.DOCUMENT_TYPE) { @case (DisplayField.DOCUMENT_TYPE) {
@if (doc.document_type) { @if (doc.document_type) {
<a class="btn-link text-dark text-decoration-none" type="button" (click)="clickDocType(doc.document_type, $event)">{{(doc.document_type$ | async)?.name}}</a> <a class="btn-link text-dark text-decoration-none" type="button" (click)="clickDocType(doc.document_type, $event)" title="Filter by document type" i18n-title>{{(doc.document_type$ | async)?.name}}</a>
} }
} }
@case (DisplayField.STORAGE_PATH) { @case (DisplayField.STORAGE_PATH) {
@if (doc.storage_path) { @if (doc.storage_path) {
<a class="btn-link text-dark text-decoration-none" type="button" (click)="clickStoragePath(doc.storage_path, $event)">{{(doc.storage_path$ | async)?.name}}</a> <a class="btn-link text-dark text-decoration-none" type="button" (click)="clickStoragePath(doc.storage_path, $event)" title="Filter by storage path" i18n-title>{{(doc.storage_path$ | async)?.name}}</a>
} }
} }
} }

View File

@ -18,7 +18,7 @@
@if (statistics?.current_asn) { @if (statistics?.current_asn) {
<div class="list-group-item d-flex justify-content-between align-items-center" routerLink="/documents/"> <div class="list-group-item d-flex justify-content-between align-items-center" routerLink="/documents/">
<ng-container i18n>Current ASN</ng-container>: <ng-container i18n>Current ASN</ng-container>:
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.current_asn | number}}</span> <span class="badge bg-secondary text-light rounded-pill">{{statistics?.current_asn}}</span>
</div> </div>
} }
@if (statistics?.document_file_type_counts?.length > 1) { @if (statistics?.document_file_type_counts?.length > 1) {

View File

@ -1,5 +1,5 @@
<pngx-page-header [(title)]="title"> <pngx-page-header [(title)]="title">
@if (contentRenderType === ContentRenderType.PDF && !useNativePdfViewer) { @if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
@if (previewNumPages) { @if (previewNumPages) {
<div class="input-group input-group-sm d-none d-md-flex"> <div class="input-group input-group-sm d-none d-md-flex">
<div class="input-group-text" i18n>Page</div> <div class="input-group-text" i18n>Page</div>
@ -45,21 +45,25 @@
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div> <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow"> <div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
<button ngbDropdownItem (click)="redoOcr()" [disabled]="!userCanEdit"> <button ngbDropdownItem (click)="reprocess()" [disabled]="!userCanEdit">
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>&nbsp;<span i18n>Redo OCR</span> <i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>&nbsp;<span i18n>Reprocess</span>
</button> </button>
<button ngbDropdownItem (click)="moreLike()"> <button ngbDropdownItem (click)="moreLike()">
<i-bs width="1em" height="1em" name="diagram-3"></i-bs>&nbsp;<span i18n>More like this</span> <i-bs width="1em" height="1em" name="diagram-3"></i-bs>&nbsp;<span i18n>More like this</span>
</button> </button>
<button ngbDropdownItem (click)="splitDocument()" [disabled]="contentRenderType !== ContentRenderType.PDF || previewNumPages === 1"> <button ngbDropdownItem (click)="splitDocument()" [disabled]="originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
<i-bs width="1em" height="1em" name="scissors"></i-bs>&nbsp;<span i18n>Split</span> <i-bs width="1em" height="1em" name="scissors"></i-bs>&nbsp;<span i18n>Split</span>
</button> </button>
<button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || metadata?.original_mime_type !== 'application/pdf'"> <button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || originalContentRenderType !== ContentRenderType.PDF">
<i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container> <i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
</button> </button>
<button ngbDropdownItem (click)="deletePages()" [disabled]="!userIsOwner || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
<i-bs name="file-earmark-minus"></i-bs>&nbsp;<ng-container i18n>Delete page(s)</ng-container>
</button>
</div> </div>
</div> </div>
@ -105,13 +109,13 @@
<pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" [horizontal]="true" formControlName='archive_serial_number'></pngx-input-number> <pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" [horizontal]="true" formControlName='archive_serial_number'></pngx-input-number>
<pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" <pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
[error]="error?.created_date"></pngx-input-date> [error]="error?.created_date"></pngx-input-date>
<pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" <pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Correspondent)"
(createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select> (createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
<pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" <pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.DocumentType)"
(createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select> (createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" <pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.StoragePath)"
(createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select> (createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
<pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags> <pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Tag)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
@for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) { @for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) {
<div [formGroup]="customFieldFormFields.controls[i]"> <div [formGroup]="customFieldFormFields.controls[i]">
@switch (getCustomFieldFromInstance(fieldInstance)?.data_type) { @switch (getCustomFieldFromInstance(fieldInstance)?.data_type) {
@ -343,11 +347,11 @@
</div> </div>
</div> </div>
} @else { } @else {
@switch (contentRenderType) { @switch (archiveContentRenderType) {
@case (ContentRenderType.PDF) { @case (ContentRenderType.PDF) {
@if (!useNativePdfViewer) { @if (!useNativePdfViewer) {
<div class="preview-sticky pdf-viewer-container"> <div class="preview-sticky pdf-viewer-container">
<pngx-pdf-viewer <pdf-viewer
[src]="{ url: previewUrl, password: password }" [src]="{ url: previewUrl, password: password }"
[original-size]="false" [original-size]="false"
[show-borders]="true" [show-borders]="true"
@ -357,7 +361,7 @@
[zoom]="previewZoomSetting" [zoom]="previewZoomSetting"
(error)="onError($event)" (error)="onError($event)"
(after-load-complete)="pdfPreviewLoaded($event)"> (after-load-complete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer> </pdf-viewer>
</div> </div>
} @else { } @else {
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object> <object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>

View File

@ -5,16 +5,18 @@
} }
.pdf-viewer-container { .pdf-viewer-container {
padding-top: 10px;
background-color: gray; background-color: gray;
pngx-pdf-viewer { pdf-viewer {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
} }
::ng-deep .pngx-pdf-viewer-container .page { ::ng-deep .ng2-pdf-viewer-container .page {
--page-margin: 10px auto; --page-margin: 0 auto 10px;
--page-border: 0;
} }
::ng-deep form .ng-select-taggable { ::ng-deep form .ng-select-taggable {

View File

@ -76,11 +76,13 @@ import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/shar
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import { CustomFieldDataType } from 'src/app/data/custom-field' import { CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { PdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component' import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
import { PdfViewerModule } from 'ng2-pdf-viewer'
import { DataType } from 'src/app/data/datatype'
const doc: Document = { const doc: Document = {
id: 3, id: 3,
@ -176,9 +178,9 @@ describe('DocumentDetailComponent', () => {
SafeUrlPipe, SafeUrlPipe,
ShareLinksDropdownComponent, ShareLinksDropdownComponent,
CustomFieldsDropdownComponent, CustomFieldsDropdownComponent,
PdfViewerComponent,
SplitConfirmDialogComponent, SplitConfirmDialogComponent,
RotateConfirmDialogComponent, RotateConfirmDialogComponent,
DeletePagesConfirmDialogComponent,
], ],
providers: [ providers: [
DocumentTitlePipe, DocumentTitlePipe,
@ -265,6 +267,7 @@ describe('DocumentDetailComponent', () => {
ReactiveFormsModule, ReactiveFormsModule,
NgbModalModule, NgbModalModule,
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
PdfViewerModule,
], ],
}).compileComponents() }).compileComponents()
@ -649,7 +652,7 @@ describe('DocumentDetailComponent', () => {
]) ])
}) })
it('should support redo ocr, confirm and close modal after started', () => { it('should support reprocess, confirm and close modal after started', () => {
initNormally() initNormally()
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit') const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
bulkEditSpy.mockReturnValue(of(true)) bulkEditSpy.mockReturnValue(of(true))
@ -657,10 +660,10 @@ describe('DocumentDetailComponent', () => {
modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open') const modalSpy = jest.spyOn(modalService, 'open')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showInfo')
component.redoOcr() component.reprocess()
const modalCloseSpy = jest.spyOn(openModal, 'close') const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next() openModal.componentInstance.confirmClicked.next()
expect(bulkEditSpy).toHaveBeenCalledWith([doc.id], 'redo_ocr', {}) expect(bulkEditSpy).toHaveBeenCalledWith([doc.id], 'reprocess', {})
expect(modalSpy).toHaveBeenCalled() expect(modalSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled()
@ -672,7 +675,7 @@ describe('DocumentDetailComponent', () => {
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const toastSpy = jest.spyOn(toastService, 'showError') const toastSpy = jest.spyOn(toastService, 'showError')
component.redoOcr() component.reprocess()
const modalCloseSpy = jest.spyOn(openModal, 'close') const modalCloseSpy = jest.spyOn(openModal, 'close')
bulkEditSpy.mockReturnValue(throwError(() => new Error('error occurred'))) bulkEditSpy.mockReturnValue(throwError(() => new Error('error occurred')))
openModal.componentInstance.confirmClicked.next() openModal.componentInstance.confirmClicked.next()
@ -781,10 +784,9 @@ describe('DocumentDetailComponent', () => {
const object = { const object = {
id: 22, id: 22,
name: 'Correspondent22', name: 'Correspondent22',
last_correspondence: new Date().toISOString(),
} as Correspondent } as Correspondent
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.filterDocuments([object]) component.filterDocuments([object], DataType.Correspondent)
expect(qfSpy).toHaveBeenCalledWith([ expect(qfSpy).toHaveBeenCalledWith([
{ {
rule_type: FILTER_CORRESPONDENT, rule_type: FILTER_CORRESPONDENT,
@ -797,7 +799,7 @@ describe('DocumentDetailComponent', () => {
initNormally() initNormally()
const object = { id: 22, name: 'DocumentType22' } as DocumentType const object = { id: 22, name: 'DocumentType22' } as DocumentType
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.filterDocuments([object]) component.filterDocuments([object], DataType.DocumentType)
expect(qfSpy).toHaveBeenCalledWith([ expect(qfSpy).toHaveBeenCalledWith([
{ {
rule_type: FILTER_DOCUMENT_TYPE, rule_type: FILTER_DOCUMENT_TYPE,
@ -814,7 +816,7 @@ describe('DocumentDetailComponent', () => {
path: '/foo/bar/', path: '/foo/bar/',
} as StoragePath } as StoragePath
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.filterDocuments([object]) component.filterDocuments([object], DataType.StoragePath)
expect(qfSpy).toHaveBeenCalledWith([ expect(qfSpy).toHaveBeenCalledWith([
{ {
rule_type: FILTER_STORAGE_PATH, rule_type: FILTER_STORAGE_PATH,
@ -840,7 +842,7 @@ describe('DocumentDetailComponent', () => {
text_color: '#000000', text_color: '#000000',
} as Tag } as Tag
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.filterDocuments([object1, object2]) component.filterDocuments([object1, object2], DataType.Tag)
expect(qfSpy).toHaveBeenCalledWith([ expect(qfSpy).toHaveBeenCalledWith([
{ {
rule_type: FILTER_HAS_TAGS_ALL, rule_type: FILTER_HAS_TAGS_ALL,
@ -885,7 +887,7 @@ describe('DocumentDetailComponent', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(false) jest.spyOn(settingsService, 'get').mockReturnValue(false)
expect(component.useNativePdfViewer).toBeFalsy() expect(component.useNativePdfViewer).toBeFalsy()
fixture.detectChanges() fixture.detectChanges()
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull() expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
}) })
it('should display native pdf viewer if enabled', () => { it('should display native pdf viewer if enabled', () => {
@ -1035,7 +1037,9 @@ describe('DocumentDetailComponent', () => {
component.metadata = { has_archive_version: true } component.metadata = { has_archive_version: true }
initNormally() initNormally()
fixture.detectChanges() fixture.detectChanges()
expect(component.contentRenderType).toEqual(component.ContentRenderType.PDF) expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.PDF
)
expect( expect(
fixture.debugElement.query(By.css('pdf-viewer-container')) fixture.debugElement.query(By.css('pdf-viewer-container'))
).not.toBeUndefined() ).not.toBeUndefined()
@ -1045,7 +1049,7 @@ describe('DocumentDetailComponent', () => {
original_mime_type: 'text/plain', original_mime_type: 'text/plain',
} }
fixture.detectChanges() fixture.detectChanges()
expect(component.contentRenderType).toEqual( expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Text component.ContentRenderType.Text
) )
expect( expect(
@ -1057,7 +1061,7 @@ describe('DocumentDetailComponent', () => {
original_mime_type: 'image/jpg', original_mime_type: 'image/jpg',
} }
fixture.detectChanges() fixture.detectChanges()
expect(component.contentRenderType).toEqual( expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Image component.ContentRenderType.Image
) )
expect( expect(
@ -1070,7 +1074,7 @@ describe('DocumentDetailComponent', () => {
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
} }
fixture.detectChanges() fixture.detectChanges()
expect(component.contentRenderType).toEqual( expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Other component.ContentRenderType.Other
) )
expect( expect(
@ -1095,7 +1099,7 @@ describe('DocumentDetailComponent', () => {
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [doc.id], documents: [doc.id],
method: 'split', method: 'split',
parameters: { pages: '1-2,3-5' }, parameters: { pages: '1-2,3-5', delete_originals: false },
}) })
req.error(new ProgressEvent('failed')) req.error(new ProgressEvent('failed'))
modal.componentInstance.confirm() modal.componentInstance.confirm()
@ -1130,6 +1134,31 @@ describe('DocumentDetailComponent', () => {
req.flush(true) req.flush(true)
}) })
it('should support delete pages', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
initNormally()
component.deletePages()
expect(modal).not.toBeUndefined()
modal.componentInstance.documentID = doc.id
modal.componentInstance.pages = [1, 2]
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
expect(req.request.body).toEqual({
documents: [doc.id],
method: 'delete_pages',
parameters: { pages: [1, 2] },
})
req.error(new ProgressEvent('failed'))
modal.componentInstance.confirm()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
})
it('should support keyboard shortcuts', () => { it('should support keyboard shortcuts', () => {
initNormally() initNormally()

View File

@ -66,10 +66,12 @@ import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldInstance } from 'src/app/data/custom-field-instance' import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { PDFDocumentProxy } from '../common/pdf-viewer/typings'
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component' import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
import { HotKeyService } from 'src/app/services/hot-key.service' import { HotKeyService } from 'src/app/services/hot-key.service'
import { PDFDocumentProxy } from 'ng2-pdf-viewer'
import { DataType } from 'src/app/data/datatype'
enum DocumentDetailNavIDs { enum DocumentDetailNavIDs {
Details = 1, Details = 1,
@ -170,6 +172,8 @@ export class DocumentDetailComponent
public readonly ContentRenderType = ContentRenderType public readonly ContentRenderType = ContentRenderType
public readonly DataType = DataType
@ViewChild('nav') nav: NgbNav @ViewChild('nav') nav: NgbNav
@ViewChild('pdfPreview') set pdfPreview(element) { @ViewChild('pdfPreview') set pdfPreview(element) {
// this gets called when component added or removed from DOM // this gets called when component added or removed from DOM
@ -216,19 +220,27 @@ export class DocumentDetailComponent
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER) return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
} }
get contentRenderType(): ContentRenderType { get archiveContentRenderType(): ContentRenderType {
if (!this.metadata) return ContentRenderType.Unknown return this.getRenderType(
const contentType = this.metadata?.has_archive_version this.metadata?.has_archive_version
? 'application/pdf' ? 'application/pdf'
: this.metadata?.original_mime_type : this.metadata?.original_mime_type
)
}
if (contentType === 'application/pdf') { get originalContentRenderType(): ContentRenderType {
return this.getRenderType(this.metadata?.original_mime_type)
}
private getRenderType(mimeType: string): ContentRenderType {
if (!mimeType) return ContentRenderType.Unknown
if (mimeType === 'application/pdf') {
return ContentRenderType.PDF return ContentRenderType.PDF
} else if ( } else if (
['text/plain', 'application/csv', 'text/csv'].includes(contentType) ['text/plain', 'application/csv', 'text/csv'].includes(mimeType)
) { ) {
return ContentRenderType.Text return ContentRenderType.Text
} else if (contentType?.indexOf('image/') === 0) { } else if (mimeType?.indexOf('image/') === 0) {
return ContentRenderType.Image return ContentRenderType.Image
} }
return ContentRenderType.Other return ContentRenderType.Other
@ -761,11 +773,11 @@ export class DocumentDetailComponent
let modal = this.modalService.open(ConfirmDialogComponent, { let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.title = $localize`Confirm delete` modal.componentInstance.title = $localize`Confirm`
modal.componentInstance.messageBold = $localize`Do you really want to delete document "${this.document.title}"?` modal.componentInstance.messageBold = $localize`Do you really want to move the document "${this.document.title}" to the trash?`
modal.componentInstance.message = $localize`The files for this document will be deleted permanently. This operation cannot be undone.` modal.componentInstance.message = $localize`Documents can be restored prior to permanent deletion.`
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Delete document` modal.componentInstance.btnCaption = $localize`Move to trash`
this.subscribeModalDelete(modal) // so can be re-subscribed if error this.subscribeModalDelete(modal) // so can be re-subscribed if error
} }
@ -800,23 +812,23 @@ export class DocumentDetailComponent
]) ])
} }
redoOcr() { reprocess() {
let modal = this.modalService.open(ConfirmDialogComponent, { let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.title = $localize`Redo OCR confirm` modal.componentInstance.title = $localize`Reprocess confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently redo OCR for this document.` modal.componentInstance.messageBold = $localize`This operation will permanently recreate the archive file for this document.`
modal.componentInstance.message = $localize`This operation cannot be undone.` modal.componentInstance.message = $localize`The archive file will be re-generated with the current settings.`
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => { modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.documentsService this.documentsService
.bulkEdit([this.document.id], 'redo_ocr', {}) .bulkEdit([this.document.id], 'reprocess', {})
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo( this.toastService.showInfo(
$localize`Redo OCR operation will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.` $localize`Reprocess operation will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.`
) )
if (modal) { if (modal) {
modal.close() modal.close()
@ -989,7 +1001,7 @@ export class DocumentDetailComponent
) )
} }
filterDocuments(items: ObjectWithId[] | NgbDateStruct[]) { filterDocuments(items: ObjectWithId[] | NgbDateStruct[], type?: DataType) {
const filterRules: FilterRule[] = items.flatMap((i) => { const filterRules: FilterRule[] = items.flatMap((i) => {
if (i.hasOwnProperty('year')) { if (i.hasOwnProperty('year')) {
const isoDateAdapter = new ISODateAdapter() const isoDateAdapter = new ISODateAdapter()
@ -1008,30 +1020,28 @@ export class DocumentDetailComponent
value: dateBefore.toISOString().substring(0, 10), value: dateBefore.toISOString().substring(0, 10),
}, },
] ]
} else if (i.hasOwnProperty('last_correspondence')) { }
// Correspondent switch (type) {
return { case DataType.Correspondent:
rule_type: FILTER_CORRESPONDENT, return {
value: (i as Correspondent).id.toString(), rule_type: FILTER_CORRESPONDENT,
} value: (i as Correspondent).id.toString(),
} else if (i.hasOwnProperty('path')) { }
// Storage Path case DataType.DocumentType:
return { return {
rule_type: FILTER_STORAGE_PATH, rule_type: FILTER_DOCUMENT_TYPE,
value: (i as StoragePath).id.toString(), value: (i as DocumentType).id.toString(),
} }
} else if (i.hasOwnProperty('is_inbox_tag')) { case DataType.StoragePath:
// Tag return {
return { rule_type: FILTER_STORAGE_PATH,
rule_type: FILTER_HAS_TAGS_ALL, value: (i as StoragePath).id.toString(),
value: (i as Tag).id.toString(), }
} case DataType.Tag:
} else { return {
// Document Type, has no specific props rule_type: FILTER_HAS_TAGS_ALL,
return { value: (i as Tag).id.toString(),
rule_type: FILTER_DOCUMENT_TYPE, }
value: (i as DocumentType).id.toString(),
}
} }
}) })
@ -1110,6 +1120,7 @@ export class DocumentDetailComponent
this.documentsService this.documentsService
.bulkEdit([this.document.id], 'split', { .bulkEdit([this.document.id], 'split', {
pages: modal.componentInstance.pagesString, pages: modal.componentInstance.pagesString,
delete_originals: modal.componentInstance.deleteOriginal,
}) })
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
@ -1138,7 +1149,6 @@ export class DocumentDetailComponent
}) })
modal.componentInstance.title = $localize`Rotate confirm` modal.componentInstance.title = $localize`Rotate confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of the current document.` modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of the current document.`
modal.componentInstance.message = $localize`This will alter the original copy.`
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.documentID = this.document.id modal.componentInstance.documentID = this.document.id
modal.componentInstance.showPDFNote = false modal.componentInstance.showPDFNote = false
@ -1173,4 +1183,41 @@ export class DocumentDetailComponent
}) })
}) })
} }
deletePages() {
let modal = this.modalService.open(DeletePagesConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Delete pages confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently delete the selected pages from the original document.`
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.documentID = this.document.id
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.documentsService
.bulkEdit([this.document.id], 'delete_pages', {
pages: modal.componentInstance.pages,
})
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Delete pages operation will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.`
)
modal.close()
},
error: (error) => {
if (modal) {
modal.componentInstance.buttonsEnabled = true
}
this.toastService.showError(
$localize`Error executing delete pages operation`,
error
)
},
})
})
}
} }

View File

@ -27,31 +27,33 @@
} }
<span class="badge bg-secondary ms-auto" [class.bg-primary]="entry.action === AuditLogAction.Create">{{ entry.action | titlecase }}</span> <span class="badge bg-secondary ms-auto" [class.bg-primary]="entry.action === AuditLogAction.Create">{{ entry.action | titlecase }}</span>
</div> </div>
@if (entry.action === AuditLogAction.Update) { <ul class="mt-2">
<ul class="mt-2"> @for (change of entry.changes | keyvalue; track change.key) {
@for (change of entry.changes | keyvalue; track change.key) { @if (change.value["type"] === 'm2m') {
@if (change.value["type"] === 'm2m') { <li>
<li> <span class="fst-italic">{{ change.value["operation"] | titlecase }}</span>&nbsp;
<span class="fst-italic" i18n>{{ change.value["operation"] | titlecase }}</span>&nbsp; <span>{{ change.key | titlecase }}</span>:&nbsp;
<span>{{ change.key | titlecase }}</span>:&nbsp; <code class="text-primary">{{ change.value["objects"].join(', ') }}</code>
<code class="text-primary">{{ change.value["objects"].join(', ') }}</code> </li>
</li>
}
@else if (change.value["type"] === 'custom_field') {
<li>
<span>{{ change.value["field"] }}</span>:&nbsp;
<code class="text-primary">{{ change.value["value"] }}</code>
</li>
}
@else {
<li>
<span>{{ change.key | titlecase }}</span>:&nbsp;
<code class="text-primary">{{ change.value[1] }}</code>
</li>
}
} }
</ul> @else if (change.value["type"] === 'custom_field') {
} <li>
<span>{{ change.value["field"] }}</span>:&nbsp;
<code class="text-primary">{{ change.value["value"] }}</code>
</li>
}
@else {
<li>
<span>{{ change.key | titlecase }}</span>:&nbsp;
@if (change.key === 'content') {
<code class="text-primary">{{ change.value[1]?.substring(0,100) }}...</code>
} @else {
<code class="text-primary">{{ change.value[1] }}</code>
}
</li>
}
}
</ul>
</li> </li>
} }
} }

View File

@ -107,8 +107,8 @@
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div> <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<button ngbDropdownItem (click)="redoOcrSelected()" [disabled]="!userCanEditAll"> <button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll">
<i-bs name="body-text"></i-bs>&nbsp;<ng-container i18n>Redo OCR</ng-container> <i-bs name="body-text"></i-bs>&nbsp;<ng-container i18n>Reprocess</ng-container>
</button> </button>
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll"> <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll">
<i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container> <i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>

View File

@ -858,7 +858,7 @@ describe('BulkEditorComponent', () => {
) )
}) })
it('should support bulk delete with confirmation', () => { it('should support bulk delete with confirmation or without', () => {
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0])) modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
@ -891,6 +891,13 @@ describe('BulkEditorComponent', () => {
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds ) // listAllFilteredIds
component.showConfirmationDialogs = false
fixture.detectChanges()
component.applyDelete()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
}) })
it('should not be accessible with insufficient global permissions', () => { it('should not be accessible with insufficient global permissions', () => {
@ -961,7 +968,7 @@ describe('BulkEditorComponent', () => {
.mockReturnValue(true) .mockReturnValue(true)
component.showConfirmationDialogs = true component.showConfirmationDialogs = true
fixture.detectChanges() fixture.detectChanges()
component.redoOcrSelected() component.reprocessSelected()
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
modal.componentInstance.confirm() modal.componentInstance.confirm()
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
@ -970,7 +977,7 @@ describe('BulkEditorComponent', () => {
req.flush(true) req.flush(true)
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({
documents: [3, 4], documents: [3, 4],
method: 'redo_ocr', method: 'reprocess',
parameters: {}, parameters: {},
}) })
httpTestingController.match( httpTestingController.match(
@ -1056,6 +1063,25 @@ describe('BulkEditorComponent', () => {
httpTestingController.match( httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id` `${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds ) // listAllFilteredIds
// Test with Delete Originals enabled
modal.componentInstance.deleteOriginals = true
modal.componentInstance.confirm()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'merge',
parameters: { metadata_document_id: 3, delete_originals: true },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
}) })
it('should support bulk download with archive, originals or both and file formatting', () => { it('should support bulk download with archive, originals or both and file formatting', () => {

View File

@ -705,21 +705,24 @@ export class BulkEditorComponent
} }
applyDelete() { applyDelete() {
let modal = this.modalService.open(ConfirmDialogComponent, { if (this.showConfirmationDialogs) {
backdrop: 'static', let modal = this.modalService.open(ConfirmDialogComponent, {
}) backdrop: 'static',
modal.componentInstance.delayConfirm(5)
modal.componentInstance.title = $localize`Delete confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently delete ${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`Delete document(s)`
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.executeBulkOperation(modal, 'delete', {})
}) })
modal.componentInstance.title = $localize`Confirm`
modal.componentInstance.messageBold = $localize`Move ${this.list.selected.size} selected document(s) to the trash?`
modal.componentInstance.message = $localize`Documents can be restored prior to permanent deletion.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Move to trash`
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.executeBulkOperation(modal, 'delete', {})
})
} else {
this.executeBulkOperation(null, 'delete', {})
}
} }
downloadSelected() { downloadSelected() {
@ -744,20 +747,20 @@ export class BulkEditorComponent
}) })
} }
redoOcrSelected() { reprocessSelected() {
let modal = this.modalService.open(ConfirmDialogComponent, { let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.title = $localize`Redo OCR confirm` modal.componentInstance.title = $localize`Reprocess confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently redo OCR for ${this.list.selected.size} selected document(s).` modal.componentInstance.messageBold = $localize`This operation will permanently recreate the archive files for ${this.list.selected.size} selected document(s).`
modal.componentInstance.message = $localize`This operation cannot be undone.` modal.componentInstance.message = $localize`The archive files will be re-generated with the current settings.`
modal.componentInstance.btnClass = 'btn-danger' modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed` modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
this.executeBulkOperation(modal, 'redo_ocr', {}) this.executeBulkOperation(modal, 'reprocess', {})
}) })
} }
@ -813,6 +816,9 @@ export class BulkEditorComponent
if (mergeDialog.metadataDocumentID > -1) { if (mergeDialog.metadataDocumentID > -1) {
args['metadata_document_id'] = mergeDialog.metadataDocumentID args['metadata_document_id'] = mergeDialog.metadataDocumentID
} }
if (mergeDialog.deleteOriginals) {
args['delete_originals'] = true
}
mergeDialog.buttonsEnabled = false mergeDialog.buttonsEnabled = false
this.executeBulkOperation(modal, 'merge', args, mergeDialog.documentIDs) this.executeBulkOperation(modal, 'merge', args, mergeDialog.documentIDs)
this.toastService.showInfo( this.toastService.showInfo(

View File

@ -34,7 +34,7 @@
</h5> </h5>
</div> </div>
<p class="card-text"> <p class="card-text">
@if (document.__search_hit__?.score && document.__search_hit__.highlights) { @if (document.__search_hit__ && document.__search_hit__.highlights) {
<span [innerHtml]="document.__search_hit__.highlights"></span> <span [innerHtml]="document.__search_hit__.highlights"></span>
} }
@for (highlight of searchNoteHighlights; track highlight) { @for (highlight of searchNoteHighlights; track highlight) {
@ -72,7 +72,7 @@
<div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0"> <div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0">
@if (displayFields.includes(DisplayField.NOTES) && notesEnabled && document.notes.length) { @if (displayFields.includes(DisplayField.NOTES) && notesEnabled && document.notes.length) {
<button routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="View notes" i18n-title> <button routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="View notes" i18n-title>
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="chat-left-text"></i-bs><small i18n>{{document.notes.length}} Notes</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="chat-left-text"></i-bs><small>{{document.notes.length}} Notes</small>
</button> </button>
} }
@if (displayFields.includes(DisplayField.DOCUMENT_TYPE) && document.document_type) { @if (displayFields.includes(DisplayField.DOCUMENT_TYPE) && document.document_type) {
@ -95,7 +95,7 @@
@if (displayFields.includes(DisplayField.CREATED) || displayFields.includes(DisplayField.ADDED)) { @if (displayFields.includes(DisplayField.CREATED) || displayFields.includes(DisplayField.ADDED)) {
<ng-template #dateTooltip> <ng-template #dateTooltip>
<div class="d-flex flex-column text-light"> <div class="d-flex flex-column text-light">
<span i18n>Created: {{ document.created | customDate }}</span> <span i18n>Created: {{ document.created_date | customDate }}</span>
<span i18n>Added: {{ document.added | customDate }}</span> <span i18n>Added: {{ document.added | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate }}</span> <span i18n>Modified: {{ document.modified | customDate }}</span>
</div> </div>

View File

@ -62,14 +62,14 @@
<div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between"> <div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between">
<ng-template #dateTooltip> <ng-template #dateTooltip>
<div class="d-flex flex-column text-light"> <div class="d-flex flex-column text-light">
<span i18n>Created: {{ document.created | customDate }}</span> <span i18n>Created: {{ document.created_date | customDate }}</span>
<span i18n>Added: {{ document.added | customDate }}</span> <span i18n>Added: {{ document.added | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate }}</span> <span i18n>Modified: {{ document.modified | customDate }}</span>
</div> </div>
</ng-template> </ng-template>
<div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip"> <div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="calendar-event"></i-bs> <i-bs width="1em" height="1em" class="me-2 text-muted" name="calendar-event"></i-bs>
<small>{{document.created | customDate:'mediumDate'}}</small> <small>{{document.created_date | customDate:'mediumDate'}}</small>
</div> </div>
</div> </div>
} }

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