Merge branch 'dev' of https://github.com/paperless-ngx/paperless-ngx into filtering-improvement
This commit is contained in:
commit
abd4690fbf
@ -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
|
||||||
|
13
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
13
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@ -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:
|
||||||
|
5
.github/dependabot.yml
vendored
5
.github/dependabot.yml
vendored
@ -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:
|
||||||
|
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -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"
|
||||||
|
|
||||||
|
4
.github/workflows/cleanup-tags.yml
vendored
4
.github/workflows/cleanup-tags.yml
vendored
@ -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 }}"
|
||||||
|
2
.github/workflows/crowdin.yml
vendored
2
.github/workflows/crowdin.yml
vendored
@ -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
|
||||||
|
2
.github/workflows/repo-maintenance.yml
vendored
2
.github/workflows/repo-maintenance.yml
vendored
@ -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
1
.gitignore
vendored
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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.
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
30
Dockerfile
30
Dockerfile
@ -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
13
Pipfile
@ -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
1974
Pipfile.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
13
docs/api.md
13
docs/api.md
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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=
|
||||||
|
@ -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
|
||||||
|
@ -76,8 +76,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": [],
|
"scripts": [],
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
"pdfjs-dist",
|
"ng2-pdf-viewer",
|
||||||
"pdfjs-dist/web/pdf_viewer",
|
|
||||||
"filesize",
|
"filesize",
|
||||||
"file-saver"
|
"file-saver"
|
||||||
],
|
],
|
||||||
|
@ -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 }) => {
|
||||||
|
@ -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',
|
||||||
|
1089
src-ui/messages.xlf
1089
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
770
src-ui/package-lock.json
generated
770
src-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||||
|
@ -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',
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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">
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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))
|
||||||
|
@ -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()
|
||||||
|
98
src-ui/src/app/components/admin/trash/trash.component.html
Normal file
98
src-ui/src/app/components/admin/trash/trash.component.html
Normal 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> <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> <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> <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> <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> <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> <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) {
|
||||||
|
({{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>
|
||||||
|
}
|
163
src-ui/src/app/components/admin/trash/trash.component.spec.ts
Normal file
163
src-ui/src/app/components/admin/trash/trash.component.spec.ts
Normal 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
|
||||||
|
})
|
||||||
|
})
|
137
src-ui/src/app/components/admin/trash/trash.component.ts
Normal file
137
src-ui/src/app/components/admin/trash/trash.component.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -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> <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"
|
||||||
|
@ -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)">
|
||||||
|
@ -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 {
|
||||||
|
@ -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' },
|
||||||
])
|
])
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
|
@ -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()
|
||||||
|
@ -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>
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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([])
|
||||||
|
})
|
||||||
|
})
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -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">
|
||||||
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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">
|
||||||
|
@ -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%;
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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] },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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[] {
|
||||||
|
@ -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> <span>{{document.title}}</span>
|
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs> <span>{{document.title}}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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(
|
||||||
|
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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">
|
||||||
|
@ -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;
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
<div #pdfViewerContainer class="pngx-pdf-viewer-container">
|
|
||||||
<div class="pdfViewer"></div>
|
|
||||||
</div>
|
|
File diff suppressed because it is too large
Load Diff
@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -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'
|
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
@ -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>
|
||||||
|
@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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', () => {
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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"> <ng-container i18n>Actions</ng-container></div>
|
<div class="d-none d-sm-inline"> <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> <span i18n>Redo OCR</span>
|
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs> <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> <span i18n>More like this</span>
|
<i-bs width="1em" height="1em" name="diagram-3"></i-bs> <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> <span i18n>Split</span>
|
<i-bs width="1em" height="1em" name="scissors"></i-bs> <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> <ng-container i18n>Rotate</ng-container>
|
<i-bs name="arrow-clockwise"></i-bs> <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> <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>
|
||||||
|
@ -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 {
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
<span class="fst-italic" i18n>{{ change.value["operation"] | titlecase }}</span>
|
<span>{{ change.key | titlecase }}</span>:
|
||||||
<span>{{ change.key | titlecase }}</span>:
|
<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>:
|
|
||||||
<code class="text-primary">{{ change.value["value"] }}</code>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
@else {
|
|
||||||
<li>
|
|
||||||
<span>{{ change.key | titlecase }}</span>:
|
|
||||||
<code class="text-primary">{{ change.value[1] }}</code>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</ul>
|
@else if (change.value["type"] === 'custom_field') {
|
||||||
}
|
<li>
|
||||||
|
<span>{{ change.value["field"] }}</span>:
|
||||||
|
<code class="text-primary">{{ change.value["value"] }}</code>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
@else {
|
||||||
|
<li>
|
||||||
|
<span>{{ change.key | titlecase }}</span>:
|
||||||
|
@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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -107,8 +107,8 @@
|
|||||||
<div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div>
|
<div class="d-none d-sm-inline"> <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> <ng-container i18n>Redo OCR</ng-container>
|
<i-bs name="body-text"></i-bs> <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> <ng-container i18n>Rotate</ng-container>
|
<i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
|
||||||
|
@ -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', () => {
|
||||||
|
@ -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(
|
||||||
|
@ -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>
|
||||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user