Compare commits

..

1 Commits

Author SHA1 Message Date
shamoon
292d7762c4 Basic RTL tweaks 2024-04-27 22:28:50 -07:00
297 changed files with 66221 additions and 109949 deletions

View File

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

View File

@@ -86,12 +86,6 @@ 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.
validations:
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
id: browser
attributes:
@@ -103,6 +97,11 @@ body:
attributes:
label: Configuration changes
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
id: required-checks
attributes:

View File

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

View File

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

View File

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

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,7 @@ COPY Pipfile* ./
RUN set -eux \
&& echo "Installing pipenv" \
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2024.0.1 \
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2023.12.1 \
&& echo "Generating requirement.txt" \
&& pipenv requirements > requirements.txt
@@ -53,7 +53,7 @@ ARG TARGETARCH
# Can be workflow provided, defaults set for manual building
ARG JBIG2ENC_VERSION=0.29
ARG QPDF_VERSION=11.9.0
ARG GS_VERSION=10.03.1
ARG GS_VERSION=10.02.1
# Set Python environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -83,6 +83,7 @@ ARG RUNTIME_PACKAGES="\
icc-profiles-free \
imagemagick \
# PostgreSQL
libpq5 \
postgresql-client \
# MySQL / MariaDB
mariadb-client \
@@ -128,17 +129,17 @@ RUN set -eux \
&& dpkg --install ./qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
&& echo "Installing Ghostscript ${GS_VERSION}" \
&& curl --fail --silent --show-error --location \
--output libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
--output libgs10_${GS_VERSION}.dfsg-2_${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 \
--output ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
--output ghostscript_${GS_VERSION}.dfsg-2_${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 \
--output libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
--output libgs10-common_${GS_VERSION}.dfsg-2_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-1_all.deb \
&& dpkg --install ./libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
&& dpkg --install ./ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
&& dpkg --install ./libgs10-common_${GS_VERSION}.dfsg-2_all.deb \
&& dpkg --install ./libgs10_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
&& dpkg --install ./ghostscript_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
&& echo "Installing jbig2enc" \
&& curl --fail --silent --show-error --location \
--output jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
@@ -222,13 +223,7 @@ RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \
&& apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \
&& python3 -m pip install --no-cache-dir --upgrade wheel \
&& echo "Installing Python requirements" \
&& 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 \
&& python3 -m pip install --default-timeout=1000 --requirement requirements.txt \
&& echo "Patching whitenoise for compression speedup" \
&& 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 \
@@ -241,7 +236,6 @@ RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \
&& apt-get --yes purge ${BUILD_PACKAGES} \
&& apt-get --yes autoremove --purge \
&& apt-get clean --yes \
&& rm --recursive --force --verbose *.whl \
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
&& rm --recursive --force --verbose /tmp/* \
&& rm --recursive --force --verbose /var/tmp/* \

11
Pipfile
View File

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

2540
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@ Thanks to the generous folks at [DigitalOcean](https://m.do.co/c/8d70b916d462),
- [Translation](#translation)
- [Feature Requests](#feature-requests)
- [Bugs](#bugs)
- [Related Projects](#related-projects)
- [Affiliated Projects](#affiliated-projects)
- [Important Note](#important-note)
<p align="right">This project is supported by:<br/>
@@ -63,7 +63,7 @@ If you'd like to jump right in, you can configure a `docker compose` environment
bash -c "$(curl -L https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/install-paperless-ngx.sh)"
```
More details and step-by-step guides for alternative installation methods can be found in [the documentation](https://docs.paperless-ngx.com/setup/#installation).
Alternatively, you can install the dependencies and setup apache and a database server yourself. The [documentation](https://docs.paperless-ngx.com/setup/#installation) has a step by step guide on how to do it.
Migrating from Paperless-ng is easy, just drop in the new docker image! See the [documentation on migrating](https://docs.paperless-ngx.com/setup/#migrating-to-paperless-ngx) for more details.
@@ -93,9 +93,9 @@ Feature requests can be submitted via [GitHub Discussions](https://github.com/pa
For bugs please [open an issue](https://github.com/paperless-ngx/paperless-ngx/issues) or [start a discussion](https://github.com/paperless-ngx/paperless-ngx/discussions) if you have questions.
# Related Projects
# Affiliated Projects
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects) for a user-maintained list of related projects and software that is compatible with Paperless-ngx.
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Affiliated-Projects) for a user-maintained list of affiliated projects and software that is compatible with Paperless-ngx.
# Important Note

View File

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

View File

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

View File

@@ -26,6 +26,7 @@
# For more extensive installation and update instructions, refer to the
# documentation.
version: "3.4"
services:
broker:
image: docker.io/library/redis:7
@@ -34,7 +35,7 @@ services:
- redisdata:/data
db:
image: docker.io/library/mariadb:11
image: docker.io/library/mariadb:10
restart: unless-stopped
volumes:
- dbdata:/var/lib/mysql

View File

@@ -28,6 +28,7 @@
# For more extensive installation and update instructions, refer to the
# documentation.
version: "3.4"
services:
broker:
image: docker.io/library/redis:7
@@ -36,7 +37,7 @@ services:
- redisdata:/data
db:
image: docker.io/library/postgres:16
image: docker.io/library/postgres:15
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data

View File

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

View File

@@ -26,6 +26,7 @@
# For more extensive installation and update instructions, refer to the
# documentation.
version: "3.4"
services:
broker:
image: docker.io/library/redis:7
@@ -34,7 +35,7 @@ services:
- redisdata:/data
db:
image: docker.io/library/postgres:16
image: docker.io/library/postgres:15
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ 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
"""
import os
import sys
import time

View File

@@ -185,12 +185,34 @@ For PostgreSQL, refer to [Upgrading a PostgreSQL Cluster](https://www.postgresql
For MariaDB, refer to [Upgrading MariaDB](https://mariadb.com/kb/en/upgrading/)
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.
## Downgrading Paperless {#downgrade-paperless}
!!! warning
Downgrades are possible. However, some updates also contain database
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.
You should not change any settings, especially paths, when doing this or there is a
risk of data loss
This table lists the compatible versions for each database migration
number.
| 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}
@@ -247,8 +269,6 @@ optional arguments:
-sm, --split-manifest
-z, --zip
-zn, --zip-name
--data-only
--passphrase
```
`target` is a folder to which the data gets written. This includes
@@ -307,12 +327,6 @@ 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
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
If exporting with the file name format, there may be errors due to
@@ -327,22 +341,15 @@ exporter](#exporter) and imports it into paperless.
The importer works just like the exporter. You point it at a directory,
and the script does the rest of the work:
```shell
```
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
the `export` folder in your paperless source directory. Specify
`../export` as the `source`.
Note that .zip files (as can be generated from the exporter) are not supported. You must unzip them into
the target directory first.
Note that .zip files (as can be generated from the exporter) are not supported.
!!! note
@@ -352,7 +359,6 @@ the target directory first.
!!! warning
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}
@@ -580,7 +586,7 @@ Enabling encryption is no longer supported.
Basic usage to disable encryption of your document store:
(Note: If `PAPERLESS_PASSPHRASE` isn't set already, you need to specify
(Note: If [`PAPERLESS_PASSPHRASE`](configuration.md#PAPERLESS_PASSPHRASE) isn't set already, you need to specify
it here)
```

View File

@@ -11,7 +11,7 @@ The API provides the following main endpoints:
- `/api/correspondents/`: Full CRUD support.
- `/api/custom_fields/`: Full CRUD support.
- `/api/documents/`: Full CRUD support, except POSTing new documents.
See [below](#file-uploads).
See below.
- `/api/document_types/`: Full CRUD support.
- `/api/groups/`: Full CRUD support.
- `/api/logs/`: Read-Only.
@@ -24,7 +24,6 @@ The API provides the following main endpoints:
- `/api/tasks/`: Read-only.
- `/api/users/`: Full CRUD support.
- `/api/workflows/`: Full CRUD support.
- `/api/search/` GET, see [below](#global-search).
All of these endpoints except for the logging endpoint allow you to
fetch (and edit and delete where appropriate) individual objects by
@@ -189,38 +188,6 @@ The REST api provides four different forms of authentication.
[configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
you can authenticate against the API using Remote User auth.
## Global search
A global search endpoint is available at `/api/search/` and requires a search term
of > 2 characters e.g. `?query=foo`. This endpoint returns a maximum of 3 results
across nearly all objects, e.g. documents, tags, saved views, mail rules, etc.
Results are only included if the requesting user has the appropriate permissions.
Results are returned in the following format:
```json
{
total: number
documents: []
saved_views: []
correspondents: []
document_types: []
storage_paths: []
tags: []
users: []
groups: []
mail_accounts: []
mail_rules: []
custom_fields: []
workflows: []
}
```
Global search first searches objects by name (or title for documents) matching the query.
If the optional `db_only` parameter is set, only document titles will be searched. Otherwise,
if the amount of documents returned by a simple title string search is < 3, results from the
search index will also be included.
## Searching for documents
Full text searching is available on the `/api/documents/` endpoint. Two
@@ -403,11 +370,11 @@ The following methods are supported:
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and / or `{ "remove_tags": [LIST_OF_TAG_IDS] }`
- `delete`
- No `parameters` required
- `reprocess`
- `redo_ocr`
- No `parameters` required
- `set_permissions`
- Requires `parameters`:
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
- `"permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
- `"owner": OWNER_ID or null`
- `"merge": true or false` (defaults to false)
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
@@ -417,22 +384,13 @@ The following methods are supported:
- The ordering of the merged document is determined by the list of IDs.
- Optional `parameters`:
- `"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`
- 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]"`
- 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.
- `rotate`
- Requires `parameters`:
- `"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

View File

@@ -1,304 +1,5 @@
# Changelog
## 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
### Bug Fixes
- Fix: Restore the compression of static files for x86_64 [@stumpylog](https://github.com/stumpylog) ([#6627](https://github.com/paperless-ngx/paperless-ngx/pull/6627))
- Fix: make backend monetary validation accept unpadded decimals [@shamoon](https://github.com/shamoon) ([#6626](https://github.com/paperless-ngx/paperless-ngx/pull/6626))
- Fix: allow bulk edit with existing fields [@shamoon](https://github.com/shamoon) ([#6625](https://github.com/paperless-ngx/paperless-ngx/pull/6625))
- Fix: table view doesn't immediately display custom fields on app startup [@shamoon](https://github.com/shamoon) ([#6600](https://github.com/paperless-ngx/paperless-ngx/pull/6600))
- Fix: dont use limit in subqueries in global search for mariadb compatibility [@shamoon](https://github.com/shamoon) ([#6611](https://github.com/paperless-ngx/paperless-ngx/pull/6611))
- Fix: exclude admin perms from group permissions serializer [@shamoon](https://github.com/shamoon) ([#6608](https://github.com/paperless-ngx/paperless-ngx/pull/6608))
- Fix: global search text illegible in light mode [@shamoon](https://github.com/shamoon) ([#6602](https://github.com/paperless-ngx/paperless-ngx/pull/6602))
- Fix: document history text color illegible in light mode [@shamoon](https://github.com/shamoon) ([#6601](https://github.com/paperless-ngx/paperless-ngx/pull/6601))
### All App Changes
<details>
<summary>10 changes</summary>
- Fix: Restore the compression of static files for x86_64 [@stumpylog](https://github.com/stumpylog) ([#6627](https://github.com/paperless-ngx/paperless-ngx/pull/6627))
- Fix: make backend monetary validation accept unpadded decimals [@shamoon](https://github.com/shamoon) ([#6626](https://github.com/paperless-ngx/paperless-ngx/pull/6626))
- Fix: allow bulk edit with existing fields [@shamoon](https://github.com/shamoon) ([#6625](https://github.com/paperless-ngx/paperless-ngx/pull/6625))
- Enhancement: show custom field name on cards if empty, add tooltip [@shamoon](https://github.com/shamoon) ([#6620](https://github.com/paperless-ngx/paperless-ngx/pull/6620))
- Security: Disable in pdfjs [@shamoon](https://github.com/shamoon) ([#6615](https://github.com/paperless-ngx/paperless-ngx/pull/6615))
- Fix: table view doesn't immediately display custom fields on app startup [@shamoon](https://github.com/shamoon) ([#6600](https://github.com/paperless-ngx/paperless-ngx/pull/6600))
- Fix: dont use limit in subqueries in global search for mariadb compatibility [@shamoon](https://github.com/shamoon) ([#6611](https://github.com/paperless-ngx/paperless-ngx/pull/6611))
- Fix: exclude admin perms from group permissions serializer [@shamoon](https://github.com/shamoon) ([#6608](https://github.com/paperless-ngx/paperless-ngx/pull/6608))
- Fix: global search text illegible in light mode [@shamoon](https://github.com/shamoon) ([#6602](https://github.com/paperless-ngx/paperless-ngx/pull/6602))
- Fix: document history text color illegible in light mode [@shamoon](https://github.com/shamoon) ([#6601](https://github.com/paperless-ngx/paperless-ngx/pull/6601))
</details>
## paperless-ngx 2.8.1
### Bug Fixes
- Fix: saved views dont immediately display custom fields in table view [@shamoon](https://github.com/shamoon) ([#6594](https://github.com/paperless-ngx/paperless-ngx/pull/6594))
- Fix: bulk edit custom fields should support multiple items [@shamoon](https://github.com/shamoon) ([#6589](https://github.com/paperless-ngx/paperless-ngx/pull/6589))
### Dependencies
- Chore(deps-dev): Bump jinja2 from 3.1.3 to 3.1.4 [@dependabot](https://github.com/dependabot) ([#6579](https://github.com/paperless-ngx/paperless-ngx/pull/6579))
- Chore(deps-dev): Bump mkdocs-glightbox from 0.3.7 to 0.4.0 in the small-changes group [@dependabot](https://github.com/dependabot) ([#6581](https://github.com/paperless-ngx/paperless-ngx/pull/6581))
### All App Changes
<details>
<summary>3 changes</summary>
- Fix: saved views dont immediately display custom fields in table view [@shamoon](https://github.com/shamoon) ([#6594](https://github.com/paperless-ngx/paperless-ngx/pull/6594))
- Chore(deps-dev): Bump mkdocs-glightbox from 0.3.7 to 0.4.0 in the small-changes group [@dependabot](https://github.com/dependabot) ([#6581](https://github.com/paperless-ngx/paperless-ngx/pull/6581))
- Fix: bulk edit custom fields should support multiple items [@shamoon](https://github.com/shamoon) ([#6589](https://github.com/paperless-ngx/paperless-ngx/pull/6589))
</details>
## paperless-ngx 2.8.0
### Breaking Changes
- Fix: remove admin.logentry perm, use admin (staff) status [@shamoon](https://github.com/shamoon) ([#6380](https://github.com/paperless-ngx/paperless-ngx/pull/6380))
### Notable Changes
- Feature: global search, keyboard shortcuts / hotkey support [@shamoon](https://github.com/shamoon) ([#6449](https://github.com/paperless-ngx/paperless-ngx/pull/6449))
- Feature: custom fields filtering \& bulk editing [@shamoon](https://github.com/shamoon) ([#6484](https://github.com/paperless-ngx/paperless-ngx/pull/6484))
- Feature: customizable fields display for documents, saved views \& dashboard widgets [@shamoon](https://github.com/shamoon) ([#6439](https://github.com/paperless-ngx/paperless-ngx/pull/6439))
- Feature: document history (audit log UI) [@shamoon](https://github.com/shamoon) ([#6388](https://github.com/paperless-ngx/paperless-ngx/pull/6388))
- Chore: Convert the consumer to a plugin [@stumpylog](https://github.com/stumpylog) ([#6361](https://github.com/paperless-ngx/paperless-ngx/pull/6361))
### Features
- Feature: global search, keyboard shortcuts / hotkey support [@shamoon](https://github.com/shamoon) ([#6449](https://github.com/paperless-ngx/paperless-ngx/pull/6449))
- Feature: customizable fields display for documents, saved views \& dashboard widgets [@shamoon](https://github.com/shamoon) ([#6439](https://github.com/paperless-ngx/paperless-ngx/pull/6439))
- Feature: document history (audit log UI) [@shamoon](https://github.com/shamoon) ([#6388](https://github.com/paperless-ngx/paperless-ngx/pull/6388))
- Enhancement: refactor monetary field [@shamoon](https://github.com/shamoon) ([#6370](https://github.com/paperless-ngx/paperless-ngx/pull/6370))
- Chore: Convert the consumer to a plugin [@stumpylog](https://github.com/stumpylog) ([#6361](https://github.com/paperless-ngx/paperless-ngx/pull/6361))
### Bug Fixes
- Fix: always check workflow if set [@shamoon](https://github.com/shamoon) ([#6474](https://github.com/paperless-ngx/paperless-ngx/pull/6474))
- Fix: use responsive tables for management lists [@DlieBG](https://github.com/DlieBG) ([#6460](https://github.com/paperless-ngx/paperless-ngx/pull/6460))
- Fix: password reset done template [@shamoon](https://github.com/shamoon) ([#6444](https://github.com/paperless-ngx/paperless-ngx/pull/6444))
- Fix: show message on empty group list [@DlieBG](https://github.com/DlieBG) ([#6393](https://github.com/paperless-ngx/paperless-ngx/pull/6393))
- Fix: remove admin.logentry perm, use admin (staff) status [@shamoon](https://github.com/shamoon) ([#6380](https://github.com/paperless-ngx/paperless-ngx/pull/6380))
- Fix: dont dismiss active alerts on dismiss completed [@shamoon](https://github.com/shamoon) ([#6364](https://github.com/paperless-ngx/paperless-ngx/pull/6364))
- Fix: Allow lowercase letters in monetary currency code field [@shamoon](https://github.com/shamoon) ([#6359](https://github.com/paperless-ngx/paperless-ngx/pull/6359))
- Fix: Allow negative monetary values with a current code [@stumpylog](https://github.com/stumpylog) ([#6358](https://github.com/paperless-ngx/paperless-ngx/pull/6358))
- Fix: add timezone fallback to install script [@Harald-Berghoff](https://github.com/Harald-Berghoff) ([#6336](https://github.com/paperless-ngx/paperless-ngx/pull/6336))
### Maintenance
- Chore(deps): Bump stumpylog/image-cleaner-action from 0.5.0 to 0.6.0 in the actions group [@dependabot](https://github.com/dependabot) ([#6541](https://github.com/paperless-ngx/paperless-ngx/pull/6541))
- Chore(deps): Bump all allowed backend packages [@stumpylog](https://github.com/stumpylog) ([#6562](https://github.com/paperless-ngx/paperless-ngx/pull/6562))
### Dependencies
<details>
<summary>10 changes</summary>
- Chore(deps): Bump stumpylog/image-cleaner-action from 0.5.0 to 0.6.0 in the actions group [@dependabot](https://github.com/dependabot) ([#6541](https://github.com/paperless-ngx/paperless-ngx/pull/6541))
- Chore(deps-dev): Bump ejs from 3.1.9 to 3.1.10 in /src-ui [@dependabot](https://github.com/dependabot) ([#6540](https://github.com/paperless-ngx/paperless-ngx/pull/6540))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 13 updates [@dependabot](https://github.com/dependabot) ([#6539](https://github.com/paperless-ngx/paperless-ngx/pull/6539))
- Chore(deps): Bump python-ipware from 2.0.3 to 3.0.0 in the major-versions group [@dependabot](https://github.com/dependabot) ([#6468](https://github.com/paperless-ngx/paperless-ngx/pull/6468))
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#6466](https://github.com/paperless-ngx/paperless-ngx/pull/6466))
- Chore: Updates Docker bundled QPDF to 11.9.0 [@stumpylog](https://github.com/stumpylog) ([#6423](https://github.com/paperless-ngx/paperless-ngx/pull/6423))
- Chore(deps): Bump gunicorn from 21.2.0 to 22.0.0 [@dependabot](https://github.com/dependabot) ([#6416](https://github.com/paperless-ngx/paperless-ngx/pull/6416))
- Chore(deps): Bump the small-changes group with 11 updates [@dependabot](https://github.com/dependabot) ([#6405](https://github.com/paperless-ngx/paperless-ngx/pull/6405))
- Chore(deps): Bump idna from 3.6 to 3.7 [@dependabot](https://github.com/dependabot) ([#6377](https://github.com/paperless-ngx/paperless-ngx/pull/6377))
- Chore(deps): Bump tar from 6.2.0 to 6.2.1 in /src-ui [@dependabot](https://github.com/dependabot) ([#6373](https://github.com/paperless-ngx/paperless-ngx/pull/6373))
</details>
### All App Changes
<details>
<summary>23 changes</summary>
- Feature: global search, keyboard shortcuts / hotkey support [@shamoon](https://github.com/shamoon) ([#6449](https://github.com/paperless-ngx/paperless-ngx/pull/6449))
- Chore(deps-dev): Bump ejs from 3.1.9 to 3.1.10 in /src-ui [@dependabot](https://github.com/dependabot) ([#6540](https://github.com/paperless-ngx/paperless-ngx/pull/6540))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 13 updates [@dependabot](https://github.com/dependabot) ([#6539](https://github.com/paperless-ngx/paperless-ngx/pull/6539))
- Chore: Hand craft SQL queries [@stumpylog](https://github.com/stumpylog) ([#6489](https://github.com/paperless-ngx/paperless-ngx/pull/6489))
- Feature: custom fields filtering \& bulk editing [@shamoon](https://github.com/shamoon) ([#6484](https://github.com/paperless-ngx/paperless-ngx/pull/6484))
- Feature: customizable fields display for documents, saved views \& dashboard widgets [@shamoon](https://github.com/shamoon) ([#6439](https://github.com/paperless-ngx/paperless-ngx/pull/6439))
- Chore(deps): Bump python-ipware from 2.0.3 to 3.0.0 in the major-versions group [@dependabot](https://github.com/dependabot) ([#6468](https://github.com/paperless-ngx/paperless-ngx/pull/6468))
- Feature: document history (audit log UI) [@shamoon](https://github.com/shamoon) ([#6388](https://github.com/paperless-ngx/paperless-ngx/pull/6388))
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#6466](https://github.com/paperless-ngx/paperless-ngx/pull/6466))
- Fix: always check workflow if set [@shamoon](https://github.com/shamoon) ([#6474](https://github.com/paperless-ngx/paperless-ngx/pull/6474))
- Fix: use responsive tables for management lists [@DlieBG](https://github.com/DlieBG) ([#6460](https://github.com/paperless-ngx/paperless-ngx/pull/6460))
- Fix: password reset done template [@shamoon](https://github.com/shamoon) ([#6444](https://github.com/paperless-ngx/paperless-ngx/pull/6444))
- Enhancement: refactor monetary field [@shamoon](https://github.com/shamoon) ([#6370](https://github.com/paperless-ngx/paperless-ngx/pull/6370))
- Enhancement: improve layout, button labels for custom fields dropdown [@shamoon](https://github.com/shamoon) ([#6362](https://github.com/paperless-ngx/paperless-ngx/pull/6362))
- Chore: Convert the consumer to a plugin [@stumpylog](https://github.com/stumpylog) ([#6361](https://github.com/paperless-ngx/paperless-ngx/pull/6361))
- Chore(deps): Bump the small-changes group with 11 updates [@dependabot](https://github.com/dependabot) ([#6405](https://github.com/paperless-ngx/paperless-ngx/pull/6405))
- Enhancement: Hide columns in document list if user does not have permissions [@theomega](https://github.com/theomega) ([#6415](https://github.com/paperless-ngx/paperless-ngx/pull/6415))
- Fix: show message on empty group list [@DlieBG](https://github.com/DlieBG) ([#6393](https://github.com/paperless-ngx/paperless-ngx/pull/6393))
- Fix: remove admin.logentry perm, use admin (staff) status [@shamoon](https://github.com/shamoon) ([#6380](https://github.com/paperless-ngx/paperless-ngx/pull/6380))
- Chore(deps): Bump tar from 6.2.0 to 6.2.1 in /src-ui [@dependabot](https://github.com/dependabot) ([#6373](https://github.com/paperless-ngx/paperless-ngx/pull/6373))
- Fix: dont dismiss active alerts on dismiss completed [@shamoon](https://github.com/shamoon) ([#6364](https://github.com/paperless-ngx/paperless-ngx/pull/6364))
- Fix: Allow lowercase letters in monetary currency code field [@shamoon](https://github.com/shamoon) ([#6359](https://github.com/paperless-ngx/paperless-ngx/pull/6359))
- Fix: Allow negative monetary values with a current code [@stumpylog](https://github.com/stumpylog) ([#6358](https://github.com/paperless-ngx/paperless-ngx/pull/6358))
</details>
## paperless-ngx 2.7.2
### Bug Fixes

View File

@@ -177,13 +177,13 @@ configure their endpoints, and enable the feature.
#### [`PAPERLESS_TIKA_ENDPOINT=<url>`](#PAPERLESS_TIKA_ENDPOINT) {#PAPERLESS_TIKA_ENDPOINT}
: Set the endpoint URL where Paperless can reach your Tika server.
: Set the endpoint URL were Paperless can reach your Tika server.
Defaults to "<http://localhost:9998>".
#### [`PAPERLESS_TIKA_GOTENBERG_ENDPOINT=<url>`](#PAPERLESS_TIKA_GOTENBERG_ENDPOINT) {#PAPERLESS_TIKA_GOTENBERG_ENDPOINT}
: Set the endpoint URL where Paperless can reach your Gotenberg server.
: Set the endpoint URL were Paperless can reach your Gotenberg server.
Defaults to "<http://localhost:3000>".
@@ -202,7 +202,7 @@ and watch out for indentation if editing the YAML file.
#### [`PAPERLESS_CONSUMPTION_DIR=<path>`](#PAPERLESS_CONSUMPTION_DIR) {#PAPERLESS_CONSUMPTION_DIR}
: This is where your documents should go to be consumed. Make sure that
: This where your documents should go to be consumed. Make sure that
it exists and that the user running the paperless service can
read/write its contents before you start Paperless.
@@ -219,10 +219,10 @@ database, classification model, etc).
Defaults to "../data/", relative to the "src" directory.
#### [`PAPERLESS_EMPTY_TRASH_DIR=<path>`](#PAPERLESS_EMPTY_TRASH_DIR) {#PAPERLESS_EMPTY_TRASH_DIR}
#### [`PAPERLESS_TRASH_DIR=<path>`](#PAPERLESS_TRASH_DIR) {#PAPERLESS_TRASH_DIR}
: When documents are deleted (e.g. after emptying the trash) the original files will be moved here
instead of being removed from the filesystem. Only the original version is kept.
: Instead of removing deleted documents, they are moved to this
directory.
This must be writeable by the user running paperless. When running
inside docker, ensure that this path is within a permanent volume
@@ -230,9 +230,7 @@ instead of being removed from the filesystem. Only the original version is kept.
Note that the directory must exist prior to using this setting.
Defaults to empty (i.e. really delete files).
This setting was previously named PAPERLESS_TRASH_DIR.
Defaults to empty (i.e. really delete documents).
#### [`PAPERLESS_MEDIA_ROOT=<path>`](#PAPERLESS_MEDIA_ROOT) {#PAPERLESS_MEDIA_ROOT}
@@ -290,12 +288,6 @@ this folder is no longer needed and can be removed manually.
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
#### [`PAPERLESS_LOGROTATE_MAX_SIZE=<num>`](#PAPERLESS_LOGROTATE_MAX_SIZE) {#PAPERLESS_LOGROTATE_MAX_SIZE}
@@ -624,8 +616,6 @@ parsing documents.
Keep in mind that Tesseract uses much more CPU time with multiple
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".
!!! note
@@ -1096,7 +1086,7 @@ document text will be checked as normal.
: Paperless searches an entire document for dates. The first date
found will be used as the initial value for the created date. When
this variable is greater than 0 (or left to its default value),
this variable is greater than 0 (or left to it's default value),
paperless will also suggest other dates found in the document, up to
a maximum of this setting. Note that duplicates will be removed,
which can result in fewer dates displayed in the frontend than this
@@ -1121,11 +1111,11 @@ This font can be changed here.
#### [`PAPERLESS_IGNORE_DATES=<string>`](#PAPERLESS_IGNORE_DATES) {#PAPERLESS_IGNORE_DATES}
: Paperless parses a document's creation date from filename and file
: Paperless parses a documents creation date from filename and file
content. You may specify a comma separated list of dates that should
be ignored during this process. This is useful for special dates
(like date of birth) that appear in documents regularly but are very
unlikely to be the document's creation date.
unlikely to be the documents creation date.
The date is parsed using the order specified in PAPERLESS_DATE_ORDER
@@ -1252,7 +1242,7 @@ barcode.
: Defines the upscale factor used in barcode detection.
Improves the detection of small barcodes, i.e. with a value of 1.5 by
upscaling the document before the detection process. Upscaling will
upscaling the document beforce the detection process. Upscaling will
only take place if value is bigger than 1.0. Otherwise upscaling will
not be performed to save resources. Try using in combination with
PAPERLESS_CONSUMER_BARCODE_DPI set to a value higher than default.
@@ -1286,7 +1276,7 @@ assigns or creates tags if a properly formatted barcode is detected.
: Defines a dictionary of filter regex and substitute expressions.
Syntax: `{"<regex>": "<substitute>" [,...]]}`
Syntax: {"<regex>": "<substitute>" [,...]]}
A barcode is considered for tagging if the barcode text matches
at least one of the provided <regex> pattern.
@@ -1298,20 +1288,20 @@ assigns or creates tags if a properly formatted barcode is detected.
Defaults to:
`{"TAG:(.*)": "\\g<1>"}` which defines
{"TAG:(.*)": "\\g<1>"} which defines
- a regex TAG:(.*) which includes barcodes beginning with TAG:
followed by any text that gets stored into match group #1 and
- a substitute `\\g<1>` that replaces the original barcode text
- a substitute \\g<1> that replaces the original barcode text
by the content in match group #1.
Consequently, the tag is the barcode text without its TAG: prefix.
More examples:
`{"ASN12.*": "JOHN", "ASN13.*": "SMITH"}` for example maps
{"ASN12.*": "JOHN", "ASN13.*": "SMITH"} for example maps
- ASN12nnnn barcodes to the tag JOHN and
- ASN13nnnn barcodes to the tag SMITH.
`{"T-J": "JOHN", "T-S": "SMITH", "T-D": "DOE"}` directly maps
{"T-J": "JOHN", "T-S": "SMITH", "T-D": "DOE"} directly maps
- T-J barcodes to the tag JOHN,
- T-S barcodes to the tag SMITH and
- T-D barcodes to the tag DOE.
@@ -1364,20 +1354,6 @@ processing. This only has an effect if
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
There are a few external software packages that Paperless expects to
@@ -1477,7 +1453,7 @@ specified as "chi-tra".
PAPERLESS_OCR_LANGUAGES=tur ces chi-tra
```
Make sure it's a space-separated list when using several values.
Make sure it's a space separated list when using several values.
To actually use these languages, also set the default OCR language
of paperless:
@@ -1508,7 +1484,7 @@ started by the container.
## Frontend Settings
#### [`PAPERLESS_APP_TITLE=<str>`](#PAPERLESS_APP_TITLE) {#PAPERLESS_APP_TITLE}
#### [`PAPERLESS_APP_TITLE=<bool>`](#PAPERLESS_APP_TITLE) {#PAPERLESS_APP_TITLE}
: If set, overrides the default name "Paperless-ngx"

View File

@@ -47,7 +47,7 @@ early on.
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.
You'll need to look at the output and fix the issue. Some hooks, such
as the Python linting and formatting tool `ruff`, will format failing
as the Python formatting tool `black`, will format failing
files, so all you need to do is `git add` those files again
and retry your commit.
@@ -81,6 +81,10 @@ first-time setup.
!!! note
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:

View File

@@ -6,7 +6,6 @@ You can go multiple routes to setup and run Paperless:
- [Pull the image from Docker Hub](#docker_hub)
- [Build the Docker image yourself](#docker_build)
- [Install Paperless directly on your system manually (bare metal)](#bare_metal)
- A user-maintained list of commercial hosting providers can be found [in the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects)
The Docker routes are quick & easy. These are the recommended routes.
This configures all the stuff from the above automatically so that it
@@ -250,14 +249,9 @@ a minimal installation of Debian/Buster, which is the current stable
release at the time of writing. Windows is not and will never be
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.
- `python3`
- `python3` - 3.9 - 3.11 are supported
- `python3-pip`
- `python3-dev`
- `default-libmysqlclient-dev` for MariaDB
@@ -305,17 +299,8 @@ are released, dependency support is confirmed, etc.
- `libatlas-base-dev`
- `libxslt1-dev`
You will also need these 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
```
You will also need `build-essential`, `python3-setuptools` and
`python3-wheel` for installing some of the python dependencies.
2. Install `redis` >= 6.0 and configure it to start automatically.
@@ -415,7 +400,8 @@ are released, dependency support is confirmed, etc.
sudo chown paperless:paperless /opt/paperless/consume
```
8. Install python requirements from the `requirements.txt` file.
8. Install python requirements from the `requirements.txt` file. It is
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
sudo -Hu paperless pip3 install -r requirements.txt
@@ -424,12 +410,6 @@ are released, dependency support is confirmed, etc.
This will install all python dependencies in the home directory of
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:
```bash
@@ -686,37 +666,24 @@ commands as well.
1. Stop and remove the paperless container
2. If using an external database, stop the container
3. Update Redis configuration
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
and continue to step 4.
1. Otherwise, in the `docker-compose.yml` add a new service for
Redis, following [the example compose
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
a) If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
and continue to step 4.
b) Otherwise, in the `docker-compose.yml` add a new service for
Redis, following [the example compose
files](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
c) Set the environment variable [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) so it points to
the new Redis container
4. Update user mapping
1. If set, change the environment variable `PUID` to `USERMAP_UID`
1. If set, change the environment variable `PGID` to `USERMAP_GID`
a) If set, change the environment variable `PUID` to `USERMAP_UID`
b) If set, change the environment variable `PGID` to `USERMAP_GID`
5. Update configuration paths
1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`
a) Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`
6. Update media paths
1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
`/data/media`
a) Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
`/data/media`
7. Update timezone
1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
value as `TZ`
a) 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
`ghcr.io/paperless-ngx/paperless-ngx:latest` or a specific version
if preferred.
@@ -724,8 +691,95 @@ commands as well.
## Moving data from SQLite to PostgreSQL or MySQL/MariaDB {#sqlite_to_psql}
The best way to migrate between database types is to perform an [export](administration.md#exporter) and then
[import](administration.md#importer) into a clean installation of Paperless-ngx.
Moving your data from SQLite to PostgreSQL or MySQL/MariaDB is done via
executing a series of django management commands as below. The commands
below use PostgreSQL, but are applicable to MySQL/MariaDB with the
!!! warning
Make sure that your SQLite database is migrated to the latest version.
Starting paperless will make sure that this is the case. If your try to
load data from an old database schema in SQLite into a newer database
schema in PostgreSQL, you will run into trouble.
!!! warning
On some database fields, PostgreSQL enforces predefined limits on
maximum length, whereas SQLite does not. The fields in question are the
title of documents (128 characters), names of document types, tags and
correspondents (128 characters), and filenames (1024 characters). If you
have data in these fields that surpasses these limits, migration to
PostgreSQL is not possible and will fail with an error.
!!! warning
MySQL is case insensitive by default, treating values like "Name" and
"NAME" as identical. See [MySQL caveats](advanced_usage.md#mysql-caveats) for details.
!!! warning
MySQL also enforces limits on maximum lengths, but does so differently than
PostgreSQL. It may not be possible to migrate to MySQL due to this.
!!! warning
Using mariadb version 10.4+ is recommended. Using the `utf8mb3` character set on
an older system may fix issues that can arise while setting up Paperless-ngx but
`utf8mb3` can cause issues with consumption (where `utf8mb4` does not).
1. Stop paperless, if it is running.
2. Tell paperless to use PostgreSQL:
a) With docker, copy the provided `docker-compose.postgres.yml`
file to `docker-compose.yml`. Remember to adjust the consumption
directory, if necessary.
b) Without docker, configure the database in your `paperless.conf`
file. See [configuration](configuration.md) for
details.
3. Open a shell and initialize the database:
a) With docker, run the following command to open a shell within
the paperless container:
``` shell-session
$ cd /path/to/paperless
$ docker compose run --rm webserver /bin/bash
```
This will launch the container and initialize the PostgreSQL
database.
b) Without docker, remember to activate any virtual environment,
switch to the `src` directory and create the database schema:
``` shell-session
$ cd /path/to/paperless/src
$ python3 manage.py migrate
```
This will not copy any data yet.
4. Dump your data from SQLite:
```shell-session
$ python3 manage.py dumpdata --database=sqlite --exclude=contenttypes --exclude=auth.Permission > data.json
```
5. Load your data into PostgreSQL:
```shell-session
$ python3 manage.py loaddata data.json
```
6. If operating inside Docker, you may exit the shell now.
```shell-session
$ exit
```
7. Start paperless.
## Moving back to Paperless

View File

@@ -109,7 +109,7 @@ process.
### Mobile upload {#usage-mobile_upload}
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects) for a user-maintained list of related projects and
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Affiliated-Projects) for a user-maintained list of affiliated projects and
software (e.g. for mobile devices) that is compatible with Paperless-ngx.
### IMAP (Email) {#usage-email}
@@ -421,12 +421,13 @@ to optionally attach data to documents which does not fit in the existing set of
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.
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".
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
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.
!!! 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.
!!! note
@@ -461,16 +462,15 @@ Paperless-ngx added the ability to create shareable links to files in version 2.
## PDF Actions
Paperless-ngx supports four basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files):
Paperless-ngx supports 3 basic editing operations for PDFs (these operations 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.
- Splitting documents: available from an individual document's details page.
- Deleting pages: available from an individual document's details page.
- Splitting documents: available from an individual document's details page
!!! important
Note that rotation and deleting pages alter the Paperless-ngx _original_ file, which would, for example, invalidate a digital signature.
Note that rotation alters the Paperless-ngx _original_ file, which would, for example, invalidate a digital signature.
## Document History
@@ -478,15 +478,6 @@ 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'
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}
Paperless offers a couple tools that help you organize your document
@@ -559,16 +550,6 @@ collection.
## Searching {#basic-usage_searching}
### Global search
The top search bar in the web UI performs a "global" search of the various
objects Paperless-ngx uses, including documents, tags, workflows, etc. Only
objects for which the user has appropriate permissions are returned. For
documents, if there are < 3 results, "advanced" search results (which use
the document index) will also be included. This can be disabled under settings.
### Document searches
Paperless offers an extensive searching mechanism that is designed to
allow you to quickly find a document you're looking for (for example,
that thing that just broke and you bought a couple months ago, that
@@ -624,12 +605,6 @@ language](https://whoosh.readthedocs.io/en/latest/querylang.html). For
details on what date parsing utilities are available, see [Date
parsing](https://whoosh.readthedocs.io/en/latest/dates.html#parsing-date-queries).
## Keyboard shortcuts / hotkeys
A list of available hotkeys can be shown on any page using <kbd>Shift</kbd> +
<kbd>?</kbd>. The help dialog shows only the keys that are currently available
based on which area of Paperless-ngx you are using.
## The recommended workflow {#usage-recommended-workflow}
Once you have familiarized yourself with paperless and are ready to use

View File

@@ -37,11 +37,11 @@ def worker_int(worker):
id2name = {th.ident: th.name for th in threading.enumerate()}
code = []
for threadId, stack in sys._current_frames().items():
code.append(f"\n# Thread: {id2name.get(threadId, '')}({threadId})")
code.append("\n# Thread: %s(%d)" % (id2name.get(threadId, ""), threadId))
for filename, lineno, name, line in traceback.extract_stack(stack):
code.append(f'File: "{filename}", line {lineno}, in {name}')
code.append('File: "%s", line %d, in %s' % (filename, lineno, name))
if line:
code.append(f" {line.strip()}")
code.append(" %s" % (line.strip()))
worker.log.debug("\n".join(code))

View File

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

View File

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

View File

@@ -3,4 +3,4 @@
docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:15
docker run -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 9998:9998 -d docker.io/apache/tika:latest
docker run -p 9998:9998 -d ghcr.io/paperless-ngx/tika:latest

View File

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

View File

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

View File

@@ -45,8 +45,8 @@ test('basic filtering', async ({ page }) => {
test('text filtering', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
await page.goto('/documents')
await page.getByRole('main').getByRole('combobox').click()
await page.getByRole('main').getByRole('combobox').fill('test')
await page.getByRole('textbox').click()
await page.getByRole('textbox').fill('test')
await expect(page.locator('pngx-document-list')).toHaveText(/32 documents/)
await expect(page).toHaveURL(/title_content=test/)
await page.getByRole('button', { name: 'Title & content' }).click()
@@ -59,12 +59,12 @@ test('text filtering', async ({ page }) => {
await expect(page.locator('pngx-document-list')).toHaveText(/26 documents/)
await page.getByRole('button', { name: 'Advanced search' }).click()
await page.getByRole('button', { name: 'ASN' }).click()
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
await page.getByRole('textbox').fill('1123')
await expect(page).toHaveURL(/archive_serial_number=1123/)
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
await page.locator('select').selectOption('greater')
await page.getByRole('main').getByRole('combobox').nth(1).click()
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
await page.getByRole('textbox').click()
await page.getByRole('textbox').fill('1123')
await expect(page).toHaveURL(/archive_serial_number__gt=1123/)
await expect(page.locator('pngx-document-list')).toHaveText(/5 documents/)
await page.locator('select').selectOption('less')

View File

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

File diff suppressed because it is too large Load Diff

830
src-ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -85,7 +85,6 @@ const mock = () => {
}
}
Object.defineProperty(window, 'open', { value: jest.fn() })
Object.defineProperty(window, 'localStorage', { value: mock() })
Object.defineProperty(window, 'sessionStorage', { value: mock() })
Object.defineProperty(window, 'getComputedStyle', {

View File

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

View File

@@ -5,7 +5,8 @@ import {
fakeAsync,
tick,
} from '@angular/core/testing'
import { Router, RouterModule } from '@angular/router'
import { Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import { TourService, TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { Subject } from 'rxjs'
import { routes } from './app-routing.module'
@@ -20,10 +21,6 @@ import { ToastService, Toast } from './services/toast.service'
import { SettingsService } from './services/settings.service'
import { FileDropComponent } from './components/file-drop/file-drop.component'
import { NgxFileDropModule } from 'ngx-file-drop'
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
import { HotKeyService } from './services/hot-key.service'
import { PermissionsGuard } from './guards/permissions.guard'
import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard'
describe('AppComponent', () => {
let component: AppComponent
@@ -34,18 +31,16 @@ describe('AppComponent', () => {
let toastService: ToastService
let router: Router
let settingsService: SettingsService
let hotKeyService: HotKeyService
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [AppComponent, ToastsComponent, FileDropComponent],
providers: [PermissionsGuard, DirtySavedViewGuard],
providers: [],
imports: [
HttpClientTestingModule,
TourNgBootstrapModule,
RouterModule.forRoot(routes),
RouterTestingModule.withRoutes(routes),
NgxFileDropModule,
NgbModalModule,
],
}).compileComponents()
@@ -55,7 +50,6 @@ describe('AppComponent', () => {
settingsService = TestBed.inject(SettingsService)
toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router)
hotKeyService = TestBed.inject(HotKeyService)
fixture = TestBed.createComponent(AppComponent)
component = fixture.componentInstance
})
@@ -145,20 +139,4 @@ describe('AppComponent', () => {
fileStatusSubject.next(new FileStatus())
expect(toastSpy).toHaveBeenCalled()
})
it('should support hotkeys', () => {
const addShortcutSpy = jest.spyOn(hotKeyService, 'addShortcut')
const routerSpy = jest.spyOn(router, 'navigate')
// prevent actual navigation
routerSpy.mockReturnValue(new Promise(() => {}))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
component.ngOnInit()
expect(addShortcutSpy).toHaveBeenCalled()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'h' }))
expect(routerSpy).toHaveBeenCalledWith(['/dashboard'])
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'd' }))
expect(routerSpy).toHaveBeenCalledWith(['/documents'])
document.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }))
expect(routerSpy).toHaveBeenCalledWith(['/settings'])
})
})

View File

@@ -12,7 +12,6 @@ import {
PermissionsService,
PermissionType,
} from './services/permissions.service'
import { HotKeyService } from './services/hot-key.service'
@Component({
selector: 'pngx-root',
@@ -32,11 +31,8 @@ export class AppComponent implements OnInit, OnDestroy {
private tasksService: TasksService,
public tourService: TourService,
private renderer: Renderer2,
private permissionsService: PermissionsService,
private hotKeyService: HotKeyService
private permissionsService: PermissionsService
) {
let anyWindow = window as any
anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js'
this.settings.updateAppearanceSettings()
}
@@ -127,36 +123,6 @@ export class AppComponent implements OnInit, OnDestroy {
}
})
this.hotKeyService
.addShortcut({ keys: 'h', description: $localize`Dashboard` })
.subscribe(() => {
this.router.navigate(['/dashboard'])
})
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Document
)
) {
this.hotKeyService
.addShortcut({ keys: 'd', description: $localize`Documents` })
.subscribe(() => {
this.router.navigate(['/documents'])
})
}
if (
this.permissionsService.currentUserCan(
PermissionAction.Change,
PermissionType.UISettings
)
) {
this.hotKeyService
.addShortcut({ keys: 's', description: $localize`Settings` })
.subscribe(() => {
this.router.navigate(['/settings'])
})
}
const prevBtnTitle = $localize`Prev`
const nextBtnTitle = $localize`Next`
const endBtnTitle = $localize`End`

View File

@@ -105,7 +105,7 @@ import { CustomFieldsComponent } from './components/manage/custom-fields/custom-
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
import { PdfViewerModule } from 'ng2-pdf-viewer'
import { PdfViewerComponent } from './components/common/pdf-viewer/pdf-viewer.component'
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
import { PreviewPopupComponent } from './components/common/preview-popup/preview-popup.component'
import { SwitchComponent } from './components/common/input/switch/switch.component'
@@ -122,10 +122,6 @@ import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/
import { DocumentHistoryComponent } from './components/document-history/document-history.component'
import { DragDropSelectComponent } from './components/common/input/drag-drop-select/drag-drop-select.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 { 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 {
airplane,
archive,
@@ -162,13 +158,11 @@ import {
clipboardCheckFill,
clipboardFill,
dash,
dashCircle,
diagram3,
dice5,
doorOpen,
download,
envelope,
envelopeAt,
exclamationCircleFill,
exclamationTriangle,
exclamationTriangleFill,
@@ -177,7 +171,6 @@ import {
fileEarmarkCheck,
fileEarmarkFill,
fileEarmarkLock,
fileEarmarkMinus,
files,
fileText,
filter,
@@ -203,7 +196,6 @@ import {
personFill,
personFillLock,
personLock,
personSquare,
plus,
plusCircle,
questionCircle,
@@ -214,7 +206,6 @@ import {
sortAlphaDown,
sortAlphaUpAlt,
tagFill,
tag,
tags,
textIndentLeft,
textLeft,
@@ -263,13 +254,11 @@ const icons = {
clipboardCheckFill,
clipboardFill,
dash,
dashCircle,
diagram3,
dice5,
doorOpen,
download,
envelope,
envelopeAt,
exclamationCircleFill,
exclamationTriangle,
exclamationTriangleFill,
@@ -278,7 +267,6 @@ const icons = {
fileEarmarkCheck,
fileEarmarkFill,
fileEarmarkLock,
fileEarmarkMinus,
files,
fileText,
filter,
@@ -304,7 +292,6 @@ const icons = {
personFill,
personFillLock,
personLock,
personSquare,
plus,
plusCircle,
questionCircle,
@@ -315,7 +302,6 @@ const icons = {
sortAlphaDown,
sortAlphaUpAlt,
tagFill,
tag,
tags,
textIndentLeft,
textLeft,
@@ -481,6 +467,7 @@ function initializeApp(settings: SettingsService) {
CustomFieldEditDialogComponent,
CustomFieldsDropdownComponent,
ProfileEditDialogComponent,
PdfViewerComponent,
DocumentLinkComponent,
PreviewPopupComponent,
SwitchComponent,
@@ -495,10 +482,6 @@ function initializeApp(settings: SettingsService) {
DocumentHistoryComponent,
DragDropSelectComponent,
CustomFieldDisplayComponent,
GlobalSearchComponent,
HotkeyDialogComponent,
DeletePagesConfirmDialogComponent,
TrashComponent,
],
imports: [
BrowserModule,
@@ -507,7 +490,6 @@ function initializeApp(settings: SettingsService) {
HttpClientModule,
FormsModule,
ReactiveFormsModule,
PdfViewerModule,
NgxFileDropModule,
NgSelectModule,
ColorSliderModule,

View File

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

View File

@@ -192,35 +192,11 @@
<div class="row mb-3">
<div class="offset-md-3 col">
<pngx-input-check i18n-title title="Show confirmation dialogs" formControlName="bulkEditConfirmationDialogs"></pngx-input-check>
<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="Apply on close" formControlName="bulkEditApplyOnClose"></pngx-input-check>
</div>
</div>
<h4 class="mt-4" i18n>Global search</h4>
<div class="row mb-3">
<div class="offset-md-3 col">
<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>
<h4 class="mt-4" i18n>Notes</h4>
<div class="row mb-3">

View File

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

View File

@@ -27,7 +27,7 @@ import {
} from 'rxjs'
import { Group } from 'src/app/data/group'
import { SavedView } from 'src/app/data/saved-view'
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import {
@@ -100,8 +100,6 @@ export class SettingsComponent
defaultPermsEditUsers: new FormControl(null),
defaultPermsEditGroups: new FormControl(null),
documentEditingRemoveInboxTags: new FormControl(null),
searchDbOnly: new FormControl(null),
searchLink: new FormControl(null),
notificationsConsumerNewDocument: new FormControl(null),
notificationsConsumerSuccess: new FormControl(null),
@@ -130,8 +128,6 @@ export class SettingsComponent
public systemStatus: SystemStatus
public readonly GlobalSearchType = GlobalSearchType
get systemStatusHasErrors(): boolean {
return (
this.systemStatus.database.status === SystemStatusItemStatus.ERROR ||
@@ -308,8 +304,6 @@ export class SettingsComponent
documentEditingRemoveInboxTags: this.settings.get(
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
),
searchDbOnly: this.settings.get(SETTINGS_KEYS.SEARCH_DB_ONLY),
searchLink: this.settings.get(SETTINGS_KEYS.SEARCH_FULL_TYPE),
savedViews: {},
}
}
@@ -539,14 +533,6 @@ export class SettingsComponent
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS,
this.settingsForm.value.documentEditingRemoveInboxTags
)
this.settings.set(
SETTINGS_KEYS.SEARCH_DB_ONLY,
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
.storeSettings()

View File

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

View File

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

View File

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

View File

@@ -24,10 +24,19 @@
}
</div>
</a>
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1">
<div class="col-12 col-md-7">
<pngx-global-search></pngx-global-search>
</div>
<div class="search-form-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<form (ngSubmit)="search()" class="form-inline flex-grow-1">
<i-bs width="1em" height="1em" name="search"></i-bs>
<input class="form-control form-control-sm" type="text" placeholder="Search documents" aria-label="Search"
[formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (keyup)="searchFieldKeyup($event)"
(selectItem)="itemSelected($event)" i18n-placeholder>
@if (!searchFieldEmpty) {
<button type="button" class="btn btn-link btn-sm ps-0 pe-1 position-absolute top-0 end-0" (click)="resetSearchField()">
<i-bs width="1em" height="1em" name="x"></i-bs>
</button>
}
</form>
</div>
<ul ngbNav class="order-sm-3">
<li ngbDropdown class="nav-item dropdown">
@@ -267,13 +276,6 @@
</a>
</li>
}
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="trash"></i-bs><span>&nbsp;<ng-container i18n>Trash</ng-container></span>
</a>
</li>
<li class="nav-item mt-2" tourAnchor="tour.outro">
<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"

View File

@@ -257,6 +257,59 @@ main {
}
}
.navbar .search-form-container {
max-width: 550px;
form {
position: relative;
> i-bs {
position: absolute;
left: 0.6rem;
top: .35rem;
color: rgba(255, 255, 255, 0.6);
@media screen and (min-width: 768px) {
// adjust for smaller font size on non-mobile
top: 0.25rem;
}
}
}
&:focus-within {
form > i-bs {
display: none;
}
.form-control::placeholder {
color: rgba(255, 255, 255, 0);
}
}
.form-control {
color: rgba(255, 255, 255, 0.3);
background-color: rgba(0, 0, 0, 0.15);
padding-left: 1.8rem;
border-color: rgba(255, 255, 255, 0.2);
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
max-width: 600px;
min-width: 300px; // 1/2 max
&::placeholder {
color: rgba(255, 255, 255, 0.4);
}
&:focus {
background-color: rgba(0, 0, 0, 0.3);
color: var(--bs-light);
flex-grow: 1;
padding-left: 0.5rem;
}
}
}
.version-check {
animation: pulse 2s ease-in-out 0s 1;
}

View File

@@ -30,13 +30,14 @@ import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { ActivatedRoute, Router } from '@angular/router'
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { SearchService } from 'src/app/services/rest/search.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'
import { SavedView } from 'src/app/data/saved-view'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { GlobalSearchComponent } from './global-search/global-search.component'
const saved_views = [
{
@@ -88,17 +89,15 @@ describe('AppFrameComponent', () => {
let toastService: ToastService
let messagesService: DjangoMessagesService
let openDocumentsService: OpenDocumentsService
let searchService: SearchService
let documentListViewService: DocumentListViewService
let router: Router
let savedViewSpy
let modalService: NgbModal
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
AppFrameComponent,
IfPermissionsDirective,
GlobalSearchComponent,
],
declarations: [AppFrameComponent, IfPermissionsDirective],
imports: [
HttpClientTestingModule,
BrowserModule,
@@ -160,6 +159,8 @@ describe('AppFrameComponent', () => {
toastService = TestBed.inject(ToastService)
messagesService = TestBed.inject(DjangoMessagesService)
openDocumentsService = TestBed.inject(OpenDocumentsService)
searchService = TestBed.inject(SearchService)
documentListViewService = TestBed.inject(DocumentListViewService)
modalService = TestBed.inject(NgbModal)
router = TestBed.inject(Router)
@@ -295,6 +296,62 @@ describe('AppFrameComponent', () => {
expect(component.canDeactivate()).toBeFalsy()
})
it('should call autocomplete endpoint on input', fakeAsync(() => {
const autocompleteSpy = jest.spyOn(searchService, 'autocomplete')
component.searchAutoComplete(of('hello')).subscribe()
tick(250)
expect(autocompleteSpy).toHaveBeenCalled()
component.searchAutoComplete(of('hello world 1')).subscribe()
tick(250)
expect(autocompleteSpy).toHaveBeenCalled()
}))
it('should handle autocomplete backend failure gracefully', fakeAsync(() => {
const serviceAutocompleteSpy = jest.spyOn(searchService, 'autocomplete')
serviceAutocompleteSpy.mockReturnValue(
throwError(() => new Error('autcomplete failed'))
)
// serviceAutocompleteSpy.mockReturnValue(of([' world']))
let result
component.searchAutoComplete(of('hello')).subscribe((res) => {
result = res
})
tick(250)
expect(serviceAutocompleteSpy).toHaveBeenCalled()
expect(result).toEqual([])
}))
it('should support reset search field', () => {
const resetSpy = jest.spyOn(component, 'resetSearchField')
const input = (fixture.nativeElement as HTMLDivElement).querySelector(
'input'
) as HTMLInputElement
input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }))
expect(resetSpy).toHaveBeenCalled()
})
it('should support choosing a search item', () => {
expect(component.searchField.value).toEqual('')
component.itemSelected({ item: 'hello', preventDefault: () => true })
expect(component.searchField.value).toEqual('hello ')
component.itemSelected({ item: 'world', preventDefault: () => true })
expect(component.searchField.value).toEqual('hello world ')
})
it('should navigate via quickFilter on search', () => {
const str = 'hello world '
component.searchField.patchValue(str)
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.search()
expect(qfSpy).toHaveBeenCalledWith([
{
rule_type: FILTER_FULLTEXT_QUERY,
value: str.trim(),
},
])
})
it('should disable global dropzone on start drag + drop, re-enable after', () => {
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
component.onDragStart(null)

View File

@@ -1,7 +1,15 @@
import { Component, HostListener, OnInit } from '@angular/core'
import { FormControl } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
import { Observable } from 'rxjs'
import { first } from 'rxjs/operators'
import { from, Observable } from 'rxjs'
import {
debounceTime,
distinctUntilChanged,
map,
switchMap,
first,
catchError,
} from 'rxjs/operators'
import { Document } from 'src/app/data/document'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import {
@@ -9,8 +17,11 @@ import {
DjangoMessagesService,
} from 'src/app/services/django-messages.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SearchService } from 'src/app/services/rest/search.service'
import { environment } from 'src/environments/environment'
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
import {
RemoteVersionService,
AppRemoteVersion,
@@ -35,7 +46,6 @@ import {
} from '@angular/cdk/drag-drop'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { ObjectWithId } from 'src/app/data/object-with-id'
@Component({
selector: 'pngx-app-frame',
@@ -53,12 +63,16 @@ export class AppFrameComponent
slimSidebarAnimating: boolean = false
searchField = new FormControl('')
constructor(
public router: Router,
private activatedRoute: ActivatedRoute,
private openDocumentsService: OpenDocumentsService,
private searchService: SearchService,
public savedViewService: SavedViewService,
private remoteVersionService: RemoteVersionService,
private list: DocumentListViewService,
public settingsService: SettingsService,
public tasksService: TasksService,
private readonly toastService: ToastService,
@@ -150,6 +164,65 @@ export class AppFrameComponent
return !this.openDocumentsService.hasDirty()
}
get searchFieldEmpty(): boolean {
return this.searchField.value.trim().length == 0
}
resetSearchField() {
this.searchField.reset('')
}
searchFieldKeyup(event: KeyboardEvent) {
if (event.key == 'Escape') {
this.resetSearchField()
}
}
searchAutoComplete = (text$: Observable<string>) =>
text$.pipe(
debounceTime(200),
distinctUntilChanged(),
map((term) => {
if (term.lastIndexOf(' ') != -1) {
return term.substring(term.lastIndexOf(' ') + 1)
} else {
return term
}
}),
switchMap((term) =>
term.length < 2
? from([[]])
: this.searchService.autocomplete(term).pipe(
catchError(() => {
return from([[]])
})
)
)
)
itemSelected(event) {
event.preventDefault()
let currentSearch: string = this.searchField.value
let lastSpaceIndex = currentSearch.lastIndexOf(' ')
if (lastSpaceIndex != -1) {
currentSearch = currentSearch.substring(0, lastSpaceIndex + 1)
currentSearch += event.item + ' '
} else {
currentSearch = event.item + ' '
}
this.searchField.patchValue(currentSearch)
}
search() {
this.closeMenu()
this.list.quickFilter([
{
rule_type: FILTER_FULLTEXT_QUERY,
value: (this.searchField.value as string).trim(),
},
])
}
closeDocument(d: Document) {
this.openDocumentsService
.closeDocument(d)

View File

@@ -1,174 +0,0 @@
<div ngbDropdown #resultsDropdown="ngbDropdown" (openChange)="onDropdownOpenChange">
<form class="form-inline position-relative">
<i-bs width="1em" height="1em" name="search"></i-bs>
<div class="input-group">
<div class="form-control form-control-sm">
<input class="bg-transparent border-0 w-100 h-100" #searchInput type="text" name="query"
placeholder="Search" aria-label="Search" i18n-placeholder
autocomplete="off"
spellcheck="false"
[(ngModel)]="query"
(ngModelChange)="this.queryDebounce.next($event)"
(keydown)="searchInputKeyDown($event)"
ngbDropdownAnchor>
<div class="position-absolute top-50 end-0 translate-middle">
@if (loading) {
<div class="spinner-border spinner-border-sm text-muted mt-1"></div>
}
</div>
</div>
@if (query) {
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="runFullSearch()">
@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>
</button>
}
</div>
</form>
<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"
(click)="primaryAction(type, item, $event)"
(mouseenter)="onItemHover($event)">
<i-bs width="1.2em" height="1.2em" name="{{icon}}" class="me-2 text-muted"></i-bs>
<div class="text-truncate">
{{item[nameProp]}}
@if (date) {
<small class="small text-muted">{{date | customDate}}</small>
}
</div>
<div class="btn-group ms-auto">
<button #primaryButton type="button" class="btn btn-sm btn-outline-primary d-flex"
(click)="primaryAction(type, item, $event); $event.stopImmediatePropagation()"
(keydown)="onButtonKeyDown($event)"
[disabled]="disablePrimaryButton(type, item)"
(mouseenter)="onButtonHover($event)">
@if (type === DataType.Document) {
<i-bs width="1em" height="1em" name="pencil"></i-bs>
<span>&nbsp;<ng-container i18n>Open</ng-container></span>
} @else if (type === DataType.SavedView) {
<i-bs width="1em" height="1em" name="eye"></i-bs>
<span>&nbsp;<ng-container i18n>Open</ng-container></span>
} @else if (type === DataType.Workflow || type === DataType.CustomField || type === DataType.Group || type === DataType.User || type === DataType.MailAccount || type === DataType.MailRule) {
<i-bs width="1em" height="1em" name="pencil"></i-bs>
<span>&nbsp;<ng-container i18n>Edit</ng-container></span>
} @else {
<i-bs width="1em" height="1em" name="filter"></i-bs>
<span>&nbsp;<ng-container i18n>Filter documents</ng-container></span>
}
</button>
@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"
(click)="secondaryAction(type, item, $event); $event.stopImmediatePropagation()"
(keydown)="onButtonKeyDown($event)"
[disabled]="disableSecondaryButton(type, item)"
(mouseenter)="onButtonHover($event)">
@if (type === DataType.Document) {
<i-bs width="1em" height="1em" name="download"></i-bs>
<span>&nbsp;<ng-container i18n>Download</ng-container></span>
} @else {
<i-bs width="1em" height="1em" name="pencil"></i-bs>
<span>&nbsp;<ng-container i18n>Edit</ng-container></span>
}
</button>
}
</div>
</div>
</ng-template>
<div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg">
<div (keydown)="dropdownKeyDown($event)">
@if (searchResults?.total === 0) {
<h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
} @else {
@if (searchResults?.documents.length) {
<h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
@for (document of searchResults.documents; track document.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.added}"></ng-container>
}
}
@if (searchResults?.saved_views.length) {
<h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
@for (saved_view of searchResults.saved_views; track saved_view.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
}
}
@if (searchResults?.tags.length) {
<h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
@for (tag of searchResults.tags; track tag.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
}
}
@if (searchResults?.correspondents.length) {
<h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
@for (correspondent of searchResults.correspondents; track correspondent.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
}
}
@if (searchResults?.document_types.length) {
<h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
@for (documentType of searchResults.document_types; track documentType.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
}
}
@if (searchResults?.storage_paths.length) {
<h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
@for (storagePath of searchResults.storage_paths; track storagePath.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
}
}
@if (searchResults?.users.length) {
<h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
@for (user of searchResults.users; track user.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
}
}
@if (searchResults?.groups.length) {
<h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
@for (group of searchResults.groups; track group.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
}
}
@if (searchResults?.custom_fields.length) {
<h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
@for (customField of searchResults.custom_fields; track customField.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
}
}
@if (searchResults?.mail_accounts.length) {
<h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
@for (mailAccount of searchResults.mail_accounts; track mailAccount.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
}
}
@if (searchResults?.mail_rules.length) {
<h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
@for (mailRule of searchResults.mail_rules; track mailRule.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
}
}
@if (searchResults?.workflows.length) {
<h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
@for (workflow of searchResults.workflows; track workflow.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
}
}
}
</div>
</div>
</div>

View File

@@ -1,101 +0,0 @@
form {
position: relative;
> i-bs[name="search"] {
position: absolute;
left: 0.6rem;
top: .35rem;
color: rgba(255, 255, 255, 0.6);
@media screen and (min-width: 768px) {
// adjust for smaller font size on non-mobile
top: 0.25rem;
}
}
&:focus-within {
i-bs[name="search"],
.badge {
display: none !important;
}
.form-control::placeholder {
color: rgba(255, 255, 255, 0);
}
}
.badge {
font-size: 0.8rem;
}
.input-group .btn {
border-color: rgba(255, 255, 255, 0.2);
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 {
color: rgba(255, 255, 255, 0.3);
background-color: rgba(0, 0, 0, 0.15);
padding-left: 1.8rem;
border-color: rgba(255, 255, 255, 0.2);
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
> input {
outline: none;
color: var(--pngx-primary-text-contrast);
&::placeholder {
color: rgba(255, 255, 255, 0.4);
}
}
&:focus-within {
background-color: rgba(0, 0, 0, 0.3);
color: var(--bs-light);
flex-grow: 1;
padding-left: 0.5rem;
}
}
}
* {
--pngx-focus-alpha: 0;
}
.cursor-pointer {
cursor: pointer;
}
.mh-75 {
max-height: 75vh;
}
.dropdown-item {
&:has(button:focus) {
background-color: var(--pngx-bg-darker);
}
& button {
transition: all 0.3s ease, color 0.15s ease;
max-width: 2rem;
overflow: hidden;
}
& button span {
opacity: 0;
transition: inherit;
}
&:hover button,
&:has(button:focus) button {
max-width: 10rem;
}
&:hover button span,
&:has(button:focus) span {
opacity: 1;
}
}

View File

@@ -1,546 +0,0 @@
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { GlobalSearchComponent } from './global-search.component'
import { of } from 'rxjs'
import { SearchService } from 'src/app/services/rest/search.service'
import { Router } from '@angular/router'
import {
NgbDropdownModule,
NgbModal,
NgbModalModule,
NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
FILTER_FULLTEXT_QUERY,
FILTER_HAS_CORRESPONDENT_ANY,
FILTER_HAS_DOCUMENT_TYPE_ANY,
FILTER_HAS_STORAGE_PATH_ANY,
FILTER_HAS_TAGS_ALL,
FILTER_TITLE_CONTENT,
} from 'src/app/data/filter-rule-type'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service'
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
import { ElementRef } from '@angular/core'
import { ToastService } from 'src/app/services/toast.service'
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 = {
total: 11,
documents: [
{
id: 1,
title: 'Test',
created_at: new Date(),
updated_at: new Date(),
document_type: { id: 1, name: 'Test' },
storage_path: { id: 1, path: 'Test' },
tags: [],
correspondents: [],
custom_fields: [],
},
],
saved_views: [
{
id: 1,
name: 'TestSavedView',
},
],
correspondents: [
{
id: 1,
name: 'TestCorrespondent',
},
],
document_types: [
{
id: 1,
name: 'TestDocumentType',
},
],
storage_paths: [
{
id: 1,
name: 'TestStoragePath',
},
],
tags: [
{
id: 1,
name: 'TestTag',
},
],
users: [
{
id: 1,
username: 'TestUser',
},
],
groups: [
{
id: 1,
name: 'TestGroup',
},
],
mail_accounts: [
{
id: 1,
name: 'TestMailAccount',
},
],
mail_rules: [
{
id: 1,
name: 'TestMailRule',
},
],
custom_fields: [
{
id: 1,
name: 'TestCustomField',
},
],
workflows: [
{
id: 1,
name: 'TestWorkflow',
},
],
}
describe('GlobalSearchComponent', () => {
let component: GlobalSearchComponent
let fixture: ComponentFixture<GlobalSearchComponent>
let searchService: SearchService
let router: Router
let modalService: NgbModal
let documentService: DocumentService
let documentListViewService: DocumentListViewService
let toastService: ToastService
let settingsService: SettingsService
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [GlobalSearchComponent],
imports: [
HttpClientTestingModule,
NgbModalModule,
NgbDropdownModule,
FormsModule,
ReactiveFormsModule,
NgxBootstrapIconsModule.pick(allIcons),
],
}).compileComponents()
searchService = TestBed.inject(SearchService)
router = TestBed.inject(Router)
modalService = TestBed.inject(NgbModal)
documentService = TestBed.inject(DocumentService)
documentListViewService = TestBed.inject(DocumentListViewService)
toastService = TestBed.inject(ToastService)
settingsService = TestBed.inject(SettingsService)
fixture = TestBed.createComponent(GlobalSearchComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should handle keyboard nav', () => {
const focusSpy = jest.spyOn(component.searchInput.nativeElement, 'focus')
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
expect(focusSpy).toHaveBeenCalled()
component.searchResults = searchResults as any
component.resultsDropdown.open()
fixture.detectChanges()
component['currentItemIndex'] = 0
component['setCurrentItem']()
const firstItemFocusSpy = jest.spyOn(
component.primaryButtons.get(1).nativeElement,
'focus'
)
component.dropdownKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
)
expect(component['currentItemIndex']).toBe(1)
expect(firstItemFocusSpy).toHaveBeenCalled()
const secondaryItemFocusSpy = jest.spyOn(
component.secondaryButtons.get(1).nativeElement,
'focus'
)
component.dropdownKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowRight' })
)
expect(secondaryItemFocusSpy).toHaveBeenCalled()
component.dropdownKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowLeft' })
)
expect(firstItemFocusSpy).toHaveBeenCalled()
const zeroItemSpy = jest.spyOn(
component.primaryButtons.get(0).nativeElement,
'focus'
)
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
expect(component['currentItemIndex']).toBe(0)
expect(zeroItemSpy).toHaveBeenCalled()
const inputFocusSpy = jest.spyOn(
component.searchInput.nativeElement,
'focus'
)
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
expect(component['currentItemIndex']).toBe(-1)
expect(inputFocusSpy).toHaveBeenCalled()
component.dropdownKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
)
component['currentItemIndex'] = searchResults.total - 1
component['setCurrentItem']()
component.dropdownKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
)
expect(component['currentItemIndex']).toBe(-1)
// Search input
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowUp' })
)
expect(component['currentItemIndex']).toBe(searchResults.total - 1)
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
)
expect(component['currentItemIndex']).toBe(0)
component.searchResults = { total: 1 } as any
const primaryActionSpy = jest.spyOn(component, 'primaryAction')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(primaryActionSpy).toHaveBeenCalled()
component.query = 'test'
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'Escape' })
)
expect(resetSpy).toHaveBeenCalled()
component.query = ''
const blurSpy = jest.spyOn(component.searchInput.nativeElement, 'blur')
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'Escape' })
)
expect(blurSpy).toHaveBeenCalled()
component.searchResults = { total: 1 } as any
component.resultsDropdown.open()
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
)
expect(component['currentItemIndex']).toBe(0)
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }))
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(() => {
const query = 'test'
const searchSpy = jest.spyOn(searchService, 'globalSearch')
searchSpy.mockReturnValue(of({} as any))
const dropdownOpenSpy = jest.spyOn(component.resultsDropdown, 'open')
component.queryDebounce.next(query)
tick(401)
expect(searchSpy).toHaveBeenCalledWith(query)
expect(dropdownOpenSpy).toHaveBeenCalled()
}))
it('should support primary action', () => {
const object = { id: 1 }
const routerSpy = jest.spyOn(router, 'navigate')
const modalSpy = jest.spyOn(modalService, 'open')
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
component.primaryAction(DataType.Document, object)
expect(routerSpy).toHaveBeenCalledWith(['/documents', object.id], {})
component.primaryAction(DataType.SavedView, object)
expect(routerSpy).toHaveBeenCalledWith(['/view', object.id], {})
component.primaryAction(DataType.Correspondent, object)
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
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)
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
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)
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
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)
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
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)
expect(modalSpy).toHaveBeenCalledWith(UserEditDialogComponent, {
size: 'lg',
})
component.primaryAction(DataType.Group, object)
expect(modalSpy).toHaveBeenCalledWith(GroupEditDialogComponent, {
size: 'lg',
})
component.primaryAction(DataType.MailAccount, object)
expect(modalSpy).toHaveBeenCalledWith(MailAccountEditDialogComponent, {
size: 'xl',
})
component.primaryAction(DataType.MailRule, object)
expect(modalSpy).toHaveBeenCalledWith(MailRuleEditDialogComponent, {
size: 'xl',
})
component.primaryAction(DataType.CustomField, object)
expect(modalSpy).toHaveBeenCalledWith(CustomFieldEditDialogComponent, {
size: 'md',
})
component.primaryAction(DataType.Workflow, object)
expect(modalSpy).toHaveBeenCalledWith(WorkflowEditDialogComponent, {
size: 'xl',
})
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
// fail first
editDialog.failed.emit({ error: 'error creating item' })
expect(toastErrorSpy).toHaveBeenCalled()
// succeed
editDialog.succeeded.emit(true)
expect(toastInfoSpy).toHaveBeenCalled()
})
it('should support secondary action', () => {
const doc = searchResults.documents[0]
const openSpy = jest.spyOn(window, 'open')
component.secondaryAction('document', doc)
expect(openSpy).toHaveBeenCalledWith(documentService.getDownloadUrl(doc.id))
const correspondent = searchResults.correspondents[0]
const modalSpy = jest.spyOn(modalService, 'open')
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
component.secondaryAction(DataType.Correspondent, correspondent)
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
size: 'md',
})
component.secondaryAction(
DataType.DocumentType,
searchResults.document_types[0]
)
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
size: 'md',
})
component.secondaryAction(
DataType.StoragePath,
searchResults.storage_paths[0]
)
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
size: 'md',
})
component.secondaryAction(DataType.Tag, searchResults.tags[0])
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
size: 'md',
})
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
// fail first
editDialog.failed.emit({ error: 'error creating item' })
expect(toastErrorSpy).toHaveBeenCalled()
// succeed
editDialog.succeeded.emit(true)
expect(toastInfoSpy).toHaveBeenCalled()
})
it('should support reset', () => {
const debounce = jest.spyOn(component.queryDebounce, 'next')
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
component['reset'](true)
expect(debounce).toHaveBeenCalledWith(null)
expect(component.searchResults).toBeNull()
expect(component['currentItemIndex']).toBe(-1)
expect(closeSpy).toHaveBeenCalled()
})
it('should support focus current item', () => {
component.searchResults = searchResults as any
fixture.detectChanges()
const focusSpy = jest.spyOn(
component.primaryButtons.get(0).nativeElement,
'focus'
)
component['currentItemIndex'] = 0
component['setCurrentItem']()
expect(focusSpy).toHaveBeenCalled()
})
it('should reset on dropdown close', () => {
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
component.onDropdownOpenChange(false)
expect(resetSpy).toHaveBeenCalled()
})
it('should focus button on dropdown item hover', () => {
component.searchResults = searchResults as any
fixture.detectChanges()
const item: ElementRef = component.resultItems.first
const focusSpy = jest.spyOn(
component.primaryButtons.first.nativeElement,
'focus'
)
component.onItemHover({ currentTarget: item.nativeElement } as any)
expect(component['currentItemIndex']).toBe(0)
expect(focusSpy).toHaveBeenCalled()
})
it('should focus on button hover', () => {
const event = { currentTarget: { focus: jest.fn() } }
const focusSpy = jest.spyOn(event.currentTarget, 'focus')
component.onButtonHover(event as any)
expect(focusSpy).toHaveBeenCalled()
})
it('should support open in new window', () => {
const openSpy = jest.spyOn(window, 'open')
const event = new Event('click')
event['ctrlKey'] = true
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 title content search and advanced search', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.query = 'test'
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([
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
])
})
})

View File

@@ -1,416 +0,0 @@
import {
Component,
ViewChild,
ElementRef,
ViewChildren,
QueryList,
OnInit,
} from '@angular/core'
import { Router } from '@angular/router'
import { NgbDropdown, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { Subject, debounceTime, distinctUntilChanged, filter, map } from 'rxjs'
import {
FILTER_FULLTEXT_QUERY,
FILTER_HAS_CORRESPONDENT_ANY,
FILTER_HAS_DOCUMENT_TYPE_ANY,
FILTER_HAS_STORAGE_PATH_ANY,
FILTER_HAS_TAGS_ALL,
FILTER_TITLE_CONTENT,
} from 'src/app/data/filter-rule-type'
import { DataType } from 'src/app/data/datatype'
import { ObjectWithId } from 'src/app/data/object-with-id'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import {
PermissionsService,
PermissionAction,
} from 'src/app/services/permissions.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import {
GlobalSearchResult,
SearchService,
} from 'src/app/services/rest/search.service'
import { ToastService } from 'src/app/services/toast.service'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-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 { 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({
selector: 'pngx-global-search',
templateUrl: './global-search.component.html',
styleUrl: './global-search.component.scss',
})
export class GlobalSearchComponent implements OnInit {
public DataType = DataType
public query: string
public queryDebounce: Subject<string>
public searchResults: GlobalSearchResult
private currentItemIndex: number = -1
private domIndex: number = -1
public loading: boolean = false
@ViewChild('searchInput') searchInput: ElementRef
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
@ViewChildren('resultItem') resultItems: QueryList<ElementRef>
@ViewChildren('primaryButton') primaryButtons: QueryList<ElementRef>
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
get useAdvancedForFullSearch(): boolean {
return (
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
GlobalSearchType.ADVANCED
)
}
constructor(
public searchService: SearchService,
private router: Router,
private modalService: NgbModal,
private documentService: DocumentService,
private documentListViewService: DocumentListViewService,
private permissionsService: PermissionsService,
private toastService: ToastService,
private hotkeyService: HotKeyService,
private settingsService: SettingsService
) {
this.queryDebounce = new Subject<string>()
this.queryDebounce
.pipe(
debounceTime(400),
map((query) => query?.trim()),
filter((query) => !query?.length || query?.length > 2),
distinctUntilChanged()
)
.subscribe((text) => {
this.query = text
if (text) this.search(text)
})
}
public ngOnInit() {
this.hotkeyService
.addShortcut({ keys: '/', description: $localize`Global search` })
.subscribe(() => {
this.searchInput.nativeElement.focus()
})
}
private search(query: string) {
this.loading = true
this.searchService.globalSearch(query).subscribe((results) => {
this.searchResults = results
this.loading = false
this.resultsDropdown.open()
})
}
public primaryAction(
type: string,
object: ObjectWithId,
event: PointerEvent = null
) {
const newWindow = event?.metaKey || event?.ctrlKey
this.reset(true)
let filterRuleType: number
let editDialogComponent: any
let size: string = 'md'
switch (type) {
case DataType.Document:
this.navigateOrOpenInNewWindow(['/documents', object.id], newWindow)
return
case DataType.SavedView:
this.navigateOrOpenInNewWindow(['/view', object.id], newWindow)
return
case DataType.Correspondent:
filterRuleType = FILTER_HAS_CORRESPONDENT_ANY
break
case DataType.DocumentType:
filterRuleType = FILTER_HAS_DOCUMENT_TYPE_ANY
break
case DataType.StoragePath:
filterRuleType = FILTER_HAS_STORAGE_PATH_ANY
break
case DataType.Tag:
filterRuleType = FILTER_HAS_TAGS_ALL
break
case DataType.User:
editDialogComponent = UserEditDialogComponent
size = 'lg'
break
case DataType.Group:
editDialogComponent = GroupEditDialogComponent
size = 'lg'
break
case DataType.MailAccount:
editDialogComponent = MailAccountEditDialogComponent
size = 'xl'
break
case DataType.MailRule:
editDialogComponent = MailRuleEditDialogComponent
size = 'xl'
break
case DataType.CustomField:
editDialogComponent = CustomFieldEditDialogComponent
break
case DataType.Workflow:
editDialogComponent = WorkflowEditDialogComponent
size = 'xl'
break
}
if (filterRuleType) {
let params = paramsFromViewState({
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) {
const modalRef: NgbModalRef = this.modalService.open(
editDialogComponent,
{ size }
)
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`)
})
modalRef.componentInstance.failed.subscribe((e) => {
this.toastService.showError($localize`Error occurred saving object.`, e)
})
}
}
public secondaryAction(type: string, object: ObjectWithId) {
this.reset(true)
let editDialogComponent: any
let size: string = 'md'
switch (type) {
case DataType.Document:
window.open(this.documentService.getDownloadUrl(object.id))
break
case DataType.Correspondent:
editDialogComponent = CorrespondentEditDialogComponent
break
case DataType.DocumentType:
editDialogComponent = DocumentTypeEditDialogComponent
break
case DataType.StoragePath:
editDialogComponent = StoragePathEditDialogComponent
break
case DataType.Tag:
editDialogComponent = TagEditDialogComponent
break
}
if (editDialogComponent) {
const modalRef: NgbModalRef = this.modalService.open(
editDialogComponent,
{ size }
)
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`)
})
modalRef.componentInstance.failed.subscribe((e) => {
this.toastService.showError($localize`Error occurred saving object.`, e)
})
}
}
private reset(close: boolean = false) {
this.queryDebounce.next(null)
this.query = null
this.searchResults = null
this.currentItemIndex = -1
if (close) {
this.resultsDropdown.close()
}
}
private setCurrentItem() {
// QueryLists do not always reflect the current DOM order, so we need to find the actual element
// Yes, using some vanilla JS
const result: HTMLElement = this.resultItems.first.nativeElement.parentNode
.querySelectorAll('.dropdown-item')
.item(this.currentItemIndex)
this.domIndex = this.resultItems
.toArray()
.indexOf(this.resultItems.find((item) => item.nativeElement === result))
const item: ElementRef = this.primaryButtons.get(this.domIndex)
item.nativeElement.focus()
}
public onItemHover(event: MouseEvent) {
const item: ElementRef = this.resultItems
.toArray()
.find((item) => item.nativeElement === event.currentTarget)
this.currentItemIndex = this.resultItems.toArray().indexOf(item)
this.setCurrentItem()
}
public onButtonHover(event: MouseEvent) {
;(event.currentTarget as HTMLElement).focus()
}
public searchInputKeyDown(event: KeyboardEvent) {
if (
event.key === 'ArrowDown' &&
this.searchResults?.total &&
this.resultsDropdown.isOpen()
) {
event.preventDefault()
this.currentItemIndex = 0
this.setCurrentItem()
} else if (
event.key === 'ArrowUp' &&
this.searchResults?.total &&
this.resultsDropdown.isOpen()
) {
event.preventDefault()
this.currentItemIndex = this.searchResults.total - 1
this.setCurrentItem()
} else if (event.key === 'Enter') {
if (this.searchResults?.total === 1 && this.resultsDropdown.isOpen()) {
this.primaryButtons.first.nativeElement.click()
this.searchInput.nativeElement.blur()
} else if (this.query?.length) {
this.runFullSearch()
this.reset(true)
}
} else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
if (this.query?.length) {
this.reset(true)
} else {
this.searchInput.nativeElement.blur()
}
}
}
public dropdownKeyDown(event: KeyboardEvent) {
if (
this.searchResults?.total &&
this.resultsDropdown.isOpen() &&
document.activeElement !== this.searchInput.nativeElement
) {
if (event.key === 'ArrowDown') {
event.preventDefault()
event.stopImmediatePropagation()
if (this.currentItemIndex < this.searchResults.total - 1) {
this.currentItemIndex++
this.setCurrentItem()
} else {
this.searchInput.nativeElement.focus()
this.currentItemIndex = -1
}
} else if (event.key === 'ArrowUp') {
event.preventDefault()
event.stopImmediatePropagation()
if (this.currentItemIndex > 0) {
this.currentItemIndex--
this.setCurrentItem()
} else {
this.searchInput.nativeElement.focus()
this.currentItemIndex = -1
}
} else if (event.key === 'ArrowRight') {
event.preventDefault()
event.stopImmediatePropagation()
this.secondaryButtons.get(this.domIndex)?.nativeElement.focus()
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
event.stopImmediatePropagation()
this.primaryButtons.get(this.domIndex).nativeElement.focus()
} else if (event.key === 'Escape') {
event.preventDefault()
event.stopImmediatePropagation()
this.reset(true)
this.searchInput.nativeElement.focus()
}
}
}
public onButtonKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.target.dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
}
}
public onDropdownOpenChange(open: boolean) {
if (!open) {
this.reset()
}
}
public disablePrimaryButton(type: DataType, object: ObjectWithId): boolean {
if (
[
DataType.Workflow,
DataType.CustomField,
DataType.Group,
DataType.User,
].includes(type)
) {
return !this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
object
)
}
return false
}
public disableSecondaryButton(type: DataType, object: ObjectWithId): boolean {
if (DataType.Document === type) {
return false
}
return !this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
object
)
}
public runFullSearch() {
const ruleType = this.useAdvancedForFullSearch
? FILTER_FULLTEXT_QUERY
: FILTER_TITLE_CONTENT
this.documentListViewService.quickFilter([
{ rule_type: ruleType, value: this.query },
])
this.reset(true)
}
private navigateOrOpenInNewWindow(
commands: any,
newWindow: boolean = false,
extras: Object = {}
) {
if (newWindow) {
const url = this.router.serializeUrl(
this.router.createUrlTree(commands, extras)
)
window.open(url, '_blank')
} else {
this.router.navigate(commands, extras)
}
}
}

View File

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

View File

@@ -54,6 +54,26 @@ export class ConfirmDialogComponent {
confirmSubject: 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() {
this.confirmSubject?.next(false)
this.confirmSubject?.complete()

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,10 +27,6 @@
}
</select>
</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>
</div>
<div class="modal-footer">

View File

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

View File

@@ -21,19 +21,21 @@
</button>
</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) {
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be rotated.</p>
}
</div>
<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>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>

View File

@@ -13,12 +13,12 @@
<div class="input-group-text" i18n>of {{totalPages}}</div>
</div>
<div class="pdf-viewer-container w-100 mt-3">
<pdf-viewer [src]="pdfSrc" [(page)]="page"
<pngx-pdf-viewer [src]="pdfSrc" [(page)]="page"
[original-size]="false"
[zoom]="1"
zoom-scale="page-fit"
(after-load-complete)="pdfPreviewLoaded($event)">
</pdf-viewer>
</pngx-pdf-viewer>
</div>
</div>
<div class="col-4">
@@ -44,10 +44,6 @@
</ul>
</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 class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">

View File

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

View File

@@ -6,8 +6,7 @@ import { ReactiveFormsModule, FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service'
import { PdfViewerModule } from 'ng2-pdf-viewer'
import { of } from 'rxjs'
import { PdfViewerComponent } from '../../pdf-viewer/pdf-viewer.component'
describe('SplitConfirmDialogComponent', () => {
let component: SplitConfirmDialogComponent
@@ -16,14 +15,13 @@ describe('SplitConfirmDialogComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [SplitConfirmDialogComponent],
declarations: [SplitConfirmDialogComponent, PdfViewerComponent],
providers: [NgbActiveModal],
imports: [
HttpClientTestingModule,
NgxBootstrapIconsModule.pick(allIcons),
ReactiveFormsModule,
FormsModule,
PdfViewerModule,
],
}).compileComponents()
@@ -33,14 +31,6 @@ describe('SplitConfirmDialogComponent', () => {
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', () => {
component.totalPages = 5
component.page = 2

View File

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

View File

@@ -1,40 +1,25 @@
@if (field) {
@if (value?.toString().length > 0) {
<ng-template #nameTooltip>
<div class="d-flex flex-column text-light">
{{field.name}}
</div>
</ng-template>
@switch (field.data_type) {
@case (CustomFieldDataType.Monetary) {
<span [ngbTooltip]="nameTooltip">{{value | currency: currency}}</span>
}
@case (CustomFieldDataType.Date) {
<span [ngbTooltip]="nameTooltip">{{value | customDate}}</span>
}
@case (CustomFieldDataType.Url) {
<a [ngbTooltip]="nameTooltip" [href]="value" class="btn-link text-dark text-decoration-none" target="_blank">{{value}}</a>
}
@case (CustomFieldDataType.DocumentLink) {
<div [ngbTooltip]="nameTooltip" class="d-flex gap-1 flex-wrap">
@for (docId of value; track docId) {
<a routerLink="/documents/{{docId}}" class="badge bg-body text-primary" title="View" i18n-title>
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs>&nbsp;<span>{{ getDocumentTitle(docId) }}</span>
</a>
}
</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 {
<span [ngbTooltip]="nameTooltip">{{value}}</span>
}
@switch (field.data_type) {
@case (CustomFieldDataType.Monetary) {
<span>{{value | currency: currency}}</span>
}
@case (CustomFieldDataType.Date) {
<span>{{value | customDate}}</span>
}
@case (CustomFieldDataType.Url) {
<a [href]="value" class="btn-link text-dark text-decoration-none" target="_blank">{{value}}</a>
}
@case (CustomFieldDataType.DocumentLink) {
<div class="d-flex gap-1 flex-wrap">
@for (docId of value; track docId) {
<a routerLink="/documents/{{docId}}" class="badge bg-dark text-primary" title="View" i18n-title>
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs>&nbsp;<span>{{ getDocumentTitle(docId) }}</span>
</a>
}
</div>
}
@default {
<span>{{value}}</span>
}
} @else if (showNameIfEmpty) {
<span class="fst-italic text-muted">{{field.name}}</span>
}
}

View File

@@ -17,8 +17,8 @@ const document: Document = {
title: 'Doc 1',
custom_fields: [
{ field: 1, document: 1, created: null, value: 'Text value' },
{ field: 2, document: 1, created: null, value: 'USD100' },
{ field: 3, document: 1, created: null, value: [1, 2, 3] },
{ field: 2, document: 1, created: null, value: '100 USD' },
{ field: 3, document: 1, created: null, value: '1,2,3' },
],
}
@@ -86,16 +86,4 @@ describe('CustomFieldDisplayComponent', () => {
expect(title2).toEqual('Document 2')
expect(title3).toEqual('Document 3')
})
it('should fallback to default currency', () => {
component['defaultCurrencyCode'] = 'EUR' // mock default locale injection
component.fieldId = 2
component.document = {
id: 1,
title: 'Doc 1',
custom_fields: [{ field: 2, document: 1, created: null, value: '100' }],
}
expect(component.currency).toEqual('EUR')
expect(component.value).toEqual(100)
})
})

View File

@@ -1,12 +1,4 @@
import { getLocaleCurrencyCode } from '@angular/common'
import {
Component,
Inject,
Input,
LOCALE_ID,
OnDestroy,
OnInit,
} from '@angular/core'
import { Component, Input, OnDestroy, OnInit } from '@angular/core'
import { Subject, takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DisplayField, Document } from 'src/app/data/document'
@@ -49,9 +41,6 @@ export class CustomFieldDisplayComponent implements OnInit, OnDestroy {
this.fieldId = parseInt(key.replace(DisplayField.CUSTOM_FIELD, ''), 10)
}
@Input()
showNameIfEmpty: boolean = false
value: any
currency: string
@@ -62,14 +51,11 @@ export class CustomFieldDisplayComponent implements OnInit, OnDestroy {
private docLinkDocuments: Document[] = []
private unsubscribeNotifier: Subject<any> = new Subject()
private defaultCurrencyCode: any
constructor(
private customFieldService: CustomFieldsService,
private documentService: DocumentService,
@Inject(LOCALE_ID) currentLocale: string
private documentService: DocumentService
) {
this.defaultCurrencyCode = getLocaleCurrencyCode(currentLocale)
this.customFieldService.listAll().subscribe((r) => {
this.customFields = r.results
this.init()
@@ -89,8 +75,7 @@ export class CustomFieldDisplayComponent implements OnInit, OnDestroy {
(f) => f.field === this._fieldId
)?.value
if (this.value && this.field.data_type === CustomFieldDataType.Monetary) {
this.currency =
this.value.match(/([A-Z]{3})/)?.[0] ?? this.defaultCurrencyCode
this.currency = this.value.match(/([A-Z]{3})/)?.[0]
this.value = parseFloat(this.value.replace(this.currency, ''))
} else if (
this.value?.length &&
@@ -105,9 +90,7 @@ export class CustomFieldDisplayComponent implements OnInit, OnDestroy {
.getFew(this.value, { fields: 'id,title' })
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((result: Results<Document>) => {
this.docLinkDocuments = this.value.map((id) =>
result.results.find((d) => d.id === id)
)
this.docLinkDocuments = result.results
})
}

View File

@@ -1,27 +1,31 @@
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)">
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose()">
<button class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
<i-bs name="ui-radios"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Custom Fields</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
<div class="list-group list-group-flush" (keydown)="listKeyDown($event)">
<div class="list-group-item">
<div class="input-group input-group-sm">
<input class="form-control" type="text" [(ngModel)]="filterText" placeholder="Search fields" i18n-placeholder (keyup.enter)="listFilterEnter()" #listFilterTextInput>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<pngx-input-select
[items]="unusedFields"
bindLabel="name"
[(ngModel)]="field"
[placeholder]="placeholderText"
[notFoundText]="notFoundText"
[disableCreateNew]="!canCreateFields"
(createNew)="createField($event)"
[hideAddButton]="true"
bindValue="id">
</pngx-input-select>
<div class="btn-toolbar" role="toolbar">
<button class="btn btn-sm btn-outline-secondary me-auto" type="button" (click)="createField()" [disabled]="!canCreateFields">
<i-bs width="1em" height="1em" name="asterisk"></i-bs>&nbsp;<ng-container i18n>Create New Field</ng-container>
</button>
<button class="btn btn-sm btn-outline-primary" type="button" (click)="addField(); fieldDropdown.close()" [disabled]="field === undefined">
<i-bs width="1.2em" height="1.2em" name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add to document</ng-container>
</button>
</div>
</div>
@for (field of filteredFields; track field.id) {
<button class="list-group-item list-group-item-action bg-light" (click)="addField(field)" #button>
<small class="d-flex">{{field.name}} <small class="ms-auto text-muted">{{getDataTypeLabel(field.data_type)}}</small></small>
</button>
}
@if (!filterText?.length || filteredFields.length === 0) {
<button class="list-group-item list-group-item-action bg-light" (click)="createField(filterText)" [disabled]="!canCreateFields" #button>
<small>
<i-bs width=".9em" height=".9em" name="asterisk"></i-bs>&nbsp;<ng-container i18n>Create new field</ng-container>
</small>
</button>
}
</div>
</li>
</ul>
</div>
</div>

View File

@@ -1,5 +1,5 @@
.custom-fields-dropdown {
min-width: 300px;
min-width: 380px;
// correct position on mobile
@media (max-width: 575.98px) {
@@ -8,3 +8,13 @@
}
}
}
::ng-deep .custom-fields-dropdown .ng-select .ng-select-container .ng-value-container .ng-placeholder,
::ng-deep .custom-fields-dropdown .ng-select .ng-option,
::ng-deep .custom-fields-dropdown .ng-select .ng-select-container .ng-value-container .ng-value {
font-size: 0.875rem;
}
::ng-deep .custom-fields-dropdown .paperless-input-select .ng-select .ng-select-container .ng-value-container .ng-input {
top: 4px;
}

View File

@@ -1,9 +1,5 @@
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { CustomFieldsDropdownComponent } from './custom-fields-dropdown.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { ToastService } from 'src/app/services/toast.service'
@@ -75,33 +71,28 @@ describe('CustomFieldsDropdownComponent', () => {
let addedField
component.added.subscribe((f) => (addedField = f))
component.documentId = 11
component.addField({ field: fields[0].id } as any)
component.field = fields[0].id
component.addField()
expect(addedField).not.toBeUndefined()
})
it('should support filtering fields', () => {
const input = fixture.debugElement.query(By.css('input'))
input.nativeElement.value = 'Field 1'
input.triggerEventHandler('input', { target: input.nativeElement })
fixture.detectChanges()
expect(component.filteredFields.length).toEqual(1)
expect(component.filteredFields[0].name).toEqual('Field 1')
})
it('should clear field on open / close, updated unused fields', () => {
component.field = fields[1].id
component.onOpenClose()
expect(component.field).toBeUndefined()
it('should support update unused fields', () => {
component.existingFields = [{ field: fields[0].id } as any]
component['updateUnusedFields']()
expect(component['unusedFields'].length).toEqual(1)
expect(component['unusedFields'][0].name).toEqual('Field 2')
})
it('should support getting data type label', () => {
expect(component.getDataTypeLabel(CustomFieldDataType.Integer)).toEqual(
'Integer'
expect(component.unusedFields).toEqual(fields)
const updateSpy = jest.spyOn(
CustomFieldsDropdownComponent.prototype as any,
'updateUnusedFields'
)
component.existingFields = [{ field: fields[1].id } as any]
component.onOpenClose()
expect(updateSpy).toHaveBeenCalled()
expect(component.unusedFields).toEqual([fields[0]])
})
it('should support creating field, show error if necessary, then add', fakeAsync(() => {
it('should support creating field, show error if necessary', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
@@ -110,9 +101,8 @@ describe('CustomFieldsDropdownComponent', () => {
CustomFieldsDropdownComponent.prototype as any,
'getFields'
)
const addFieldSpy = jest.spyOn(component, 'addField')
const createButton = fixture.debugElement.queryAll(By.css('button'))[3]
const createButton = fixture.debugElement.queryAll(By.css('button'))[1]
createButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined()
@@ -125,11 +115,9 @@ describe('CustomFieldsDropdownComponent', () => {
// succeed
editDialog.succeeded.emit(fields[0])
tick(100)
expect(toastInfoSpy).toHaveBeenCalled()
expect(getFieldsSpy).toHaveBeenCalled()
expect(addFieldSpy).toHaveBeenCalled()
}))
})
it('should support creating field with name', () => {
let modal: NgbModalRef
@@ -140,106 +128,4 @@ describe('CustomFieldsDropdownComponent', () => {
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
expect(editDialog.object.name).toEqual('Foo bar')
})
it('should support arrow keyboard navigation', fakeAsync(() => {
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)
const itemButtons = Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll(
'.custom-fields-dropdown button'
)
).filter((b) => b.textContent.includes('Field'))
filterInputEl.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[0])
itemButtons[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[1])
itemButtons[1].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[0])
itemButtons[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
)
expect(document.activeElement).toEqual(filterInputEl)
filterInputEl.value = 'foo'
component.filterText = 'foo'
// dont move focus if we're traversing the field
filterInputEl.selectionStart = 1
expect(document.activeElement).toEqual(filterInputEl)
// now we're at end, so move focus
filterInputEl.selectionStart = 3
filterInputEl.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[0])
}))
it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)
const itemButtons = Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll(
'.custom-fields-dropdown button'
)
).filter((b) => b.textContent.includes('Field'))
filterInputEl.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })
)
itemButtons[0]['focus']() // normally handled by browser
itemButtons[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })
)
itemButtons[1]['focus']() // normally handled by browser
itemButtons[1].dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: true,
bubbles: true,
})
)
itemButtons[0]['focus']() // normally handled by browser
itemButtons[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[1])
}))
it('should support enter keyboard navigation', fakeAsync(() => {
jest.spyOn(component, 'canCreateFields', 'get').mockReturnValue(true)
const addFieldSpy = jest.spyOn(component, 'addField')
const createFieldSpy = jest.spyOn(component, 'createField')
component.filterText = 'Field 1'
component.listFilterEnter()
expect(addFieldSpy).toHaveBeenCalled()
component.filterText = 'Field 3'
component.listFilterEnter()
expect(createFieldSpy).toHaveBeenCalledWith('Field 3')
addFieldSpy.mockClear()
createFieldSpy.mockClear()
component.filterText = undefined
component.listFilterEnter()
expect(createFieldSpy).not.toHaveBeenCalled()
expect(addFieldSpy).not.toHaveBeenCalled()
}))
})

View File

@@ -1,17 +1,13 @@
import {
Component,
ElementRef,
EventEmitter,
Input,
OnDestroy,
Output,
QueryList,
ViewChild,
ViewChildren,
} from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject, first, takeUntil } from 'rxjs'
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
import { CustomField } from 'src/app/data/custom-field'
import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { ToastService } from 'src/app/services/toast.service'
@@ -43,25 +39,23 @@ export class CustomFieldsDropdownComponent implements OnDestroy {
@Output()
created: EventEmitter<CustomField> = new EventEmitter()
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
@ViewChildren('button') buttons: QueryList<ElementRef>
private customFields: CustomField[] = []
private unusedFields: CustomField[] = []
private keyboardIndex: number
public unusedFields: CustomField[]
public get filteredFields(): CustomField[] {
return this.unusedFields.filter(
(f) =>
!this.filterText ||
f.name.toLowerCase().includes(this.filterText.toLowerCase())
)
}
public name: string
public filterText: string
public field: number
private unsubscribeNotifier: Subject<any> = new Subject()
get placeholderText(): string {
return $localize`Choose field`
}
get notFoundText(): string {
return $localize`No unused fields found`
}
get canCreateFields(): boolean {
return this.permissionsService.currentUserCan(
PermissionAction.Add,
@@ -93,26 +87,28 @@ export class CustomFieldsDropdownComponent implements OnDestroy {
})
}
public getCustomFieldFromInstance(
instance: CustomFieldInstance
): CustomField {
return this.customFields.find((f) => f.id === instance.field)
}
private updateUnusedFields() {
this.unusedFields = this.customFields.filter(
(f) => !this.existingFields?.find((e) => e.field === f.id)
(f) =>
!this.existingFields?.find(
(e) => this.getCustomFieldFromInstance(e)?.id === f.id
)
)
}
onOpenClose(open: boolean) {
if (open) {
setTimeout(() => {
this.listFilterTextInput.nativeElement.focus()
}, 100)
} else {
this.filterText = undefined
}
onOpenClose() {
this.field = undefined
this.updateUnusedFields()
}
addField(field: CustomField) {
this.added.emit(field)
this.updateUnusedFields()
addField() {
this.added.emit(this.customFields.find((f) => f.id === this.field))
}
createField(newName: string = null) {
@@ -125,7 +121,6 @@ export class CustomFieldsDropdownComponent implements OnDestroy {
this.customFieldsService.clearCache()
this.getFields()
this.created.emit(newField)
setTimeout(() => this.addField(newField), 100)
})
modal.componentInstance.failed
.pipe(takeUntil(this.unsubscribeNotifier))
@@ -133,82 +128,4 @@ export class CustomFieldsDropdownComponent implements OnDestroy {
this.toastService.showError($localize`Error saving field.`, e)
})
}
getDataTypeLabel(dataType: string) {
return DATA_TYPE_LABELS.find((l) => l.id === dataType)?.name
}
public listFilterEnter() {
if (this.filteredFields.length === 1) {
this.addField(this.filteredFields[0])
} else if (
this.filterText &&
this.filteredFields.length === 0 &&
this.canCreateFields
) {
this.createField(this.filterText)
}
}
private focusNextButtonItem(setFocus: boolean = true) {
this.keyboardIndex = Math.min(
this.buttons.length - 1,
this.keyboardIndex + 1
)
if (setFocus) this.setButtonItemFocus()
}
focusPreviousButtonItem(setFocus: boolean = true) {
this.keyboardIndex = Math.max(0, this.keyboardIndex - 1)
if (setFocus) this.setButtonItemFocus()
}
setButtonItemFocus() {
this.buttons.get(this.keyboardIndex)?.nativeElement.focus()
}
public listKeyDown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowDown':
if (event.target instanceof HTMLInputElement) {
if (
!this.filterText ||
event.target.selectionStart === this.filterText.length
) {
this.keyboardIndex = -1
this.focusNextButtonItem()
event.preventDefault()
}
} else if (event.target instanceof HTMLButtonElement) {
this.focusNextButtonItem()
event.preventDefault()
}
break
case 'ArrowUp':
if (event.target instanceof HTMLButtonElement) {
if (this.keyboardIndex === 0) {
this.listFilterTextInput.nativeElement.focus()
} else {
this.focusPreviousButtonItem()
}
event.preventDefault()
}
break
case 'Tab':
// just track the index in case user uses arrows
if (event.target instanceof HTMLInputElement) {
this.keyboardIndex = 0
} else if (event.target instanceof HTMLButtonElement) {
if (event.shiftKey) {
if (this.keyboardIndex > 0) {
this.focusPreviousButtonItem(false)
}
} else {
this.focusNextButtonItem(false)
}
}
default:
break
}
}
}

View File

@@ -26,7 +26,6 @@ import { TagComponent } from '../tag/tag.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { HotKeyService } from 'src/app/services/hot-key.service'
const items: Tag[] = [
{
@@ -54,7 +53,6 @@ let selectionModel: FilterableDropdownSelectionModel
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
let component: FilterableDropdownComponent
let fixture: ComponentFixture<FilterableDropdownComponent>
let hotkeyService: HotKeyService
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -74,7 +72,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
],
}).compileComponents()
hotkeyService = TestBed.inject(HotKeyService)
fixture = TestBed.createComponent(FilterableDropdownComponent)
component = fixture.componentInstance
selectionModel = new FilterableDropdownSelectionModel()
@@ -580,14 +577,4 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(selectionModel.getSelectedItems()).toEqual([items[0]])
expect(selectionModel.getExcludedItems()).toEqual([items[1]])
})
it('should support shortcut keys', () => {
component.items = items
component.icon = 'tag-fill'
component.shortcutKey = 't'
fixture.detectChanges()
const openSpy = jest.spyOn(component.dropdown, 'open')
document.dispatchEvent(new KeyboardEvent('keydown', { key: 't' }))
expect(openSpy).toHaveBeenCalled()
})
})

View File

@@ -5,17 +5,14 @@ import {
Output,
ElementRef,
ViewChild,
OnInit,
OnDestroy,
} from '@angular/core'
import { FilterPipe } from 'src/app/pipes/filter.pipe'
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
import { ToggleableItemState } from './toggleable-dropdown-button/toggleable-dropdown-button.component'
import { MatchingModel } from 'src/app/data/matching-model'
import { Subject, filter, take, takeUntil } from 'rxjs'
import { Subject } from 'rxjs'
import { SelectionDataItem } from 'src/app/services/rest/document.service'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { HotKeyService } from 'src/app/services/hot-key.service'
export interface ChangedItems {
itemsToAdd: MatchingModel[]
@@ -325,7 +322,7 @@ export class FilterableDropdownSelectionModel {
templateUrl: './filterable-dropdown.component.html',
styleUrls: ['./filterable-dropdown.component.scss'],
})
export class FilterableDropdownComponent implements OnDestroy, OnInit {
export class FilterableDropdownComponent {
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
@ViewChild('dropdown') dropdown: NgbDropdown
@ViewChild('buttonItems') buttonItems: ElementRef
@@ -422,9 +419,6 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
@Input()
documentCounts: SelectionDataItem[]
@Input()
shortcutKey: string
get name(): string {
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
}
@@ -433,39 +427,12 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
private keyboardIndex: number
private unsubscribeNotifier: Subject<any> = new Subject()
constructor(
private filterPipe: FilterPipe,
private hotkeyService: HotKeyService
) {
constructor(private filterPipe: FilterPipe) {
this.selectionModelChange.subscribe((updatedModel) => {
this.modelIsDirty = updatedModel.isDirty()
})
}
ngOnInit(): void {
if (this.shortcutKey) {
this.hotkeyService
.addShortcut({
keys: this.shortcutKey,
description: $localize`Open ${this.title} filter`,
})
.pipe(
takeUntil(this.unsubscribeNotifier),
filter(() => !this.disabled)
)
.subscribe(() => {
this.dropdown.open()
})
}
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next(true)
this.unsubscribeNotifier.complete()
}
applyClicked() {
if (this.selectionModel.isDirty()) {
this.dropdown.close()

View File

@@ -1,23 +0,0 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<table class="table">
<tbody>
@for (key of hotkeys.entries(); track key[0]) {
<tr>
<td>{{ key[1] }}</td>
<td class="d-flex justify-content-end">
<kbd [innerHTML]="formatKey(key[0])"></kbd>
@if (key[0].includes('control')) {
&nbsp;(macOS&nbsp;<kbd [innerHTML]="formatKey(key[0], true)"></kbd>)
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="modal-footer">
</div>

View File

@@ -1,35 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { HotkeyDialogComponent } from './hotkey-dialog.component'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
describe('HotkeyDialogComponent', () => {
let component: HotkeyDialogComponent
let fixture: ComponentFixture<HotkeyDialogComponent>
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HotkeyDialogComponent],
providers: [NgbActiveModal],
}).compileComponents()
fixture = TestBed.createComponent(HotkeyDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should create', () => {
expect(component).toBeTruthy()
})
it('should support close', () => {
const closeSpy = jest.spyOn(component.activeModal, 'close')
component.close()
expect(closeSpy).toHaveBeenCalled()
})
it('should format keys', () => {
expect(component.formatKey('control.a')).toEqual('&#8963; + a') // ⌃ + a
expect(component.formatKey('control.a', true)).toEqual('&#8984; + a') // ⌘ + a
})
})

View File

@@ -1,38 +0,0 @@
import { Component } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
const SYMBOLS = {
meta: '&#8984;', // ⌘
control: '&#8963;', // ⌃
shift: '&#8679;', // ⇧
left: '&#8592;', // ←
right: '&#8594;', // →
up: '&#8593;', // ↑
down: '&#8595;', // ↓
}
@Component({
selector: 'pngx-hotkey-dialog',
templateUrl: './hotkey-dialog.component.html',
styleUrl: './hotkey-dialog.component.scss',
})
export class HotkeyDialogComponent {
public title: string = $localize`Keyboard shortcuts`
public hotkeys: Map<string, string> = new Map()
constructor(public activeModal: NgbActiveModal) {}
public close(): void {
this.activeModal.close()
}
public formatKey(key: string, macOS: boolean = false): string {
if (macOS) {
key = key.replace('control', 'meta')
}
return key
.split('.')
.map((k) => SYMBOLS[k] || k)
.join(' + ')
}
}

View File

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

View File

@@ -66,20 +66,6 @@ describe('DocumentLinkComponent', () => {
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', () => {
const listSpy = jest.spyOn(documentService, 'listFiltered')
listSpy.mockImplementation(

View File

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

View File

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

View File

@@ -74,13 +74,4 @@ describe('MonetaryComponent', () => {
expect(component.currency).toEqual('USD')
expect(component.monetaryValue).toEqual('')
})
it('should handle zero values', () => {
component.writeValue('USD0.00')
expect(component.currency).toEqual('USD')
expect(component.monetaryValue).toEqual('0.00')
component.monetaryValue = '0'
component.monetaryValueChange()
expect(component.value).toEqual('USD0.00')
})
})

View File

@@ -17,16 +17,7 @@ import { getLocaleCurrencyCode } from '@angular/common'
})
export class MonetaryComponent extends AbstractInputComponent<string> {
public currency: string = ''
public _monetaryValue: string = ''
public get monetaryValue(): string {
return this._monetaryValue
}
public set monetaryValue(value: any) {
if (value || value?.toString() === '0')
this._monetaryValue = value.toString()
}
public monetaryValue: string = ''
defaultCurrencyCode: string
constructor(@Inject(LOCALE_ID) currentLocale: string) {
@@ -45,9 +36,6 @@ export class MonetaryComponent extends AbstractInputComponent<string> {
public monetaryValueChange(fixed: boolean = false): void {
this.monetaryValue = this.parseMonetaryValue(this.monetaryValue, fixed)
if (this.monetaryValue === '0') {
this.monetaryValue = '0.00'
}
this.onChange(this.currency + this.monetaryValue)
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,599 @@
/**
* 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
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()
})
})
}
}

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