Compare commits

..

1 Commits

Author SHA1 Message Date
shamoon
44d25f72b7 Tweakhancement: add ID badge to doc details page 2025-10-09 12:24:16 -07:00
373 changed files with 50280 additions and 103024 deletions

View File

@@ -9,15 +9,6 @@ component_management:
- component_id: frontend - component_id: frontend
paths: paths:
- src-ui/** - src-ui/**
flags:
backend:
paths:
- src/**
carryforward: true
frontend:
paths:
- src-ui/**
carryforward: true
# https://docs.codecov.com/docs/pull-request-comments # https://docs.codecov.com/docs/pull-request-comments
comment: comment:
layout: "header, diff, components, flags, files" layout: "header, diff, components, flags, files"

View File

@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM docker.io/node:24-trixie-slim as main-app FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-slim as main-app
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
@@ -8,17 +8,16 @@ ARG DEBIAN_FRONTEND=noninteractive
ARG TARGETARCH ARG TARGETARCH
# Can be workflow provided, defaults set for manual building # Can be workflow provided, defaults set for manual building
ARG JBIG2ENC_VERSION=0.30 ARG JBIG2ENC_VERSION=0.29
ARG QPDF_VERSION=11.9.0
ARG GS_VERSION=10.03.1
# Set Python environment variables # Set Python environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
# Ignore warning from Whitenoise # Ignore warning from Whitenoise
PYTHONWARNINGS="ignore:::django.http.response:517" \ PYTHONWARNINGS="ignore:::django.http.response:517" \
PNGX_CONTAINERIZED=1 \ PNGX_CONTAINERIZED=1
# https://docs.astral.sh/uv/reference/settings/#link-mode
UV_LINK_MODE=copy \
UV_CACHE_DIR=/cache/uv/
# #
# Begin installation and configuration # Begin installation and configuration
@@ -84,15 +83,37 @@ RUN set -eux \
&& apt-get update \ && apt-get update \
&& apt-get install --yes --quiet ${PYTHON_PACKAGES} && apt-get install --yes --quiet ${PYTHON_PACKAGES}
COPY --from=ghcr.io/astral-sh/uv:0.9.10 /uv /bin/uv COPY --from=ghcr.io/astral-sh/uv:0.7.8 /uv /bin/uv
RUN set -eux \ RUN set -eux \
&& echo "Installing pre-built updates" \ && echo "Installing pre-built updates" \
&& echo "Installing qpdf ${QPDF_VERSION}" \
&& curl --fail --silent --show-error --location \
--output libqpdf29_${QPDF_VERSION}-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/qpdf-${QPDF_VERSION}/libqpdf29_${QPDF_VERSION}-1_${TARGETARCH}.deb \
&& curl --fail --silent --show-error --location \
--output qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/qpdf-${QPDF_VERSION}/qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
&& dpkg --install ./libqpdf29_${QPDF_VERSION}-1_${TARGETARCH}.deb \
&& 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 \
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 \
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 \
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 \
&& echo "Installing jbig2enc" \ && echo "Installing jbig2enc" \
&& curl --fail --silent --show-error --location \ && curl --fail --silent --show-error --location \
--output jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ --output jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ https://github.com/paperless-ngx/builder/releases/download/jbig2enc-${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb && dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb
# setup docker-specific things # setup docker-specific things
@@ -106,7 +127,6 @@ COPY [ \
RUN set -eux \ RUN set -eux \
&& echo "Configuring ImageMagick" \ && echo "Configuring ImageMagick" \
&& mkdir -p /etc/ImageMagick-6 \
&& mv paperless-policy.xml /etc/ImageMagick-6/policy.xml && mv paperless-policy.xml /etc/ImageMagick-6/policy.xml
@@ -122,7 +142,7 @@ ARG BUILD_PACKAGES="\
pkg-config" pkg-config"
# hadolint ignore=DL3042 # hadolint ignore=DL3042
RUN --mount=type=cache,target=/cache/uv/,id=uv-cache \ RUN --mount=type=cache,target=/root/.cache/uv,id=pip-cache \
set -eux \ set -eux \
&& echo "Installing build system packages" \ && echo "Installing build system packages" \
&& apt-get update \ && apt-get update \

View File

@@ -11,10 +11,6 @@ end_of_line = lf
charset = utf-8 charset = utf-8
max_line_length = 79 max_line_length = 79
[*.sh]
indent_style = tab
indent_size = 1
[{*.html,*.css,*.js}] [{*.html,*.css,*.js}]
max_line_length = off max_line_length = off

View File

@@ -51,5 +51,5 @@ body:
id: logs id: logs
attributes: attributes:
label: Relevant logs or output label: Relevant logs or output
description: If you have logs, errors that might help, paste it here. For example other containers or services (database, redis, etc). description: If you have logs, errors that might help, paste it here.
render: bash render: bash

View File

@@ -6,8 +6,8 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
### ⚠️ Please remember: issues are for *bugs* only! ⚠️ ### ⚠️ Please remember: issues are for *bugs*
That is, something you believe affects every single user of Paperless-ngx (and the demo, for example), not just you. If you are not sure, start with one of the other options below. That is, something you believe affects every single user of Paperless-ngx, not just you. If you're not sure, start with one of the other options below.
Also, note that **Paperless-ngx does not perform OCR or archive file creation itself**, those are handled by other tools. Problems with OCR or archive versions of specific files should likely be raised 'upstream', see https://github.com/ocrmypdf/OCRmyPDF/issues or https://github.com/tesseract-ocr/tesseract/issues Also, note that **Paperless-ngx does not perform OCR or archive file creation itself**, those are handled by other tools. Problems with OCR or archive versions of specific files should likely be raised 'upstream', see https://github.com/ocrmypdf/OCRmyPDF/issues or https://github.com/tesseract-ocr/tesseract/issues
- type: markdown - type: markdown
@@ -59,12 +59,6 @@ body:
label: Browser logs label: Browser logs
description: Logs from the web browser related to your issue, if needed description: Logs from the web browser related to your issue, if needed
render: bash render: bash
- type: textarea
id: logs_services
attributes:
label: Services logs
description: Logs from other services (or containers) related to your issue, if needed. For example, the database or redis logs.
render: bash
- type: input - type: input
id: version id: version
attributes: attributes:

View File

@@ -35,8 +35,8 @@ NOTE: PRs that do not address the following will not be merged, please do not sk
- [ ] I have read & agree with the [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/main/CONTRIBUTING.md). - [ ] I have read & agree with the [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/main/CONTRIBUTING.md).
- [ ] If applicable, I have included testing coverage for new code in this PR, for [backend](https://docs.paperless-ngx.com/development/#testing) and / or [front-end](https://docs.paperless-ngx.com/development/#testing-and-code-style) changes. - [ ] If applicable, I have included testing coverage for new code in this PR, for [backend](https://docs.paperless-ngx.com/development/#testing) and / or [front-end](https://docs.paperless-ngx.com/development/#testing-and-code-style) changes.
- [ ] If applicable, I have tested my code for breaking changes & regressions on both mobile & desktop devices, using the latest version of major browsers. - [ ] If applicable, I have tested my code for new features & regressions on both mobile & desktop devices, using the latest version of major browsers.
- [ ] If applicable, I have checked that all tests pass, see [documentation](https://docs.paperless-ngx.com/development/#back-end-development). - [ ] If applicable, I have checked that all tests pass, see [documentation](https://docs.paperless-ngx.com/development/#back-end-development).
- [ ] I have run all `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks). - [ ] I have run all `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks).
- [ ] I have made corresponding changes to the documentation as needed. - [ ] I have made corresponding changes to the documentation as needed.
- [ ] In the description of the PR above I have disclosed the use of AI tools in the coding of this PR. - [ ] I have checked my modifications for any breaking changes.

View File

@@ -41,56 +41,30 @@ updates:
- "backend" - "backend"
- "dependencies" - "dependencies"
groups: groups:
# Development & CI/CD Tooling
development: development:
patterns: patterns:
- "*pytest*" - "*pytest*"
- "ruff" - "ruff"
- "mkdocs-material" - "mkdocs-material"
- "pre-commit*" - "pre-commit*"
# Django & DRF Ecosystem django:
django-ecosystem:
patterns: patterns:
- "*django*" - "*django*"
- "drf-*" - "drf-*"
- "djangorestframework" major-versions:
- "whitenoise"
- "bleach"
- "jinja2"
# Async, Task Queuing & Caching
async-tasks:
patterns:
- "celery*"
- "channels*"
- "flower"
- "redis"
# Document, PDF, and OCR Processing
document-processing:
patterns:
- "ocrmypdf"
- "pdf2image"
- "pyzbar"
- "zxing-cpp"
- "tika-client"
- "gotenberg-client"
- "python-magic"
- "python-gnupg"
# Data, NLP, and Search
data-nlp-search:
patterns:
- "nltk"
- "scikit-learn"
- "langdetect"
- "rapidfuzz"
- "whoosh-reloaded"
# Utilities (Patch Updates)
utilities-patch:
update-types: update-types:
- "patch" - "major"
# Utilities (Minor Updates) small-changes:
utilities-minor:
update-types: update-types:
- "minor" - "minor"
- "patch"
exclude-patterns:
- "*django*"
- "drf-*"
pre-built:
patterns:
- psycopg*
- zxing-cpp
# Enable updates for GitHub Actions # Enable updates for GitHub Actions
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
target-branch: "dev" target-branch: "dev"

View File

@@ -1,104 +0,0 @@
name: Backend Tests
on:
push:
branches-ignore:
- 'translations**'
paths:
- 'src/**'
- 'pyproject.toml'
- 'uv.lock'
- 'docker/compose/docker-compose.ci-test.yml'
- '.github/workflows/ci-backend.yml'
pull_request:
branches-ignore:
- 'translations**'
paths:
- 'src/**'
- 'pyproject.toml'
- 'uv.lock'
- 'docker/compose/docker-compose.ci-test.yml'
- '.github/workflows/ci-backend.yml'
workflow_dispatch:
concurrency:
group: backend-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
DEFAULT_UV_VERSION: "0.9.x"
NLTK_DATA: "/usr/share/nltk_data"
jobs:
test:
name: "Python ${{ matrix.python-version }}"
runs-on: ubuntu-24.04
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12']
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Start containers
run: |
docker compose --file docker/compose/docker-compose.ci-test.yml pull --quiet
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
- name: Set up Python
id: setup-python
uses: actions/setup-python@v6
with:
python-version: "${{ matrix.python-version }}"
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ steps.setup-python.outputs.python-version }}
- name: Install system dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends \
unpaper tesseract-ocr imagemagick ghostscript libzbar0 poppler-utils
- name: Configure ImageMagick
run: |
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml
- name: Install Python dependencies
run: |
uv sync \
--python ${{ steps.setup-python.outputs.python-version }} \
--group testing \
--frozen
- name: List installed Python dependencies
run: |
uv pip list
- name: Install NLTK data
run: |
uv run python -m nltk.downloader punkt punkt_tab snowball_data stopwords -d ${{ env.NLTK_DATA }}
- name: Run tests
env:
NLTK_DATA: ${{ env.NLTK_DATA }}
PAPERLESS_CI_TEST: 1
PAPERLESS_MAIL_TEST_HOST: ${{ secrets.TEST_MAIL_HOST }}
PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }}
PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }}
run: |
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
pytest
- name: Upload test results to Codecov
if: always()
uses: codecov/codecov-action@v5
with:
flags: backend,backend-python-${{ matrix.python-version }}
files: junit.xml
report_type: test_results
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
flags: backend,backend-python-${{ matrix.python-version }}
files: coverage.xml
report_type: coverage
- name: Stop containers
if: always()
run: |
docker compose --file docker/compose/docker-compose.ci-test.yml logs
docker compose --file docker/compose/docker-compose.ci-test.yml down

View File

@@ -1,239 +0,0 @@
name: Docker Build
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-beta.rc[0-9]+'
branches:
- dev
- beta
pull_request:
branches:
- dev
- main
workflow_dispatch:
concurrency:
group: docker-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
jobs:
build-arch:
name: Build ${{ matrix.arch }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
arch: amd64
platform: linux/amd64
- runner: ubuntu-24.04-arm
arch: arm64
platform: linux/arm64
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
outputs:
can-push: ${{ steps.check-push.outputs.can-push }}
push-external: ${{ steps.check-push.outputs.push-external }}
repository: ${{ steps.repo.outputs.name }}
ref-name: ${{ steps.ref.outputs.name }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.1
- name: Determine ref name
id: ref
run: |
ref_name="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}"
# Sanitize by replacing / with - for cache keys
cache_ref="${ref_name//\//-}"
echo "ref_name=${ref_name}"
echo "cache_ref=${cache_ref}"
echo "name=${ref_name}" >> $GITHUB_OUTPUT
echo "cache-ref=${cache_ref}" >> $GITHUB_OUTPUT
- name: Check push permissions
id: check-push
env:
REF_NAME: ${{ steps.ref.outputs.name }}
run: |
# can-push: Can we push to GHCR?
# True for: pushes, or PRs from the same repo (not forks)
can_push=${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }}
echo "can-push=${can_push}"
echo "can-push=${can_push}" >> $GITHUB_OUTPUT
# push-external: Should we also push to Docker Hub and Quay.io?
# Only for main repo on dev/beta branches or version tags
push_external="false"
if [[ "${can_push}" == "true" && "${{ github.repository_owner }}" == "paperless-ngx" ]]; then
case "${REF_NAME}" in
dev|beta)
push_external="true"
;;
esac
case "${{ github.ref }}" in
refs/tags/v*|*beta.rc*)
push_external="true"
;;
esac
fi
echo "push-external=${push_external}"
echo "push-external=${push_external}" >> $GITHUB_OUTPUT
- name: Set repository name
id: repo
run: |
repo_name="${{ github.repository }}"
repo_name="${repo_name,,}"
echo "repository=${repo_name}"
echo "name=${repo_name}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.12.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Maximize space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf /usr/local/share/boost
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
- name: Docker metadata
id: docker-meta
uses: docker/metadata-action@v5.10.0
with:
images: |
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
tags: |
type=ref,event=branch
type=raw,value=${{ steps.ref.outputs.name }},enable=${{ github.event_name == 'pull_request' }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6.18.0
with:
context: .
file: ./Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.docker-meta.outputs.labels }}
build-args: |
PNGX_TAG_VERSION=${{ steps.docker-meta.outputs.version }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }},push-by-digest=true,name-canonical=true,push=${{ steps.check-push.outputs.can-push }}
cache-from: |
type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}/cache/app:${{ steps.ref.outputs.cache-ref }}-${{ matrix.arch }}
type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}/cache/app:dev-${{ matrix.arch }}
cache-to: ${{ steps.check-push.outputs.can-push == 'true' && format('type=registry,mode=max,ref={0}/{1}/cache/app:{2}-{3}', env.REGISTRY, steps.repo.outputs.name, steps.ref.outputs.cache-ref, matrix.arch) || '' }}
- name: Export digest
if: steps.check-push.outputs.can-push == 'true'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
echo "digest=${digest}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
if: steps.check-push.outputs.can-push == 'true'
uses: actions/upload-artifact@v6.0.0
with:
name: digests-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge-and-push:
name: Merge and Push Manifest
runs-on: ubuntu-24.04
needs: build-arch
if: needs.build-arch.outputs.can-push == 'true'
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v7.0.0
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: List digests
run: |
echo "Downloaded digests:"
ls -la /tmp/digests/
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.12.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Quay.io
if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@v3.6.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
- name: Docker metadata
id: docker-meta
uses: docker/metadata-action@v5.10.0
with:
images: |
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
tags: |
type=ref,event=branch
type=raw,value=${{ needs.build-arch.outputs.ref-name }},enable=${{ github.event_name == 'pull_request' }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Create manifest list and push
working-directory: /tmp/digests
env:
REPOSITORY: ${{ needs.build-arch.outputs.repository }}
run: |
tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "${DOCKER_METADATA_OUTPUT_JSON}")
digests=""
for digest in *; do
digests+="${{ env.REGISTRY }}/${REPOSITORY}@sha256:${digest} "
done
echo "Creating manifest with tags: ${tags}"
echo "From digests: ${digests}"
docker buildx imagetools create ${tags} ${digests}
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }}
- name: Copy to Docker Hub
if: needs.build-arch.outputs.push-external == 'true'
env:
TAGS: ${{ steps.docker-meta.outputs.tags }}
GHCR_REPO: ${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
run: |
for tag in ${TAGS}; do
dockerhub_tag="${tag/${GHCR_REPO}/docker.io/paperlessngx/paperless-ngx}"
echo "Copying ${tag} to ${dockerhub_tag}"
skopeo copy --all "docker://${tag}" "docker://${dockerhub_tag}"
done
- name: Copy to Quay.io
if: needs.build-arch.outputs.push-external == 'true'
env:
TAGS: ${{ steps.docker-meta.outputs.tags }}
GHCR_REPO: ${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
run: |
for tag in ${TAGS}; do
quay_tag="${tag/${GHCR_REPO}/quay.io/paperlessngx/paperless-ngx}"
echo "Copying ${tag} to ${quay_tag}"
skopeo copy --all "docker://${tag}" "docker://${quay_tag}"
done

View File

@@ -1,88 +0,0 @@
name: Documentation
on:
push:
branches:
- main
- dev
paths:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/ci-docs.yml'
pull_request:
paths:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/ci-docs.yml'
workflow_dispatch:
concurrency:
group: docs-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
DEFAULT_UV_VERSION: "0.9.x"
DEFAULT_PYTHON_VERSION: "3.11"
jobs:
build:
name: Build Documentation
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Python
id: setup-python
uses: actions/setup-python@v6
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install Python dependencies
run: |
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
- name: Build documentation
run: |
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
mkdocs build --config-file ./mkdocs.yml
- name: Upload artifact
uses: actions/upload-artifact@v6
with:
name: documentation
path: site/
retention-days: 7
deploy:
name: Deploy Documentation
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Python
id: setup-python
uses: actions/setup-python@v6
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install Python dependencies
run: |
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
- name: Deploy documentation
run: |
echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME"
git config --global user.name "${{ github.actor }}"
git config --global user.email "${{ github.actor }}@users.noreply.github.com"
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
mkdocs gh-deploy --force --no-history

View File

@@ -1,189 +0,0 @@
name: Frontend Tests
on:
push:
branches-ignore:
- 'translations**'
paths:
- 'src-ui/**'
- '.github/workflows/ci-frontend.yml'
pull_request:
branches-ignore:
- 'translations**'
paths:
- 'src-ui/**'
- '.github/workflows/ci-frontend.yml'
workflow_dispatch:
concurrency:
group: frontend-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
install-dependencies:
name: Install Dependencies
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
id: cache-frontend-deps
uses: actions/cache@v5
with:
path: |
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Install dependencies
run: cd src-ui && pnpm install
lint:
name: Lint
needs: install-dependencies
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
uses: actions/cache@v5
with:
path: |
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Re-link Angular CLI
run: cd src-ui && pnpm link @angular/cli
- name: Run lint
run: cd src-ui && pnpm run lint
unit-tests:
name: "Unit Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
needs: install-dependencies
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
node-version: [24.x]
shard-index: [1, 2, 3, 4]
shard-count: [4]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
uses: actions/cache@v5
with:
path: |
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Re-link Angular CLI
run: cd src-ui && pnpm link @angular/cli
- name: Run Jest unit tests
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- name: Upload test results to Codecov
if: always()
uses: codecov/codecov-action@v5
with:
flags: frontend,frontend-node-${{ matrix.node-version }}
directory: src-ui/
report_type: test_results
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
flags: frontend,frontend-node-${{ matrix.node-version }}
directory: src-ui/coverage/
e2e-tests:
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
needs: install-dependencies
runs-on: ubuntu-24.04
container: mcr.microsoft.com/playwright:v1.57.0-noble
env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
strategy:
fail-fast: false
matrix:
node-version: [24.x]
shard-index: [1, 2]
shard-count: [2]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
uses: actions/cache@v5
with:
path: |
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Re-link Angular CLI
run: cd src-ui && pnpm link @angular/cli
- name: Install dependencies
run: cd src-ui && pnpm install --no-frozen-lockfile
- name: Run Playwright E2E tests
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
bundle-analysis:
name: Bundle Analysis
needs: [unit-tests, e2e-tests]
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
uses: actions/cache@v5
with:
path: |
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Re-link Angular CLI
run: cd src-ui && pnpm link @angular/cli
- name: Build and analyze
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: cd src-ui && pnpm run build --configuration=production

View File

@@ -1,24 +0,0 @@
name: Lint
on:
push:
branches-ignore:
- 'translations**'
pull_request:
branches-ignore:
- 'translations**'
concurrency:
group: lint-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
pre-commit:
name: Pre-commit Checks
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Run pre-commit
uses: pre-commit/action@v3.0.1

View File

@@ -1,237 +0,0 @@
name: Release
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-beta.rc[0-9]+'
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
DEFAULT_UV_VERSION: "0.9.x"
DEFAULT_PYTHON_VERSION: "3.11"
jobs:
wait-for-docker:
name: Wait for Docker Build
runs-on: ubuntu-24.04
steps:
- name: Wait for Docker build
uses: lewagon/wait-on-check-action@v1.4.1
with:
ref: ${{ github.sha }}
check-name: 'Build Docker Image'
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 60
build-release:
name: Build Release
needs: wait-for-docker
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
# ---- Frontend Build ----
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Install frontend dependencies
run: cd src-ui && pnpm install
- name: Build frontend
run: cd src-ui && pnpm run build --configuration production
# ---- Backend Setup ----
- name: Set up Python
id: setup-python
uses: actions/setup-python@v6
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ steps.setup-python.outputs.python-version }}
- name: Install Python dependencies
run: |
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
- name: Install system dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext liblept5
# ---- Build Documentation ----
- name: Build documentation
run: |
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
mkdocs build --config-file ./mkdocs.yml
# ---- Prepare Release ----
- name: Generate requirements file
run: |
uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
- name: Compile messages
run: |
cd src/
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
manage.py compilemessages
- name: Collect static files
run: |
cd src/
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
manage.py collectstatic --no-input --clear
- name: Assemble release package
run: |
mkdir -p dist/paperless-ngx/scripts
for file_name in .dockerignore \
.env \
Dockerfile \
pyproject.toml \
uv.lock \
requirements.txt \
LICENSE \
README.md \
paperless.conf.example
do
cp --verbose ${file_name} dist/paperless-ngx/
done
mv dist/paperless-ngx/paperless.conf.example dist/paperless-ngx/paperless.conf
cp --recursive docker/ dist/paperless-ngx/docker
cp scripts/*.service scripts/*.sh scripts/*.socket dist/paperless-ngx/scripts/
cp --recursive src/ dist/paperless-ngx/src
cp --recursive site/ dist/paperless-ngx/docs
mv static dist/paperless-ngx/
find dist/paperless-ngx -name "__pycache__" -type d -exec rm -rf {} +
- name: Create release archive
run: |
cd dist
sudo chown -R 1000:1000 paperless-ngx/
tar -cJf paperless-ngx.tar.xz paperless-ngx/
- name: Upload release artifact
uses: actions/upload-artifact@v6
with:
name: release
path: dist/paperless-ngx.tar.xz
retention-days: 7
publish-release:
name: Publish Release
needs: build-release
runs-on: ubuntu-24.04
outputs:
prerelease: ${{ steps.get-version.outputs.prerelease }}
changelog: ${{ steps.create-release.outputs.body }}
version: ${{ steps.get-version.outputs.version }}
steps:
- name: Download release artifact
uses: actions/download-artifact@v7
with:
name: release
path: ./
- name: Get version info
id: get-version
run: |
echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT
if [[ "${{ github.ref_name }}" == *"-beta.rc"* ]]; then
echo "prerelease=true" >> $GITHUB_OUTPUT
else
echo "prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Create release and changelog
id: create-release
uses: release-drafter/release-drafter@v6
with:
name: Paperless-ngx ${{ steps.get-version.outputs.version }}
tag: ${{ steps.get-version.outputs.version }}
version: ${{ steps.get-version.outputs.version }}
prerelease: ${{ steps.get-version.outputs.prerelease }}
publish: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload release archive
uses: shogo82148/actions-upload-release-asset@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
upload_url: ${{ steps.create-release.outputs.upload_url }}
asset_path: ./paperless-ngx.tar.xz
asset_name: paperless-ngx-${{ steps.get-version.outputs.version }}.tar.xz
asset_content_type: application/x-xz
# ---------------------------------------------------------------------------
# Append changelog to docs (only on non-prerelease)
# ---------------------------------------------------------------------------
append-changelog:
name: Append Changelog
needs: publish-release
if: needs.publish-release.outputs.prerelease == 'false'
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: main
- name: Set up Python
id: setup-python
uses: actions/setup-python@v6
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Update changelog
working-directory: docs
run: |
git branch ${{ needs.publish-release.outputs.version }}-changelog
git checkout ${{ needs.publish-release.outputs.version }}-changelog
echo -e "# Changelog\n\n${{ needs.publish-release.outputs.changelog }}\n" > changelog-new.md
echo "Manually linking usernames"
sed -i -r 's|@([a-zA-Z0-9_]+) \(\[#|[@\1](https://github.com/\1) ([#|g' changelog-new.md
echo "Removing unneeded comment tags"
sed -i -r 's|@<!---->|@|g' changelog-new.md
CURRENT_CHANGELOG=$(tail --lines +2 changelog.md)
echo -e "$CURRENT_CHANGELOG" >> changelog-new.md
mv changelog-new.md changelog.md
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
pre-commit run --files changelog.md || true
git config --global user.name "github-actions"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"
git push origin ${{ needs.publish-release.outputs.version }}-changelog
- name: Create pull request
uses: actions/github-script@v8
with:
script: |
const { repo, owner } = context.repo;
const result = await github.rest.pulls.create({
title: 'Documentation: Add ${{ needs.publish-release.outputs.version }} changelog',
owner,
repo,
head: '${{ needs.publish-release.outputs.version }}-changelog',
base: 'main',
body: 'This PR is auto-generated by CI.'
});
github.rest.issues.addLabels({
owner,
repo,
issue_number: result.data.number,
labels: ['documentation', 'skip-changelog']
});

673
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,673 @@
name: ci
on:
push:
tags:
# https://semver.org/#spec-item-2
- 'v[0-9]+.[0-9]+.[0-9]+'
# https://semver.org/#spec-item-9
- 'v[0-9]+.[0-9]+.[0-9]+-beta.rc[0-9]+'
branches-ignore:
- 'translations**'
pull_request:
branches-ignore:
- 'translations**'
env:
DEFAULT_UV_VERSION: "0.8.x"
# This is the default version of Python to use in most steps which aren't specific
DEFAULT_PYTHON_VERSION: "3.11"
NLTK_DATA: "/usr/share/nltk_data"
jobs:
detect-duplicate:
name: Detect Duplicate Run
runs-on: ubuntu-24.04
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- name: Check if workflow should run
id: check
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
if (context.eventName !== 'push') {
core.info('Not a push event; running workflow.');
core.setOutput('should_run', 'true');
return;
}
const ref = context.ref || '';
if (!ref.startsWith('refs/heads/')) {
core.info('Push is not to a branch; running workflow.');
core.setOutput('should_run', 'true');
return;
}
const branch = ref.substring('refs/heads/'.length);
const { owner, repo } = context.repo;
const prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
head: `${owner}:${branch}`,
per_page: 100,
});
if (prs.length === 0) {
core.info(`No open PR found for ${branch}; running workflow.`);
core.setOutput('should_run', 'true');
} else {
core.info(`Found ${prs.length} open PR(s) for ${branch}; skipping duplicate push run.`);
core.setOutput('should_run', 'false');
}
pre-commit:
needs:
- detect-duplicate
if: needs.detect-duplicate.outputs.should_run == 'true'
name: Linting Checks
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Install python
uses: actions/setup-python@v6
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Check files
uses: pre-commit/action@v3.0.1
documentation:
name: "Build & Deploy Documentation"
runs-on: ubuntu-24.04
needs:
- pre-commit
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Python
id: setup-python
uses: actions/setup-python@v6
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install Python dependencies
run: |
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
- name: Make documentation
run: |
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
mkdocs build --config-file ./mkdocs.yml
- name: Deploy documentation
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME"
git config --global user.name "${{ github.actor }}"
git config --global user.email "${{ github.actor }}@users.noreply.github.com"
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
mkdocs gh-deploy --force --no-history
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: documentation
path: site/
retention-days: 7
tests-backend:
name: "Backend Tests (Python ${{ matrix.python-version }})"
runs-on: ubuntu-24.04
needs:
- pre-commit
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12']
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Start containers
run: |
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml pull --quiet
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml up --detach
- name: Set up Python
id: setup-python
uses: actions/setup-python@v6
with:
python-version: "${{ matrix.python-version }}"
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ steps.setup-python.outputs.python-version }}
- name: Install system dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends unpaper tesseract-ocr imagemagick ghostscript libzbar0 poppler-utils
- name: Configure ImageMagick
run: |
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml
- name: Install Python dependencies
run: |
uv sync \
--python ${{ steps.setup-python.outputs.python-version }} \
--group testing \
--frozen
- name: List installed Python dependencies
run: |
uv pip list
- name: Install or update NLTK dependencies
run: uv run python -m nltk.downloader punkt punkt_tab snowball_data stopwords -d ${{ env.NLTK_DATA }}
- name: Tests
env:
NLTK_DATA: ${{ env.NLTK_DATA }}
PAPERLESS_CI_TEST: 1
# Enable paperless_mail testing against real server
PAPERLESS_MAIL_TEST_HOST: ${{ secrets.TEST_MAIL_HOST }}
PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }}
PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }}
run: |
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
pytest
- name: Upload backend test results to Codecov
if: always()
uses: codecov/test-results-action@v1
with:
flags: backend-python-${{ matrix.python-version }}
files: junit.xml
- name: Upload backend coverage to Codecov
uses: codecov/codecov-action@v5
with:
flags: backend-python-${{ matrix.python-version }}
files: coverage.xml
- name: Stop containers
if: always()
run: |
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml logs
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml down
install-frontend-dependencies:
name: "Install Frontend Dependencies"
runs-on: ubuntu-24.04
needs:
- pre-commit
steps:
- uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js 20
uses: actions/setup-node@v5
with:
node-version: 20.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
id: cache-frontend-deps
uses: actions/cache@v4
with:
path: |
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Install dependencies
run: cd src-ui && pnpm install
tests-frontend:
name: "Frontend Unit Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})"
runs-on: ubuntu-24.04
needs:
- install-frontend-dependencies
strategy:
fail-fast: false
matrix:
node-version: [20.x]
shard-index: [1, 2, 3, 4]
shard-count: [4]
steps:
- uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js 20
uses: actions/setup-node@v5
with:
node-version: 20.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
id: cache-frontend-deps
uses: actions/cache@v4
with:
path: |
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Re-link Angular cli
run: cd src-ui && pnpm link @angular/cli
- name: Linting checks
run: cd src-ui && pnpm run lint
- name: Run Jest unit tests
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- name: Upload frontend test results to Codecov
uses: codecov/test-results-action@v1
if: always()
with:
flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/
- name: Upload frontend coverage to Codecov
uses: codecov/codecov-action@v5
with:
flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/coverage/
tests-frontend-e2e:
name: "Frontend E2E Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})"
runs-on: ubuntu-24.04
needs:
- install-frontend-dependencies
strategy:
fail-fast: false
matrix:
node-version: [20.x]
shard-index: [1, 2]
shard-count: [2]
steps:
- uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js 20
uses: actions/setup-node@v5
with:
node-version: 20.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
id: cache-frontend-deps
uses: actions/cache@v4
with:
path: |
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Re-link Angular cli
run: cd src-ui && pnpm link @angular/cli
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install Playwright system dependencies
run: npx playwright install-deps
- name: Install dependencies
run: cd src-ui && pnpm install --no-frozen-lockfile
- name: Install Playwright
run: cd src-ui && pnpm exec playwright install
- name: Run Playwright e2e tests
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
frontend-bundle-analysis:
name: "Frontend Bundle Analysis"
runs-on: ubuntu-24.04
needs:
- tests-frontend
- tests-frontend-e2e
steps:
- uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js 20
uses: actions/setup-node@v5
with:
node-version: 20.x
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
id: cache-frontend-deps
uses: actions/cache@v4
with:
path: |
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }}
- name: Re-link Angular cli
run: cd src-ui && pnpm link @angular/cli
- name: Build frontend and upload analysis
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: cd src-ui && pnpm run build --configuration=production
build-docker-image:
name: Build Docker image for ${{ github.ref_name }}
runs-on: ubuntu-24.04
if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || startsWith(github.ref, 'refs/heads/fix-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/l10n_'))
concurrency:
group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }}
cancel-in-progress: true
needs:
- tests-backend
- tests-frontend
- tests-frontend-e2e
steps:
- name: Check pushing to Docker Hub
id: push-other-places
# Only push to Dockerhub from the main repo AND the ref is either:
# main
# dev
# beta
# a tag
# Otherwise forks would require a Docker Hub account and secrets setup
run: |
if [[ ${{ github.repository_owner }} == "paperless-ngx" && ( ${{ github.ref_name }} == "dev" || ${{ github.ref_name }} == "beta" || ${{ startsWith(github.ref, 'refs/tags/v') }} == "true" ) ]] ; then
echo "Enabling DockerHub image push"
echo "enable=true" >> $GITHUB_OUTPUT
else
echo "Not pushing to DockerHub"
echo "enable=false" >> $GITHUB_OUTPUT
fi
- name: Set ghcr repository name
id: set-ghcr-repository
run: |
ghcr_name=$(echo "${{ github.repository }}" | awk '{ print tolower($0) }')
echo "Name is ${ghcr_name}"
echo "ghcr-repository=${ghcr_name}" >> $GITHUB_OUTPUT
- name: Gather Docker metadata
id: docker-meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}
name=paperlessngx/paperless-ngx,enable=${{ steps.push-other-places.outputs.enable }}
name=quay.io/paperlessngx/paperless-ngx,enable=${{ steps.push-other-places.outputs.enable }}
tags: |
# Tag branches with branch name
type=ref,event=branch
# Process semver tags
# For a tag x.y.z or vX.Y.Z, output an x.y.z and x.y image tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Checkout
uses: actions/checkout@v5
# If https://github.com/docker/buildx/issues/1044 is resolved,
# the append input with a native arm64 arch could be used to
# significantly speed up building
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v3
# Don't attempt to login if not pushing to Docker Hub
if: steps.push-other-places.outputs.enable == 'true'
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Quay.io
uses: docker/login-action@v3
# Don't attempt to login if not pushing to Quay.io
if: steps.push-other-places.outputs.enable == 'true'
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}
build-args: |
PNGX_TAG_VERSION=${{ steps.docker-meta.outputs.version }}
# Get cache layers from this branch, then dev
# This allows new branches to get at least some cache benefits, generally from dev
cache-from: |
type=registry,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:${{ github.ref_name }}
type=registry,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:dev
cache-to: |
type=registry,mode=max,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:${{ github.ref_name }}
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }}
- name: Export frontend artifact from docker
run: |
docker create --name frontend-extract ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }}
docker cp frontend-extract:/usr/src/paperless/src/documents/static/frontend src/documents/static/frontend/
- name: Upload frontend artifact
uses: actions/upload-artifact@v4
with:
name: frontend-compiled
path: src/documents/static/frontend/
retention-days: 7
build-release:
name: "Build Release"
needs:
- build-docker-image
- documentation
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Python
id: setup-python
uses: actions/setup-python@v6
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ steps.setup-python.outputs.python-version }}
- name: Install Python dependencies
run: |
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
- name: Install system dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext liblept5
- name: Download frontend artifact
uses: actions/download-artifact@v5
with:
name: frontend-compiled
path: src/documents/static/frontend/
- name: Download documentation artifact
uses: actions/download-artifact@v5
with:
name: documentation
path: docs/_build/html/
- name: Generate requirements file
run: |
uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
- name: Compile messages
run: |
cd src/
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
manage.py compilemessages
- name: Collect static files
run: |
cd src/
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
manage.py collectstatic --no-input
- name: Move files
run: |
echo "Making dist folders"
for directory in dist \
dist/paperless-ngx \
dist/paperless-ngx/scripts;
do
mkdir --verbose --parents ${directory}
done
echo "Copying basic files"
for file_name in .dockerignore \
.env \
Dockerfile \
pyproject.toml \
uv.lock \
requirements.txt \
LICENSE \
README.md \
paperless.conf.example
do
cp --verbose ${file_name} dist/paperless-ngx/
done
mv --verbose dist/paperless-ngx/paperless.conf.example dist/paperless-ngx/paperless.conf
echo "Copying Docker related files"
cp --recursive docker/ dist/paperless-ngx/docker
echo "Copying startup scripts"
cp --verbose scripts/*.service scripts/*.sh scripts/*.socket dist/paperless-ngx/scripts/
echo "Copying source files"
cp --recursive src/ dist/paperless-ngx/src
echo "Copying documentation"
cp --recursive docs/_build/html/ dist/paperless-ngx/docs
mv --verbose static dist/paperless-ngx
- name: Make release package
run: |
echo "Creating release archive"
cd dist
sudo chown -R 1000:1000 paperless-ngx/
tar -cJf paperless-ngx.tar.xz paperless-ngx/
- name: Upload release artifact
uses: actions/upload-artifact@v4
with:
name: release
path: dist/paperless-ngx.tar.xz
retention-days: 7
publish-release:
name: "Publish Release"
runs-on: ubuntu-24.04
outputs:
prerelease: ${{ steps.get_version.outputs.prerelease }}
changelog: ${{ steps.create-release.outputs.body }}
version: ${{ steps.get_version.outputs.version }}
needs:
- build-release
if: github.ref_type == 'tag' && (startsWith(github.ref_name, 'v') || contains(github.ref_name, '-beta.rc'))
steps:
- name: Download release artifact
uses: actions/download-artifact@v5
with:
name: release
path: ./
- name: Get version
id: get_version
run: |
echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT
if [[ ${{ contains(github.ref_name, '-beta.rc') }} == 'true' ]]; then
echo "prerelease=true" >> $GITHUB_OUTPUT
else
echo "prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Create Release and Changelog
id: create-release
uses: release-drafter/release-drafter@v6
with:
name: Paperless-ngx ${{ steps.get_version.outputs.version }}
tag: ${{ steps.get_version.outputs.version }}
version: ${{ steps.get_version.outputs.version }}
prerelease: ${{ steps.get_version.outputs.prerelease }}
publish: true # ensures release is not marked as draft
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload release archive
id: upload-release-asset
uses: shogo82148/actions-upload-release-asset@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
upload_url: ${{ steps.create-release.outputs.upload_url }}
asset_path: ./paperless-ngx.tar.xz
asset_name: paperless-ngx-${{ steps.get_version.outputs.version }}.tar.xz
asset_content_type: application/x-xz
append-changelog:
name: "Append Changelog"
runs-on: ubuntu-24.04
needs:
- publish-release
if: needs.publish-release.outputs.prerelease == 'false'
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: main
- name: Set up Python
id: setup-python
uses: actions/setup-python@v6
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Append Changelog to docs
id: append-Changelog
working-directory: docs
run: |
git branch ${{ needs.publish-release.outputs.version }}-changelog
git checkout ${{ needs.publish-release.outputs.version }}-changelog
echo -e "# Changelog\n\n${{ needs.publish-release.outputs.changelog }}\n" > changelog-new.md
echo "Manually linking usernames"
sed -i -r 's|@([a-zA-Z0-9_]+) \(\[#|[@\1](https://github.com/\1) ([#|g' changelog-new.md
echo "Removing unneeded comment tags"
sed -i -r 's|@<!---->|@|g' changelog-new.md
CURRENT_CHANGELOG=`tail --lines +2 changelog.md`
echo -e "$CURRENT_CHANGELOG" >> changelog-new.md
mv changelog-new.md changelog.md
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
pre-commit run --files changelog.md || true
git config --global user.name "github-actions"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"
git push origin ${{ needs.publish-release.outputs.version }}-changelog
- name: Create Pull Request
uses: actions/github-script@v8
with:
script: |
const { repo, owner } = context.repo;
const result = await github.rest.pulls.create({
title: 'Documentation: Add ${{ needs.publish-release.outputs.version }} changelog',
owner,
repo,
head: '${{ needs.publish-release.outputs.version }}-changelog',
base: 'main',
body: 'This PR is auto-generated by CI.'
});
github.rest.issues.addLabels({
owner,
repo,
issue_number: result.data.number,
labels: ['documentation', 'skip-changelog']
});

View File

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

View File

@@ -34,10 +34,10 @@ jobs:
# Learn more about CodeQL language support at https://git.io/codeql-language-support # Learn more about CodeQL language support at https://git.io/codeql-language-support
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v5
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v4 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -45,4 +45,4 @@ jobs:
# Prefix the list here with "+" to use these queries and those in the config file. # Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main # queries: ./path/to/local/query, your-org/your-repo/queries@main
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4 uses: github/codeql-action/analyze@v3

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v5
with: with:
token: ${{ secrets.PNGX_BOT_PAT }} token: ${{ secrets.PNGX_BOT_PAT }}
- name: crowdin action - name: crowdin action

View File

@@ -37,7 +37,7 @@ jobs:
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: dessant/lock-threads@v6 - uses: dessant/lock-threads@v5
with: with:
issue-inactive-days: '30' issue-inactive-days: '30'
pr-inactive-days: '30' pr-inactive-days: '30'

View File

@@ -11,12 +11,10 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v5
env:
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
with: with:
token: ${{ secrets.PNGX_BOT_PAT }} token: ${{ secrets.PNGX_BOT_PAT }}
ref: ${{ env.GH_REF }} ref: ${{ github.head_ref }}
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
@@ -25,7 +23,7 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext sudo apt-get install -qq --no-install-recommends gettext
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v6
with: with:
enable-cache: true enable-cache: true
- name: Install backend python dependencies - name: Install backend python dependencies
@@ -39,15 +37,15 @@ jobs:
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 20
uses: actions/setup-node@v6 uses: actions/setup-node@v5
with: with:
node-version: 24.x node-version: 20.x
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
id: cache-frontend-deps id: cache-frontend-deps
uses: actions/cache@v5 uses: actions/cache@v4
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
@@ -63,7 +61,7 @@ jobs:
cd src-ui cd src-ui
pnpm run ng extract-i18n pnpm run ng extract-i18n
- name: Commit changes - name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v7 uses: stefanzweifel/git-auto-commit-action@v6
with: with:
file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po' file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po'
commit_message: "Auto translate strings" commit_message: "Auto translate strings"

View File

@@ -49,12 +49,12 @@ repos:
- 'prettier-plugin-organize-imports@4.1.0' - 'prettier-plugin-organize-imports@4.1.0'
# Python hooks # Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.5 rev: v0.13.2
hooks: hooks:
- id: ruff-check - id: ruff-check
- id: ruff-format - id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt - repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.11.1" rev: "v2.6.0"
hooks: hooks:
- id: pyproject-fmt - id: pyproject-fmt
# Dockerfile hooks # Dockerfile hooks
@@ -64,11 +64,11 @@ repos:
- id: hadolint - id: hadolint
# Shell script hooks # Shell script hooks
- repo: https://github.com/lovesegfault/beautysh - repo: https://github.com/lovesegfault/beautysh
rev: v6.4.2 rev: v6.2.1
hooks: hooks:
- id: beautysh - id: beautysh
types: [file] additional_dependencies:
files: (\.sh$|/run$|/finish$) - setuptools
args: args:
- "--tab" - "--tab"
- repo: https://github.com/shellcheck-py/shellcheck-py - repo: https://github.com/shellcheck-py/shellcheck-py
@@ -76,9 +76,7 @@ repos:
hooks: hooks:
- id: shellcheck - id: shellcheck
- repo: https://github.com/google/yamlfmt - repo: https://github.com/google/yamlfmt
rev: v0.20.0 rev: v0.17.2
hooks: hooks:
- id: yamlfmt - id: yamlfmt
exclude: "^src-ui/pnpm-lock.yaml" exclude: "^src-ui/pnpm-lock.yaml"
types:
- yaml

View File

@@ -5,12 +5,14 @@
# Purpose: Compiles the frontend # Purpose: Compiles the frontend
# Notes: # Notes:
# - Does PNPM stuff with Typescript and such # - Does PNPM stuff with Typescript and such
FROM --platform=$BUILDPLATFORM docker.io/node:24-trixie-slim AS compile-frontend FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-slim AS compile-frontend
COPY ./src-ui /src/src-ui COPY ./src-ui /src/src-ui
WORKDIR /src/src-ui WORKDIR /src/src-ui
RUN set -eux \ RUN set -eux \
&& npm update -g pnpm \
&& npm install -g corepack@latest \
&& corepack enable \ && corepack enable \
&& pnpm install && pnpm install
@@ -30,7 +32,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs # Purpose: Installs s6-overlay and rootfs
# Comments: # Comments:
# - Don't leave anything extra in here either # - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.9.15-python3.12-trixie-slim AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.8.22-python3.12-bookworm-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6
@@ -100,6 +102,8 @@ ARG TARGETARCH
# Can be workflow provided, defaults set for manual building # Can be workflow provided, defaults set for manual building
ARG JBIG2ENC_VERSION=0.30 ARG JBIG2ENC_VERSION=0.30
ARG QPDF_VERSION=11.9.0
ARG GS_VERSION=10.03.1
# Set Python environment variables # Set Python environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -108,7 +112,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONWARNINGS="ignore:::django.http.response:517" \ PYTHONWARNINGS="ignore:::django.http.response:517" \
PNGX_CONTAINERIZED=1 \ PNGX_CONTAINERIZED=1 \
# https://docs.astral.sh/uv/reference/settings/#link-mode # https://docs.astral.sh/uv/reference/settings/#link-mode
UV_LINK_MODE=copy UV_LINK_MODE=copy \
UV_CACHE_DIR=/cache/uv/
# #
# Begin installation and configuration # Begin installation and configuration
@@ -165,8 +170,20 @@ RUN set -eux \
&& apt-get update \ && apt-get update \
&& apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES} \ && apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES} \
&& echo "Installing pre-built updates" \ && echo "Installing pre-built updates" \
&& curl --fail --silent --no-progress-meter --show-error --location --remote-name-all \ && curl --fail --silent --no-progress-meter --show-error --location --remote-name-all --parallel --parallel-max 4 \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ https://github.com/paperless-ngx/builder/releases/download/qpdf-${QPDF_VERSION}/libqpdf29_${QPDF_VERSION}-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/qpdf-${QPDF_VERSION}/qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& echo "Installing qpdf ${QPDF_VERSION}" \
&& dpkg --install ./libqpdf29_${QPDF_VERSION}-1_${TARGETARCH}.deb \
&& dpkg --install ./qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
&& echo "Installing Ghostscript ${GS_VERSION}" \
&& 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 \
&& echo "Installing jbig2enc" \ && echo "Installing jbig2enc" \
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \ && dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
&& echo "Configuring imagemagick" \ && echo "Configuring imagemagick" \
@@ -190,13 +207,14 @@ ARG BUILD_PACKAGES="\
pkg-config" pkg-config"
# hadolint ignore=DL3042 # hadolint ignore=DL3042
RUN set -eux \ RUN --mount=type=cache,target=${UV_CACHE_DIR},id=python-cache \
set -eux \
&& echo "Installing build system packages" \ && echo "Installing build system packages" \
&& apt-get update \ && apt-get update \
&& apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \ && apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \
&& echo "Installing Python requirements" \ && echo "Installing Python requirements" \
&& uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt \ && uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt \
&& uv pip install --no-cache --system --no-python-downloads --python-preference system --requirements requirements.txt \ && uv pip install --system --no-python-downloads --python-preference system --requirements requirements.txt \
&& echo "Installing NLTK data" \ && echo "Installing NLTK data" \
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \ && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \ && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \
@@ -236,8 +254,7 @@ RUN set -eux \
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \ && chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
&& echo "Collecting static files" \ && echo "Collecting static files" \
&& s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \ && s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \
&& s6-setuidgid paperless python3 manage.py compilemessages \ && s6-setuidgid paperless python3 manage.py compilemessages
&& /usr/local/bin/deduplicate.py --verbose /usr/src/paperless/static/
VOLUME ["/usr/src/paperless/data", \ VOLUME ["/usr/src/paperless/data", \
"/usr/src/paperless/media", \ "/usr/src/paperless/media", \

View File

@@ -4,7 +4,7 @@
# correct networking for the tests # correct networking for the tests
services: services:
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.25 image: docker.io/gotenberg/gotenberg:8.23
hostname: gotenberg hostname: gotenberg
container_name: gotenberg container_name: gotenberg
network_mode: host network_mode: host

View File

@@ -72,7 +72,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.25 image: docker.io/gotenberg/gotenberg:8.23
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript. # want to allow external content like tracking pixels or even javascript.

View File

@@ -35,7 +35,7 @@ services:
image: docker.io/library/postgres:18 image: docker.io/library/postgres:18
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- pgdata:/var/lib/postgresql - pgdata:/var/lib/postgresql/data
environment: environment:
POSTGRES_DB: paperless POSTGRES_DB: paperless
POSTGRES_USER: paperless POSTGRES_USER: paperless

View File

@@ -38,7 +38,7 @@ services:
image: docker.io/library/postgres:18 image: docker.io/library/postgres:18
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- pgdata:/var/lib/postgresql - pgdata:/var/lib/postgresql/data
environment: environment:
POSTGRES_DB: paperless POSTGRES_DB: paperless
POSTGRES_USER: paperless POSTGRES_USER: paperless
@@ -66,7 +66,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.25 image: docker.io/gotenberg/gotenberg:8.23
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript. # want to allow external content like tracking pixels or even javascript.

View File

@@ -34,7 +34,7 @@ services:
image: docker.io/library/postgres:18 image: docker.io/library/postgres:18
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- pgdata:/var/lib/postgresql - pgdata:/var/lib/postgresql/data
environment: environment:
POSTGRES_DB: paperless POSTGRES_DB: paperless
POSTGRES_USER: paperless POSTGRES_USER: paperless

View File

@@ -55,7 +55,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.25 image: docker.io/gotenberg/gotenberg:8.23
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript. # want to allow external content like tracking pixels or even javascript.

View File

@@ -11,7 +11,6 @@ for command in decrypt_documents \
mail_fetcher \ mail_fetcher \
document_create_classifier \ document_create_classifier \
document_index \ document_index \
document_llmindex \
document_renamer \ document_renamer \
document_retagger \ document_retagger \
document_thumbnails \ document_thumbnails \

View File

@@ -29,5 +29,5 @@ if find /run/s6/container_environment/*"_FILE" -maxdepth 1 > /dev/null 2>&1; the
fi fi
done done
else else
echo "${log_prefix} No *_FILE environment found" echo "${log_prefix} No *_FILE environment found"
fi fi

View File

@@ -1,66 +1,70 @@
#!/command/with-contenv /usr/bin/bash #!/command/with-contenv /usr/bin/bash
# shellcheck shell=bash # shellcheck shell=bash
# vim: set ft=bash ts=4 sw=4 sts=4 et :
set -euo pipefail declare -r log_prefix="[init-db-wait]"
declare -r LOG_PREFIX="[init-db-wait]"
declare -ri TIMEOUT=60
declare -i ATTEMPT=0
declare -i DELAY=0
declare -i STARTED_AT=${EPOCHSECONDS:?EPOCHSECONDS var unset}
delay_next_attempt() {
local -i elapsed=$(( EPOCHSECONDS - STARTED_AT ))
local -ri remaining=$(( TIMEOUT - elapsed ))
if (( remaining <= 0 )); then
echo "${LOG_PREFIX} Unable to connect after $elapsed seconds."
exit 1
fi
DELAY+=1
# clamp to remaining time
if (( DELAY > remaining )); then
DELAY=$remaining
fi
ATTEMPT+=1
echo "${LOG_PREFIX} Attempt $ATTEMPT failed! Trying again in $DELAY seconds..."
sleep "$DELAY"
}
wait_for_postgres() { wait_for_postgres() {
echo "${LOG_PREFIX} Waiting for PostgreSQL to start..." local attempt_num=1
local -r max_attempts=5
echo "${log_prefix} Waiting for PostgreSQL to start..."
local -r host="${PAPERLESS_DBHOST:-localhost}" local -r host="${PAPERLESS_DBHOST:-localhost}"
local -r port="${PAPERLESS_DBPORT:-5432}" local -r port="${PAPERLESS_DBPORT:-5432}"
local -r user="${PAPERLESS_DBUSER:-paperless}" local -r user="${PAPERLESS_DBUSER:-paperless}"
while ! pg_isready -h "${host}" -p "${port}" --username "${user}"; do # Disable warning, host and port can't have spaces
delay_next_attempt # shellcheck disable=SC2086
while [ ! "$(pg_isready -h ${host} -p ${port} --username ${user})" ]; do
if [ $attempt_num -eq $max_attempts ]; then
echo "${log_prefix} Unable to connect to database."
exit 1
else
echo "${log_prefix} Attempt $attempt_num failed! Trying again in 5 seconds..."
fi
attempt_num=$(("$attempt_num" + 1))
sleep 5
done done
echo "${LOG_PREFIX} Connected to PostgreSQL" # Extra in case this is a first start
sleep 5
echo "Connected to PostgreSQL"
} }
wait_for_mariadb() { wait_for_mariadb() {
echo "${LOG_PREFIX} Waiting for MariaDB to start..." echo "${log_prefix} Waiting for MariaDB to start..."
local -r host="${PAPERLESS_DBHOST:-localhost}" local -r host="${PAPERLESS_DBHOST:=localhost}"
local -r port="${PAPERLESS_DBPORT:-3306}" local -r port="${PAPERLESS_DBPORT:=3306}"
while ! mariadb-admin --host="${host}" --port="${port}" --skip-ssl ping --silent >/dev/null 2>&1; do local attempt_num=1
delay_next_attempt local -r max_attempts=5
# Disable warning, host and port can't have spaces
# shellcheck disable=SC2086
while ! true > /dev/tcp/$host/$port; do
if [ $attempt_num -eq $max_attempts ]; then
echo "${log_prefix} Unable to connect to database."
exit 1
else
echo "${log_prefix} Attempt $attempt_num failed! Trying again in 5 seconds..."
fi
attempt_num=$(("$attempt_num" + 1))
sleep 5
done done
echo "${LOG_PREFIX} Connected to MariaDB" echo "Connected to MariaDB"
} }
if [[ "${PAPERLESS_DBENGINE:-}" == "mariadb" ]]; then if [[ "${PAPERLESS_DBENGINE}" == "mariadb" ]]; then
echo "${log_prefix} Waiting for MariaDB to report ready"
wait_for_mariadb wait_for_mariadb
elif [[ -n "${PAPERLESS_DBHOST:-}" ]]; then elif [[ -n "${PAPERLESS_DBHOST}" ]]; then
echo "${log_prefix} Waiting for postgresql to report ready"
wait_for_postgres wait_for_postgres
fi fi
echo "${LOG_PREFIX} Database is ready" echo "${log_prefix} Database is ready"

View File

@@ -10,11 +10,11 @@ export GRANIAN_WORKERS=${GRANIAN_WORKERS:-${PAPERLESS_WEBSERVER_WORKERS:-1}}
# Only set GRANIAN_URL_PATH_PREFIX if PAPERLESS_FORCE_SCRIPT_NAME is set # Only set GRANIAN_URL_PATH_PREFIX if PAPERLESS_FORCE_SCRIPT_NAME is set
if [[ -n "${PAPERLESS_FORCE_SCRIPT_NAME}" ]]; then if [[ -n "${PAPERLESS_FORCE_SCRIPT_NAME}" ]]; then
export GRANIAN_URL_PATH_PREFIX=${PAPERLESS_FORCE_SCRIPT_NAME} export GRANIAN_URL_PATH_PREFIX=${PAPERLESS_FORCE_SCRIPT_NAME}
fi fi
if [[ -n "${USER_IS_NON_ROOT}" ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
exec granian --interface asginl --ws --loop uvloop "paperless.asgi:application" exec granian --interface asginl --ws --loop uvloop "paperless.asgi:application"
else else
exec s6-setuidgid paperless granian --interface asginl --ws --loop uvloop "paperless.asgi:application" exec s6-setuidgid paperless granian --interface asginl --ws --loop uvloop "paperless.asgi:application"
fi fi

View File

@@ -1,167 +0,0 @@
#!/usr/bin/env python3
"""
File deduplication script that replaces identical files with symlinks.
Uses SHA256 hashing to identify duplicate files.
"""
import hashlib
from collections import defaultdict
from pathlib import Path
import click
import humanize
def calculate_sha256(filepath: Path) -> str | None:
sha256_hash = hashlib.sha256()
try:
with filepath.open("rb") as f:
# Read file in chunks to handle large files efficiently
while chunk := f.read(65536): # 64KB chunks
sha256_hash.update(chunk)
return sha256_hash.hexdigest()
except OSError as e:
click.echo(f"Error reading {filepath}: {e}", err=True)
return None
def find_duplicate_files(directory: Path) -> dict[str, list[Path]]:
"""
Recursively scan directory and group files by their SHA256 hash.
Returns a dictionary mapping hash -> list of file paths.
"""
hash_to_files: dict[str, list[Path]] = defaultdict(list)
for filepath in directory.rglob("*"):
# Skip symlinks
if filepath.is_symlink():
continue
# Skip if not a regular file
if not filepath.is_file():
continue
file_hash = calculate_sha256(filepath)
if file_hash:
hash_to_files[file_hash].append(filepath)
# Filter to only return hashes with duplicates
return {h: files for h, files in hash_to_files.items() if len(files) > 1}
def replace_with_symlinks(
duplicate_groups: dict[str, list[Path]],
*,
dry_run: bool = False,
) -> tuple[int, int]:
"""
Replace duplicate files with symlinks to the first occurrence.
Returns (number_of_files_replaced, space_saved_in_bytes).
"""
total_duplicates = 0
space_saved = 0
for file_hash, file_list in duplicate_groups.items():
# Keep the first file as the original, replace others with symlinks
original_file = file_list[0]
duplicates = file_list[1:]
click.echo(f"Found {len(duplicates)} duplicate(s) of: {original_file}")
for duplicate in duplicates:
try:
# Get file size before deletion
file_size = duplicate.stat().st_size
if dry_run:
click.echo(f" [DRY RUN] Would replace: {duplicate}")
else:
# Remove the duplicate file
duplicate.unlink()
# Create relative symlink if possible, otherwise absolute
try:
# Try to create a relative symlink
rel_path = original_file.relative_to(duplicate.parent)
duplicate.symlink_to(rel_path)
click.echo(f" Replaced: {duplicate} -> {rel_path}")
except ValueError:
# Fall back to absolute path
duplicate.symlink_to(original_file.resolve())
click.echo(f" Replaced: {duplicate} -> {original_file}")
space_saved += file_size
total_duplicates += 1
except OSError as e:
click.echo(f" Error replacing {duplicate}: {e}", err=True)
return total_duplicates, space_saved
@click.command()
@click.argument(
"directory",
type=click.Path(
exists=True,
file_okay=False,
dir_okay=True,
readable=True,
path_type=Path,
),
)
@click.option(
"--dry-run",
is_flag=True,
help="Show what would be done without making changes",
)
@click.option("--verbose", "-v", is_flag=True, help="Show verbose output")
def deduplicate(directory: Path, *, dry_run: bool, verbose: bool) -> None:
"""
Recursively search DIRECTORY for identical files and replace them with symlinks.
Uses SHA256 hashing to identify duplicate files. The first occurrence of each
unique file is kept, and all duplicates are replaced with symlinks pointing to it.
"""
directory = directory.resolve()
click.echo(f"Scanning directory: {directory}")
if dry_run:
click.echo("Running in DRY RUN mode - no changes will be made")
# Find all duplicate files
click.echo("Calculating file hashes...")
duplicate_groups = find_duplicate_files(directory)
if not duplicate_groups:
click.echo("No duplicate files found!")
return
total_files = sum(len(files) - 1 for files in duplicate_groups.values())
click.echo(
f"Found {len(duplicate_groups)} group(s) of duplicates "
f"({total_files} files to deduplicate)",
)
if verbose:
for file_hash, files in duplicate_groups.items():
click.echo(f"Hash: {file_hash}")
for f in files:
click.echo(f" - {f}")
# Replace duplicates with symlinks
click.echo("Processing duplicates...")
num_replaced, space_saved = replace_with_symlinks(duplicate_groups, dry_run=dry_run)
# Summary
click.echo(
f"{'Would replace' if dry_run else 'Replaced'} "
f"{num_replaced} duplicate file(s)",
)
if not dry_run:
click.echo(f"Space saved: {humanize.naturalsize(space_saved, binary=True)}")
if __name__ == "__main__":
deduplicate()

View File

@@ -1,14 +0,0 @@
#!/command/with-contenv /usr/bin/bash
# shellcheck shell=bash
set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_llmindex "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_llmindex "$@"
else
echo "Unknown user."
fi

View File

@@ -294,13 +294,6 @@ The following methods are supported:
- `"delete_original": true` to delete the original documents after editing. - `"delete_original": true` to delete the original documents after editing.
- `"update_document": true` to update the existing document with the edited PDF. - `"update_document": true` to update the existing document with the edited PDF.
- `"include_metadata": true` to copy metadata from the original document to the edited document. - `"include_metadata": true` to copy metadata from the original document to the edited document.
- `remove_password`
- Requires `parameters`:
- `"password": "PASSWORD_STRING"` The password to remove from the PDF documents.
- Optional `parameters`:
- `"update_document": true` to replace the existing document with the password-less PDF.
- `"delete_original": true` to delete the original document after editing.
- `"include_metadata": true` to copy metadata from the original document to the new password-less document.
- `merge` - `merge`
- No additional `parameters` required. - No additional `parameters` required.
- The ordering of the merged document is determined by the list of IDs. - The ordering of the merged document is determined by the list of IDs.

View File

@@ -1,462 +1,5 @@
# Changelog # Changelog
## paperless-ngx 2.20.3
## paperless-ngx 2.20.2
### Features / Enhancements
- Tweakhancement: dim inactive users in users-groups list [@shamoon](https://github.com/shamoon) ([#11537](https://github.com/paperless-ngx/paperless-ngx/pull/11537))
### Bug Fixes
- Fix: Expanded SVG validation whitelist and additional checks [@stumpylog](https://github.com/stumpylog) ([#11590](https://github.com/paperless-ngx/paperless-ngx/pull/11590))
- Fix: normalize allowed SVG tag and attribute names, add version [@shamoon](https://github.com/shamoon) ([#11586](https://github.com/paperless-ngx/paperless-ngx/pull/11586))
- Fix: pass additional arguments to TagSerializer for permissions [@shamoon](https://github.com/shamoon) ([#11576](https://github.com/paperless-ngx/paperless-ngx/pull/11576))
### Maintenance
- Chore(deps): Bump actions/checkout from 5 to 6 in the actions group @[dependabot[bot]](https://github.com/apps/dependabot) ([#11515](https://github.com/paperless-ngx/paperless-ngx/pull/11515))
### Dependencies
<details>
<summary>6 changes</summary>
- Chore: update Angular dependencies to 20.3.15 [@shamoon](https://github.com/shamoon) ([#11568](https://github.com/paperless-ngx/paperless-ngx/pull/11568))
- Chore(deps): Bump actions/checkout from 5 to 6 in the actions group @[dependabot[bot]](https://github.com/apps/dependabot) ([#11515](https://github.com/paperless-ngx/paperless-ngx/pull/11515))
- Chore(deps-dev): Bump webpack from 5.102.1 to 5.103.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#11513](https://github.com/paperless-ngx/paperless-ngx/pull/11513))
- Chore(deps-dev): Bump @playwright/test from 1.56.1 to 1.57.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#11514](https://github.com/paperless-ngx/paperless-ngx/pull/11514))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11512](https://github.com/paperless-ngx/paperless-ngx/pull/11512))
- docker(deps): bump astral-sh/uv from 0.9.14-python3.12-trixie-slim to 0.9.15-python3.12-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11533](https://github.com/paperless-ngx/paperless-ngx/pull/11533))
</details>
### All App Changes
<details>
<summary>12 changes</summary>
- Fix: Expanded SVG validation whitelist and additional checks [@stumpylog](https://github.com/stumpylog) ([#11590](https://github.com/paperless-ngx/paperless-ngx/pull/11590))
- Fixhancement: pass ordering to tag children [@shamoon](https://github.com/shamoon) ([#11556](https://github.com/paperless-ngx/paperless-ngx/pull/11556))
- Performance: avoid unnecessary filename operations on bulk custom field updates [@shamoon](https://github.com/shamoon) ([#11558](https://github.com/paperless-ngx/paperless-ngx/pull/11558))
- Fix: normalize allowed SVG tag and attribute names, add version [@shamoon](https://github.com/shamoon) ([#11586](https://github.com/paperless-ngx/paperless-ngx/pull/11586))
- Chore: refactor workflows code [@shamoon](https://github.com/shamoon) ([#11563](https://github.com/paperless-ngx/paperless-ngx/pull/11563))
- Fix: pass additional arguments to TagSerializer for permissions [@shamoon](https://github.com/shamoon) ([#11576](https://github.com/paperless-ngx/paperless-ngx/pull/11576))
- Chore: update Angular dependencies to 20.3.15 [@shamoon](https://github.com/shamoon) ([#11568](https://github.com/paperless-ngx/paperless-ngx/pull/11568))
- Chore(deps-dev): Bump webpack from 5.102.1 to 5.103.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#11513](https://github.com/paperless-ngx/paperless-ngx/pull/11513))
- Chore(deps-dev): Bump @playwright/test from 1.56.1 to 1.57.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#11514](https://github.com/paperless-ngx/paperless-ngx/pull/11514))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11512](https://github.com/paperless-ngx/paperless-ngx/pull/11512))
- Tweakhancement: dim inactive users in users-groups list [@shamoon](https://github.com/shamoon) ([#11537](https://github.com/paperless-ngx/paperless-ngx/pull/11537))
- Chore: add some output of social login errors [@shamoon](https://github.com/shamoon) ([#11527](https://github.com/paperless-ngx/paperless-ngx/pull/11527))
</details>
## paperless-ngx 2.20.1
### Bug Fixes
- Fix: set search term when using advanced search from global search [@shamoon](https://github.com/shamoon) ([#11503](https://github.com/paperless-ngx/paperless-ngx/pull/11503))
- Fix: change async handling of select custom field updates [@shamoon](https://github.com/shamoon) ([#11490](https://github.com/paperless-ngx/paperless-ngx/pull/11490))
- Fix: skip SSL for MariaDB ping in init script [@danielrheinbay](https://github.com/danielrheinbay) ([#11491](https://github.com/paperless-ngx/paperless-ngx/pull/11491))
- Fix: handle allauth groups location breaking change [@shamoon](https://github.com/shamoon) ([#11471](https://github.com/paperless-ngx/paperless-ngx/pull/11471))
### Dependencies
- docker(deps): Bump astral-sh/uv from 0.9.10-python3.12-trixie-slim to 0.9.11-python3.12-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11450](https://github.com/paperless-ngx/paperless-ngx/pull/11450))
- Chore(deps): Bump @angular/common from 20.3.12 to 20.3.14 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#11481](https://github.com/paperless-ngx/paperless-ngx/pull/11481))
### All App Changes
<details>
<summary>4 changes</summary>
- Fix: set search term when using advanced search from global search [@shamoon](https://github.com/shamoon) ([#11503](https://github.com/paperless-ngx/paperless-ngx/pull/11503))
- Fix: change async handling of select custom field updates [@shamoon](https://github.com/shamoon) ([#11490](https://github.com/paperless-ngx/paperless-ngx/pull/11490))
- Chore(deps): Bump @angular/common from 20.3.12 to 20.3.14 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#11481](https://github.com/paperless-ngx/paperless-ngx/pull/11481))
- Fix: handle allauth groups location breaking change [@shamoon](https://github.com/shamoon) ([#11471](https://github.com/paperless-ngx/paperless-ngx/pull/11471))
</details>
## paperless-ngx 2.20.0
### Notable Changes
- Feature: Upgrade underlying Docker image to Trixie [@stumpylog](https://github.com/stumpylog) ([#10562](https://github.com/paperless-ngx/paperless-ngx/pull/10562))
### Features / Enhancements
- Feature: Upgrade underlying Docker image to Trixie [@stumpylog](https://github.com/stumpylog) ([#10562](https://github.com/paperless-ngx/paperless-ngx/pull/10562))
- Fixhancement: more log viewer improvements [@shamoon](https://github.com/shamoon) ([#11426](https://github.com/paperless-ngx/paperless-ngx/pull/11426))
- Performance: Replace duplicated static files with symlinks [@stumpylog](https://github.com/stumpylog) ([#11418](https://github.com/paperless-ngx/paperless-ngx/pull/11418))
- Enhancement: add more relative dates, support modified [@shamoon](https://github.com/shamoon) ([#11411](https://github.com/paperless-ngx/paperless-ngx/pull/11411))
- Performance: make move files after select custom field change async [@shamoon](https://github.com/shamoon) ([#11391](https://github.com/paperless-ngx/paperless-ngx/pull/11391))
- Enhancement: Use a better check for the MariaDB server to be ready [@stumpylog](https://github.com/stumpylog) ([#11396](https://github.com/paperless-ngx/paperless-ngx/pull/11396))
- Enhancement: speed-up docker container startup [@flrgh](https://github.com/flrgh) ([#11134](https://github.com/paperless-ngx/paperless-ngx/pull/11134))
### Bug Fixes
- Fix: prevent focus loss from change detection in cf query dropdown [@shamoon](https://github.com/shamoon) ([#11409](https://github.com/paperless-ngx/paperless-ngx/pull/11409))
- Fix: sort editing filterable dropdowns sooner [@shamoon](https://github.com/shamoon) ([#11404](https://github.com/paperless-ngx/paperless-ngx/pull/11404))
- Fix: support for custom field ordering w advanced search [@shamoon](https://github.com/shamoon) ([#11383](https://github.com/paperless-ngx/paperless-ngx/pull/11383))
### Maintenance
- Chore(deps): Bump the actions group with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11259](https://github.com/paperless-ngx/paperless-ngx/pull/11259))
### Dependencies
<details>
<summary>16 changes</summary>
- Chore: Upgrades psycopg to 3.2.12 [@stumpylog](https://github.com/stumpylog) ([#11420](https://github.com/paperless-ngx/paperless-ngx/pull/11420))
- Chore(deps-dev): Bump glob from 10.4.1 to 10.5.0 in /src/paperless_mail/templates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11413](https://github.com/paperless-ngx/paperless-ngx/pull/11413))
- docker-compose(deps): bump gotenberg/gotenberg from 8.24 to 8.25 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#11393](https://github.com/paperless-ngx/paperless-ngx/pull/11393))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 21 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11260](https://github.com/paperless-ngx/paperless-ngx/pull/11260))
- Chore(deps-dev): Bump @playwright/test from 1.55.1 to 1.56.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#11263](https://github.com/paperless-ngx/paperless-ngx/pull/11263))
- Chore(deps-dev): Bump webpack from 5.102.0 to 5.102.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#11264](https://github.com/paperless-ngx/paperless-ngx/pull/11264))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11262](https://github.com/paperless-ngx/paperless-ngx/pull/11262))
- Chore(deps-dev): Bump jest-preset-angular from 15.0.2 to 15.0.3 in /src-ui in the frontend-jest-dependencies group @[dependabot[bot]](https://github.com/apps/dependabot) ([#11261](https://github.com/paperless-ngx/paperless-ngx/pull/11261))
- Chore(deps-dev): Bump @types/node from 24.6.1 to 24.9.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#11265](https://github.com/paperless-ngx/paperless-ngx/pull/11265))
- Chore(deps): Bump the small-changes group across 1 directory with 11 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11337](https://github.com/paperless-ngx/paperless-ngx/pull/11337))
- Chore(deps): Bump django-auditlog from 3.2.1 to 3.3.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11021](https://github.com/paperless-ngx/paperless-ngx/pull/11021))
- Chore(deps): Bump the actions group with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11259](https://github.com/paperless-ngx/paperless-ngx/pull/11259))
- Chore(deps): Bump drf-spectacular-sidecar from 2025.9.1 to 2025.10.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11019](https://github.com/paperless-ngx/paperless-ngx/pull/11019))
- Chore(deps): Bump django-filter from 25.1 to 25.2 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11020](https://github.com/paperless-ngx/paperless-ngx/pull/11020))
- Chore(deps): Update django-allauth[mfa,socialaccount] requirement from ~=65.4.0 to ~=65.12.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11198](https://github.com/paperless-ngx/paperless-ngx/pull/11198))
- docker(deps): bump astral-sh/uv from 0.9.9-python3.12-bookworm-slim to 0.9.10-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11394](https://github.com/paperless-ngx/paperless-ngx/pull/11394))
</details>
### All App Changes
<details>
<summary>19 changes</summary>
- Fixhancement: more log viewer improvements [@shamoon](https://github.com/shamoon) ([#11426](https://github.com/paperless-ngx/paperless-ngx/pull/11426))
- Chore: Upgrades psycopg to 3.2.12 [@stumpylog](https://github.com/stumpylog) ([#11420](https://github.com/paperless-ngx/paperless-ngx/pull/11420))
- Enhancement: add more relative dates, support modified [@shamoon](https://github.com/shamoon) ([#11411](https://github.com/paperless-ngx/paperless-ngx/pull/11411))
- Chore(deps-dev): Bump glob from 10.4.1 to 10.5.0 in /src/paperless_mail/templates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11413](https://github.com/paperless-ngx/paperless-ngx/pull/11413))
- Performance: make move files after select custom field change async [@shamoon](https://github.com/shamoon) ([#11391](https://github.com/paperless-ngx/paperless-ngx/pull/11391))
- Fix: prevent focus loss from change detection in cf query dropdown [@shamoon](https://github.com/shamoon) ([#11409](https://github.com/paperless-ngx/paperless-ngx/pull/11409))
- Fix: sort editing filterable dropdowns sooner [@shamoon](https://github.com/shamoon) ([#11404](https://github.com/paperless-ngx/paperless-ngx/pull/11404))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 21 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11260](https://github.com/paperless-ngx/paperless-ngx/pull/11260))
- Chore(deps-dev): Bump @playwright/test from 1.55.1 to 1.56.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#11263](https://github.com/paperless-ngx/paperless-ngx/pull/11263))
- Chore(deps-dev): Bump webpack from 5.102.0 to 5.102.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#11264](https://github.com/paperless-ngx/paperless-ngx/pull/11264))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11262](https://github.com/paperless-ngx/paperless-ngx/pull/11262))
- Chore(deps-dev): Bump jest-preset-angular from 15.0.2 to 15.0.3 in /src-ui in the frontend-jest-dependencies group @[dependabot[bot]](https://github.com/apps/dependabot) ([#11261](https://github.com/paperless-ngx/paperless-ngx/pull/11261))
- Chore(deps-dev): Bump @types/node from 24.6.1 to 24.9.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#11265](https://github.com/paperless-ngx/paperless-ngx/pull/11265))
- Chore(deps): Bump the small-changes group across 1 directory with 11 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11337](https://github.com/paperless-ngx/paperless-ngx/pull/11337))
- Chore(deps): Bump django-auditlog from 3.2.1 to 3.3.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11021](https://github.com/paperless-ngx/paperless-ngx/pull/11021))
- Chore(deps): Bump drf-spectacular-sidecar from 2025.9.1 to 2025.10.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11019](https://github.com/paperless-ngx/paperless-ngx/pull/11019))
- Chore(deps): Bump django-filter from 25.1 to 25.2 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11020](https://github.com/paperless-ngx/paperless-ngx/pull/11020))
- Chore(deps): Update django-allauth[mfa,socialaccount] requirement from ~=65.4.0 to ~=65.12.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11198](https://github.com/paperless-ngx/paperless-ngx/pull/11198))
- Fix: support for custom field ordering w advanced search [@shamoon](https://github.com/shamoon) ([#11383](https://github.com/paperless-ngx/paperless-ngx/pull/11383))
</details>
## paperless-ngx 2.19.6
### Bug Fixes
- Chore: include password validation on user edit [@shamoon](https://github.com/shamoon) ([#11308](https://github.com/paperless-ngx/paperless-ngx/pull/11308))
- Fix: include BASE_URL when constructing for workflows [@ebardsley](https://github.com/ebardsley) ([#11360](https://github.com/paperless-ngx/paperless-ngx/pull/11360))
- Fixhancement: refactor email attachment logic [@shamoon](https://github.com/shamoon) ([#11336](https://github.com/paperless-ngx/paperless-ngx/pull/11336))
- Fixhancement: trim whitespace for some text searches [@shamoon](https://github.com/shamoon) ([#11357](https://github.com/paperless-ngx/paperless-ngx/pull/11357))
- Fix: update Outlook refresh token when refreshed [@shamoon](https://github.com/shamoon) ([#11341](https://github.com/paperless-ngx/paperless-ngx/pull/11341))
- Fix: only cache remote version data for version checking [@shamoon](https://github.com/shamoon) ([#11320](https://github.com/paperless-ngx/paperless-ngx/pull/11320))
- Fix: include replace none logic in storage path preview, improve jinja conditionals for empty metadata [@shamoon](https://github.com/shamoon) ([#11315](https://github.com/paperless-ngx/paperless-ngx/pull/11315))
### Dependencies
- docker(deps): bump astral-sh/uv from 0.9.7-python3.12-bookworm-slim to 0.9.9-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11338](https://github.com/paperless-ngx/paperless-ngx/pull/11338))
### All App Changes
<details>
<summary>7 changes</summary>
- Fix: include BASE_URL when constructing for workflows [@ebardsley](https://github.com/ebardsley) ([#11360](https://github.com/paperless-ngx/paperless-ngx/pull/11360))
- Fixhancement: refactor email attachment logic [@shamoon](https://github.com/shamoon) ([#11336](https://github.com/paperless-ngx/paperless-ngx/pull/11336))
- Fixhancement: trim whitespace for some text searches [@shamoon](https://github.com/shamoon) ([#11357](https://github.com/paperless-ngx/paperless-ngx/pull/11357))
- Fix: update Outlook refresh token when refreshed [@shamoon](https://github.com/shamoon) ([#11341](https://github.com/paperless-ngx/paperless-ngx/pull/11341))
- Fix: only cache remote version data for version checking [@shamoon](https://github.com/shamoon) ([#11320](https://github.com/paperless-ngx/paperless-ngx/pull/11320))
- Fix: include replace none logic in storage path preview, improve jinja conditionals for empty metadata [@shamoon](https://github.com/shamoon) ([#11315](https://github.com/paperless-ngx/paperless-ngx/pull/11315))
- Chore: include password validation on user edit [@shamoon](https://github.com/shamoon) ([#11308](https://github.com/paperless-ngx/paperless-ngx/pull/11308))
</details>
## paperless-ngx 2.19.5
### Bug Fixes
- Fix: ensure custom field query propagation, change detection [@shamoon](https://github.com/shamoon) ([#11291](https://github.com/paperless-ngx/paperless-ngx/pull/11291))
### Dependencies
- docker(deps): Bump astral-sh/uv from 0.9.4-python3.12-bookworm-slim to 0.9.7-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11283](https://github.com/paperless-ngx/paperless-ngx/pull/11283))
### All App Changes
- Fix: ensure custom field query propagation, change detection [@shamoon](https://github.com/shamoon) ([#11291](https://github.com/paperless-ngx/paperless-ngx/pull/11291))
## paperless-ngx 2.19.4
### Bug Fixes
- Fix: use original_file when attaching docs to workflow emails with added trigger [@shamoon](https://github.com/shamoon) ([#11266](https://github.com/paperless-ngx/paperless-ngx/pull/11266))
- Fix: mark 'Select' button in doc list for translation [@shamoon](https://github.com/shamoon) ([#11278](https://github.com/paperless-ngx/paperless-ngx/pull/11278))
- Fix: respect fields parameter for created field [@shamoon](https://github.com/shamoon) ([#11251](https://github.com/paperless-ngx/paperless-ngx/pull/11251))
- Fix: improve legibility of processed mail error popover in light mode [@shamoon](https://github.com/shamoon) ([#11258](https://github.com/paperless-ngx/paperless-ngx/pull/11258))
- Fixhancement: truncate large logs, improve auto-scroll [@shamoon](https://github.com/shamoon) ([#11239](https://github.com/paperless-ngx/paperless-ngx/pull/11239))
- Chore: add max-height and overflow to processedmail error popover [@shamoon](https://github.com/shamoon) ([#11252](https://github.com/paperless-ngx/paperless-ngx/pull/11252))
- Fix: delay iframe DOM removal, handle onafterprint error for print in FF [@shamoon](https://github.com/shamoon) ([#11237](https://github.com/paperless-ngx/paperless-ngx/pull/11237))
- Fix: de-deduplicate children in tag list when filtering [@shamoon](https://github.com/shamoon) ([#11229](https://github.com/paperless-ngx/paperless-ngx/pull/11229))
### Performance
- Performance: re-enable virtual scroll, bump ng-select [@shamoon](https://github.com/shamoon) ([#11279](https://github.com/paperless-ngx/paperless-ngx/pull/11279))
- Performance: use virtual scroll container and log level parsing for logs view [@MickLesk](https://github.com/MickLesk) ([#11233](https://github.com/paperless-ngx/paperless-ngx/pull/11233))
### All App Changes
<details>
<summary>11 changes</summary>
- Performance: re-enable virtual scroll, bump ng-select [@shamoon](https://github.com/shamoon) ([#11279](https://github.com/paperless-ngx/paperless-ngx/pull/11279))
- Fix: use original_file when attaching docs to workflow emails with added trigger [@shamoon](https://github.com/shamoon) ([#11266](https://github.com/paperless-ngx/paperless-ngx/pull/11266))
- Fix: mark 'Select' button in doc list for translation [@shamoon](https://github.com/shamoon) ([#11278](https://github.com/paperless-ngx/paperless-ngx/pull/11278))
- Fix: respect fields parameter for created field [@shamoon](https://github.com/shamoon) ([#11251](https://github.com/paperless-ngx/paperless-ngx/pull/11251))
- Fix: improve legibility of processed mail error popover in light mode [@shamoon](https://github.com/shamoon) ([#11258](https://github.com/paperless-ngx/paperless-ngx/pull/11258))
- Fixhancement: truncate large logs, improve auto-scroll [@shamoon](https://github.com/shamoon) ([#11239](https://github.com/paperless-ngx/paperless-ngx/pull/11239))
- Chore: add max-height and overflow to processedmail error popover [@shamoon](https://github.com/shamoon) ([#11252](https://github.com/paperless-ngx/paperless-ngx/pull/11252))
- Fix: delay iframe DOM removal, handle onafterprint error for print in FF [@shamoon](https://github.com/shamoon) ([#11237](https://github.com/paperless-ngx/paperless-ngx/pull/11237))
- Performance: use virtual scroll container and log level parsing for logs view [@MickLesk](https://github.com/MickLesk) ([#11233](https://github.com/paperless-ngx/paperless-ngx/pull/11233))
- Chore: cache Github version check for 15 minutes [@shamoon](https://github.com/shamoon) ([#11235](https://github.com/paperless-ngx/paperless-ngx/pull/11235))
- Fix: de-deduplicate children in tag list when filtering [@shamoon](https://github.com/shamoon) ([#11229](https://github.com/paperless-ngx/paperless-ngx/pull/11229))
</details>
## paperless-ngx 2.19.3
### Bug Fixes
- Fix: remove unnecessary permission requirements for new email endpoint [@shamoon](https://github.com/shamoon) ([#11215](https://github.com/paperless-ngx/paperless-ngx/pull/11215))
- Fix: refactor nested sorting in filterable dropdowns [@shamoon](https://github.com/shamoon) ([#11214](https://github.com/paperless-ngx/paperless-ngx/pull/11214))
- Fix: add root tag filtering for tag list page consistency, fix toggle all [@shamoon](https://github.com/shamoon) ([#11208](https://github.com/paperless-ngx/paperless-ngx/pull/11208))
- Fix: support ConsumableDocument in email attachments [@shamoon](https://github.com/shamoon) ([#11196](https://github.com/paperless-ngx/paperless-ngx/pull/11196))
- Fix: add missing import for ConfirmButtonComponent in user-edit-dialog [@shamoon](https://github.com/shamoon) ([#11167](https://github.com/paperless-ngx/paperless-ngx/pull/11167))
- Fix: resolve migration warning in 2.19.2 [@shamoon](https://github.com/shamoon) ([#11157](https://github.com/paperless-ngx/paperless-ngx/pull/11157))
### Changes
- Change: make workflow action only title draggable [@shamoon](https://github.com/shamoon) ([#11209](https://github.com/paperless-ngx/paperless-ngx/pull/11209))
- Change: change workflowrun to softdeletemodel [@shamoon](https://github.com/shamoon) ([#11194](https://github.com/paperless-ngx/paperless-ngx/pull/11194))
### Dependencies
- Chore(deps): Bump django from 5.2.6 to 5.2.7 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11200](https://github.com/paperless-ngx/paperless-ngx/pull/11200))
### All App Changes
<details>
<summary>9 changes</summary>
- Chore(deps): Bump django from 5.2.6 to 5.2.7 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11200](https://github.com/paperless-ngx/paperless-ngx/pull/11200))
- Fix: remove unnecessary permission requirements for new email endpoint [@shamoon](https://github.com/shamoon) ([#11215](https://github.com/paperless-ngx/paperless-ngx/pull/11215))
- Fix: refactor nested sorting in filterable dropdowns [@shamoon](https://github.com/shamoon) ([#11214](https://github.com/paperless-ngx/paperless-ngx/pull/11214))
- Fix: add root tag filtering for tag list page consistency, fix toggle all [@shamoon](https://github.com/shamoon) ([#11208](https://github.com/paperless-ngx/paperless-ngx/pull/11208))
- Change: make workflow action only title draggable [@shamoon](https://github.com/shamoon) ([#11209](https://github.com/paperless-ngx/paperless-ngx/pull/11209))
- Change: change workflowrun to softdeletemodel [@shamoon](https://github.com/shamoon) ([#11194](https://github.com/paperless-ngx/paperless-ngx/pull/11194))
- Chore: Minor migration optimization for workflow titles [@stumpylog](https://github.com/stumpylog) ([#11197](https://github.com/paperless-ngx/paperless-ngx/pull/11197))
- Fix: support ConsumableDocument in email attachments [@shamoon](https://github.com/shamoon) ([#11196](https://github.com/paperless-ngx/paperless-ngx/pull/11196))
- Fix: add missing import for ConfirmButtonComponent in user-edit-dialog [@shamoon](https://github.com/shamoon) ([#11167](https://github.com/paperless-ngx/paperless-ngx/pull/11167))
- Fix: resolve migration warning in 2.19.2 [@shamoon](https://github.com/shamoon) ([#11157](https://github.com/paperless-ngx/paperless-ngx/pull/11157))
</details>
## paperless-ngx 2.19.2
### Features / Enhancements
- Fixhancement: display loading status for tags instead of 'Private' [@shamoon](https://github.com/shamoon) ([#11140](https://github.com/paperless-ngx/paperless-ngx/pull/11140))
### Bug Fixes
- Fix: Remove edit requirement for bulk email, show based on setting [@shamoon](https://github.com/shamoon) ([#11149](https://github.com/paperless-ngx/paperless-ngx/pull/11149))
- Fix: handle undefined IDs in getOriginalObject [@shamoon](https://github.com/shamoon) ([#11147](https://github.com/paperless-ngx/paperless-ngx/pull/11147))
### All App Changes
<details>
<summary>3 changes</summary>
- Fix: Remove edit requirement for bulk email, show based on setting [@shamoon](https://github.com/shamoon) ([#11149](https://github.com/paperless-ngx/paperless-ngx/pull/11149))
- Fix: handle undefined IDs in getOriginalObject [@shamoon](https://github.com/shamoon) ([#11147](https://github.com/paperless-ngx/paperless-ngx/pull/11147))
- Fixhancement: display loading status for tags instead of 'Private' [@shamoon](https://github.com/shamoon) ([#11140](https://github.com/paperless-ngx/paperless-ngx/pull/11140))
</details>
## paperless-ngx 2.19.1
### Bug Fixes
- Fix: skip workflow title migration for empty titles [@shamoon](https://github.com/shamoon) ([#11136](https://github.com/paperless-ngx/paperless-ngx/pull/11136))
- Fix: restore workflow title migration [@shamoon](https://github.com/shamoon) ([#11131](https://github.com/paperless-ngx/paperless-ngx/pull/11131))
- Fix: retrieve document_count for tag children [@shamoon](https://github.com/shamoon) ([#11125](https://github.com/paperless-ngx/paperless-ngx/pull/11125))
- Fix: move hierarchical order logic in dropdown sorting [@shamoon](https://github.com/shamoon) ([#11128](https://github.com/paperless-ngx/paperless-ngx/pull/11128))
- Fix: use original object for children in tag list [@shamoon](https://github.com/shamoon) ([#11127](https://github.com/paperless-ngx/paperless-ngx/pull/11127))
- Fix: dont display or fetch users or groups with insufficient perms [@shamoon](https://github.com/shamoon) ([#11111](https://github.com/paperless-ngx/paperless-ngx/pull/11111))
### All App Changes
<details>
<summary>6 changes</summary>
- Fix: skip workflow title migration for empty titles [@shamoon](https://github.com/shamoon) ([#11136](https://github.com/paperless-ngx/paperless-ngx/pull/11136))
- Fix: restore workflow title migration [@shamoon](https://github.com/shamoon) ([#11131](https://github.com/paperless-ngx/paperless-ngx/pull/11131))
- Fix: retrieve document_count for tag children [@shamoon](https://github.com/shamoon) ([#11125](https://github.com/paperless-ngx/paperless-ngx/pull/11125))
- Fix: move hierarchical order logic in dropdown sorting [@shamoon](https://github.com/shamoon) ([#11128](https://github.com/paperless-ngx/paperless-ngx/pull/11128))
- Fix: use original object for children in tag list [@shamoon](https://github.com/shamoon) ([#11127](https://github.com/paperless-ngx/paperless-ngx/pull/11127))
- Fix: dont display or fetch users or groups with insufficient perms [@shamoon](https://github.com/shamoon) ([#11111](https://github.com/paperless-ngx/paperless-ngx/pull/11111))
</details>
## paperless-ngx 2.19.0
### Notable Changes
- Feature: Advanced Workflow Filters [@shamoon](https://github.com/shamoon) ([#11029](https://github.com/paperless-ngx/paperless-ngx/pull/11029))
- Feature: Nested Tags [@shamoon](https://github.com/shamoon) ([#10833](https://github.com/paperless-ngx/paperless-ngx/pull/10833))
### Features / Enhancements
- docker(deps): bump astral-sh/uv from 0.9.2-python3.12-bookworm-slim to 0.9.4-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11091](https://github.com/paperless-ngx/paperless-ngx/pull/11091))
- Enhancement: use friendly file names when emailing documents [@JanKleine](https://github.com/JanKleine) ([#11055](https://github.com/paperless-ngx/paperless-ngx/pull/11055))
- Feature: Advanced Workflow Filters [@shamoon](https://github.com/shamoon) ([#11029](https://github.com/paperless-ngx/paperless-ngx/pull/11029))
- docker(deps): Bump astral-sh/uv from 0.8.22-python3.12-bookworm-slim to 0.9.2-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11052](https://github.com/paperless-ngx/paperless-ngx/pull/11052))
- Feature: add support for emailing multiple documents [@JanKleine](https://github.com/JanKleine) ([#10666](https://github.com/paperless-ngx/paperless-ngx/pull/10666))
- Enhancement: ignore same files in sanity checker as consumer [@shamoon](https://github.com/shamoon) ([#10999](https://github.com/paperless-ngx/paperless-ngx/pull/10999))
- Enhancement: open color picker on swatch button click [@shamoon](https://github.com/shamoon) ([#10994](https://github.com/paperless-ngx/paperless-ngx/pull/10994))
- Performance: Cache django-guardian permissions when counting documents [@Merinorus](https://github.com/Merinorus) ([#10657](https://github.com/paperless-ngx/paperless-ngx/pull/10657))
- Tweakhancement: reorganize some list \& bulk editing buttons [@shamoon](https://github.com/shamoon) ([#10944](https://github.com/paperless-ngx/paperless-ngx/pull/10944))
- Enhancement: support workflow path matching of barcode-split documents [@DerRockWolf](https://github.com/DerRockWolf) ([#10723](https://github.com/paperless-ngx/paperless-ngx/pull/10723))
- Feature: processed mail UI [@shamoon](https://github.com/shamoon) ([#10866](https://github.com/paperless-ngx/paperless-ngx/pull/10866))
- Enhancement: support custom field values on post document [@shamoon](https://github.com/shamoon) ([#10859](https://github.com/paperless-ngx/paperless-ngx/pull/10859))
- Feature: Nested Tags [@shamoon](https://github.com/shamoon) ([#10833](https://github.com/paperless-ngx/paperless-ngx/pull/10833))
- Enhancement: long text custom field [@jojo2357](https://github.com/jojo2357) ([#10846](https://github.com/paperless-ngx/paperless-ngx/pull/10846))
- Enhancement: Add print button [@mpaletti](https://github.com/mpaletti) ([#10626](https://github.com/paperless-ngx/paperless-ngx/pull/10626))
- Enhancement: add storage path as workflow trigger filter @david-loe ([#10771](https://github.com/paperless-ngx/paperless-ngx/pull/10771))
- Enhancement: jinja template support for workflow title assignment [@sidey79](https://github.com/sidey79) ([#10700](https://github.com/paperless-ngx/paperless-ngx/pull/10700))
- Enhancement: Limit excessively long content length when computing suggestions [@Merinorus](https://github.com/Merinorus) ([#10656](https://github.com/paperless-ngx/paperless-ngx/pull/10656))
### Bug Fixes
- Fix: remove obsolete warning for custom field value index [@shamoon](https://github.com/shamoon) ([#11083](https://github.com/paperless-ngx/paperless-ngx/pull/11083))
- Fix: set min-height for drag-drop items container [@shamoon](https://github.com/shamoon) ([#11064](https://github.com/paperless-ngx/paperless-ngx/pull/11064))
- Fix custom field query dropdown toggle corners [@shamoon](https://github.com/shamoon) ([#11028](https://github.com/paperless-ngx/paperless-ngx/pull/11028))
- Fix: correct save hotkey action when no next document exists [@shamoon](https://github.com/shamoon) ([#11027](https://github.com/paperless-ngx/paperless-ngx/pull/11027))
- Fix: require only change permissions for task dismissal, add frontend error handling [@shamoon](https://github.com/shamoon) ([#11023](https://github.com/paperless-ngx/paperless-ngx/pull/11023))
- Chore(deps): Bulk upgrade backend dependencies [@stumpylog](https://github.com/stumpylog) ([#10971](https://github.com/paperless-ngx/paperless-ngx/pull/10971))
- Chore: remove Codecov token from CI workflow [@shamoon](https://github.com/shamoon) ([#10941](https://github.com/paperless-ngx/paperless-ngx/pull/10941))
- Fix: fix select option removal and pagination update [@shamoon](https://github.com/shamoon) ([#10933](https://github.com/paperless-ngx/paperless-ngx/pull/10933))
- Fix: skip fuzzy matching for empty document content [@shamoon](https://github.com/shamoon) ([#10914](https://github.com/paperless-ngx/paperless-ngx/pull/10914))
- Fix: add extra error handling to \_consume for file checks [@shamoon](https://github.com/shamoon) ([#10897](https://github.com/paperless-ngx/paperless-ngx/pull/10897))
- Fix: restore str celery beat schedule filename [@shamoon](https://github.com/shamoon) ([#10893](https://github.com/paperless-ngx/paperless-ngx/pull/10893))
- Fix: fix pdf editor hover rotate counterclockwise button [@shamoon](https://github.com/shamoon) ([#10848](https://github.com/paperless-ngx/paperless-ngx/pull/10848))
- Fix: warp long words in toast content [@shamoon](https://github.com/shamoon) ([#10839](https://github.com/paperless-ngx/paperless-ngx/pull/10839))
- Fix: fix error when bulk adding empty doc link custom fields [@shamoon](https://github.com/shamoon) ([#10832](https://github.com/paperless-ngx/paperless-ngx/pull/10832))
- Fix: set match value for correspondents created by mail rule [@shamoon](https://github.com/shamoon) ([#10820](https://github.com/paperless-ngx/paperless-ngx/pull/10820))
### Maintenance
- Chore(deps): Bump the actions group with 5 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10978](https://github.com/paperless-ngx/paperless-ngx/pull/10978))
- Chore: remove Codecov token from CI workflow [@shamoon](https://github.com/shamoon) ([#10941](https://github.com/paperless-ngx/paperless-ngx/pull/10941))
### Dependencies
<details>
<summary>29 changes</summary>
- docker(deps): bump astral-sh/uv from 0.9.2-python3.12-bookworm-slim to 0.9.4-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11091](https://github.com/paperless-ngx/paperless-ngx/pull/11091))
- docker-compose(deps): Bump gotenberg/gotenberg from 8.23 to 8.24 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#11050](https://github.com/paperless-ngx/paperless-ngx/pull/11050))
- Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11065](https://github.com/paperless-ngx/paperless-ngx/pull/11065))
- docker(deps): Bump astral-sh/uv from 0.8.22-python3.12-bookworm-slim to 0.9.2-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11052](https://github.com/paperless-ngx/paperless-ngx/pull/11052))
- Chore(deps): Bump the actions group with 5 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10978](https://github.com/paperless-ngx/paperless-ngx/pull/10978))
- Chore(deps): Bump uuid from 11.1.0 to 13.0.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10983](https://github.com/paperless-ngx/paperless-ngx/pull/10983))
- Chore(deps-dev): Bump @playwright/test from 1.55.0 to 1.55.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10982](https://github.com/paperless-ngx/paperless-ngx/pull/10982))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10981](https://github.com/paperless-ngx/paperless-ngx/pull/10981))
- Chore(deps-dev): Bump webpack from 5.101.3 to 5.102.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10986](https://github.com/paperless-ngx/paperless-ngx/pull/10986))
- Chore(deps-dev): Bump prettier-plugin-organize-imports from 4.2.0 to 4.3.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10985](https://github.com/paperless-ngx/paperless-ngx/pull/10985))
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10980](https://github.com/paperless-ngx/paperless-ngx/pull/10980))
- Chore(deps-dev): Bump @types/node from 24.3.0 to 24.6.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10984](https://github.com/paperless-ngx/paperless-ngx/pull/10984))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 21 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10979](https://github.com/paperless-ngx/paperless-ngx/pull/10979))
- docker-compose(deps): Bump library/postgres from 17 to 18 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#10965](https://github.com/paperless-ngx/paperless-ngx/pull/10965))
- Chore(deps): Bump the major-versions group with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10960](https://github.com/paperless-ngx/paperless-ngx/pull/10960))
- Chore(deps): Bump types-colorama from 0.4.15.20240311 to 0.4.15.20250801 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10961](https://github.com/paperless-ngx/paperless-ngx/pull/10961))
- Chore(deps): Bump django-guardian from 3.1.3 to 3.2.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10909](https://github.com/paperless-ngx/paperless-ngx/pull/10909))
- Chore(deps): Bump django-soft-delete from 1.0.19 to 1.0.21 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10908](https://github.com/paperless-ngx/paperless-ngx/pull/10908))
- Chore(deps): Bump whitenoise from 6.10.0 to 6.11.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10910](https://github.com/paperless-ngx/paperless-ngx/pull/10910))
- Chore(deps): Bump django-cors-headers from 4.8.0 to 4.9.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10907](https://github.com/paperless-ngx/paperless-ngx/pull/10907))
- docker(deps): bump astral-sh/uv from 0.8.17-python3.12-bookworm-slim to 0.8.19-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10906](https://github.com/paperless-ngx/paperless-ngx/pull/10906))
- docker(deps): Bump astral-sh/uv from 0.8.15-python3.12-bookworm-slim to 0.8.17-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10864](https://github.com/paperless-ngx/paperless-ngx/pull/10864))
- Chore(deps): Bump the small-changes group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10880](https://github.com/paperless-ngx/paperless-ngx/pull/10880))
- Chore(deps): Bump django-guardian from 3.1.2 to 3.1.3 in the django group @[dependabot[bot]](https://github.com/apps/dependabot) ([#10863](https://github.com/paperless-ngx/paperless-ngx/pull/10863))
- Chore(deps): Bump pytest-cov from 6.2.1 to 7.0.0 in the development group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#10822](https://github.com/paperless-ngx/paperless-ngx/pull/10822))
- Chore(deps): Bump the django group with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10811](https://github.com/paperless-ngx/paperless-ngx/pull/10811))
- docker-compose(deps): Bump gotenberg/gotenberg from 8.22 to 8.23 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#10812](https://github.com/paperless-ngx/paperless-ngx/pull/10812))
- Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10821](https://github.com/paperless-ngx/paperless-ngx/pull/10821))
- docker(deps): Bump astral-sh/uv from 0.8.13-python3.12-bookworm-slim to 0.8.15-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10810](https://github.com/paperless-ngx/paperless-ngx/pull/10810))
</details>
### All App Changes
<details>
<summary>51 changes</summary>
- Tweak: improve tag parent validation error handling [@shamoon](https://github.com/shamoon) ([#11096](https://github.com/paperless-ngx/paperless-ngx/pull/11096))
- Fix: remove obsolete warning for custom field value index [@shamoon](https://github.com/shamoon) ([#11083](https://github.com/paperless-ngx/paperless-ngx/pull/11083))
- Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11065](https://github.com/paperless-ngx/paperless-ngx/pull/11065))
- Enhancement: use friendly file names when emailing documents [@JanKleine](https://github.com/JanKleine) ([#11055](https://github.com/paperless-ngx/paperless-ngx/pull/11055))
- Fix: set min-height for drag-drop items container [@shamoon](https://github.com/shamoon) ([#11064](https://github.com/paperless-ngx/paperless-ngx/pull/11064))
- Feature: Advanced Workflow Filters [@shamoon](https://github.com/shamoon) ([#11029](https://github.com/paperless-ngx/paperless-ngx/pull/11029))
- Feature: add support for emailing multiple documents [@JanKleine](https://github.com/JanKleine) ([#10666](https://github.com/paperless-ngx/paperless-ngx/pull/10666))
- Fix custom field query dropdown toggle corners [@shamoon](https://github.com/shamoon) ([#11028](https://github.com/paperless-ngx/paperless-ngx/pull/11028))
- Fix: correct save hotkey action when no next document exists [@shamoon](https://github.com/shamoon) ([#11027](https://github.com/paperless-ngx/paperless-ngx/pull/11027))
- Fix: require only change permissions for task dismissal, add frontend error handling [@shamoon](https://github.com/shamoon) ([#11023](https://github.com/paperless-ngx/paperless-ngx/pull/11023))
- Enhancement: ignore same files in sanity checker as consumer [@shamoon](https://github.com/shamoon) ([#10999](https://github.com/paperless-ngx/paperless-ngx/pull/10999))
- Enhancement: open color picker on swatch button click [@shamoon](https://github.com/shamoon) ([#10994](https://github.com/paperless-ngx/paperless-ngx/pull/10994))
- Chore(deps): Bump uuid from 11.1.0 to 13.0.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10983](https://github.com/paperless-ngx/paperless-ngx/pull/10983))
- Chore(deps-dev): Bump @playwright/test from 1.55.0 to 1.55.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10982](https://github.com/paperless-ngx/paperless-ngx/pull/10982))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10981](https://github.com/paperless-ngx/paperless-ngx/pull/10981))
- Chore(deps-dev): Bump webpack from 5.101.3 to 5.102.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10986](https://github.com/paperless-ngx/paperless-ngx/pull/10986))
- Chore(deps-dev): Bump prettier-plugin-organize-imports from 4.2.0 to 4.3.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10985](https://github.com/paperless-ngx/paperless-ngx/pull/10985))
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10980](https://github.com/paperless-ngx/paperless-ngx/pull/10980))
- Chore(deps-dev): Bump @types/node from 24.3.0 to 24.6.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10984](https://github.com/paperless-ngx/paperless-ngx/pull/10984))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 21 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10979](https://github.com/paperless-ngx/paperless-ngx/pull/10979))
- Performance: Cache django-guardian permissions when counting documents [@Merinorus](https://github.com/Merinorus) ([#10657](https://github.com/paperless-ngx/paperless-ngx/pull/10657))
- Chore(deps): Bulk upgrade backend dependencies [@stumpylog](https://github.com/stumpylog) ([#10971](https://github.com/paperless-ngx/paperless-ngx/pull/10971))
- Chore(deps): Bump the major-versions group with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10960](https://github.com/paperless-ngx/paperless-ngx/pull/10960))
- Chore(deps): Bump types-colorama from 0.4.15.20240311 to 0.4.15.20250801 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10961](https://github.com/paperless-ngx/paperless-ngx/pull/10961))
- Chore(deps): Bump django-guardian from 3.1.3 to 3.2.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10909](https://github.com/paperless-ngx/paperless-ngx/pull/10909))
- Chore(deps): Bump django-soft-delete from 1.0.19 to 1.0.21 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10908](https://github.com/paperless-ngx/paperless-ngx/pull/10908))
- Chore(deps): Bump whitenoise from 6.10.0 to 6.11.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10910](https://github.com/paperless-ngx/paperless-ngx/pull/10910))
- Tweakhancement: reorganize some list \& bulk editing buttons [@shamoon](https://github.com/shamoon) ([#10944](https://github.com/paperless-ngx/paperless-ngx/pull/10944))
- Chore(deps): Bump django-cors-headers from 4.8.0 to 4.9.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10907](https://github.com/paperless-ngx/paperless-ngx/pull/10907))
- Fix: fix select option removal and pagination update [@shamoon](https://github.com/shamoon) ([#10933](https://github.com/paperless-ngx/paperless-ngx/pull/10933))
- Enhancement: support workflow path matching of barcode-split documents [@DerRockWolf](https://github.com/DerRockWolf) ([#10723](https://github.com/paperless-ngx/paperless-ngx/pull/10723))
- Fix: skip fuzzy matching for empty document content [@shamoon](https://github.com/shamoon) ([#10914](https://github.com/paperless-ngx/paperless-ngx/pull/10914))
- Feature: processed mail UI [@shamoon](https://github.com/shamoon) ([#10866](https://github.com/paperless-ngx/paperless-ngx/pull/10866))
- Fix: add extra error handling to \_consume for file checks [@shamoon](https://github.com/shamoon) ([#10897](https://github.com/paperless-ngx/paperless-ngx/pull/10897))
- Fix: restore str celery beat schedule filename [@shamoon](https://github.com/shamoon) ([#10893](https://github.com/paperless-ngx/paperless-ngx/pull/10893))
- Enhancement: support custom field values on post document [@shamoon](https://github.com/shamoon) ([#10859](https://github.com/paperless-ngx/paperless-ngx/pull/10859))
- Feature: Nested Tags [@shamoon](https://github.com/shamoon) ([#10833](https://github.com/paperless-ngx/paperless-ngx/pull/10833))
- Chore(deps): Bump the small-changes group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10880](https://github.com/paperless-ngx/paperless-ngx/pull/10880))
- Chore(deps): Bump django-guardian from 3.1.2 to 3.1.3 in the django group @[dependabot[bot]](https://github.com/apps/dependabot) ([#10863](https://github.com/paperless-ngx/paperless-ngx/pull/10863))
- Enhancement: long text custom field [@jojo2357](https://github.com/jojo2357) ([#10846](https://github.com/paperless-ngx/paperless-ngx/pull/10846))
- Fix: fix pdf editor hover rotate counterclockwise button [@shamoon](https://github.com/shamoon) ([#10848](https://github.com/paperless-ngx/paperless-ngx/pull/10848))
- Fix: warp long words in toast content [@shamoon](https://github.com/shamoon) ([#10839](https://github.com/paperless-ngx/paperless-ngx/pull/10839))
- Fix: fix error when bulk adding empty doc link custom fields [@shamoon](https://github.com/shamoon) ([#10832](https://github.com/paperless-ngx/paperless-ngx/pull/10832))
- Enhancement: Add print button [@mpaletti](https://github.com/mpaletti) ([#10626](https://github.com/paperless-ngx/paperless-ngx/pull/10626))
- Enhancement: add storage path as workflow trigger filter @david-loe ([#10771](https://github.com/paperless-ngx/paperless-ngx/pull/10771))
- Enhancement: jinja template support for workflow title assignment [@sidey79](https://github.com/sidey79) ([#10700](https://github.com/paperless-ngx/paperless-ngx/pull/10700))
- Chore(deps): Bump pytest-cov from 6.2.1 to 7.0.0 in the development group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#10822](https://github.com/paperless-ngx/paperless-ngx/pull/10822))
- Chore(deps): Bump the django group with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10811](https://github.com/paperless-ngx/paperless-ngx/pull/10811))
- Enhancement: Limit excessively long content length when computing suggestions [@Merinorus](https://github.com/Merinorus) ([#10656](https://github.com/paperless-ngx/paperless-ngx/pull/10656))
- Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10821](https://github.com/paperless-ngx/paperless-ngx/pull/10821))
- Fix: set match value for correspondents created by mail rule [@shamoon](https://github.com/shamoon) ([#10820](https://github.com/paperless-ngx/paperless-ngx/pull/10820))
</details>
## paperless-ngx 2.18.4 ## paperless-ngx 2.18.4
### Features / Enhancements ### Features / Enhancements

View File

@@ -980,10 +980,21 @@ paperless will process in parallel on a single document.
process very large documents faster, use a higher thread per worker process very large documents faster, use a higher thread per worker
count. count.
If unset, paperless uses `max(floor(cpu_count / PAPERLESS_TASK_WORKERS), 1)` The default is a balance between the two, according to your CPU core
threads per worker. The idea behind this is that as long as there are enough cores, count, with a slight favor towards threads per worker:
the total number of threads should less than or equal to the total number of (logical)
CPU cores. | CPU core count | Workers | Threads |
| -------------- | ------- | ------- |
| > 1 | > 1 | > 1 |
| > 2 | > 2 | > 1 |
| > 4 | > 2 | > 2 |
| > 6 | > 2 | > 3 |
| > 8 | > 2 | > 4 |
| > 12 | > 3 | > 4 |
| > 16 | > 4 | > 4 |
If you only specify PAPERLESS_TASK_WORKERS, paperless will adjust
PAPERLESS_THREADS_PER_WORKER automatically.
#### [`PAPERLESS_WORKER_TIMEOUT=<num>`](#PAPERLESS_WORKER_TIMEOUT) {#PAPERLESS_WORKER_TIMEOUT} #### [`PAPERLESS_WORKER_TIMEOUT=<num>`](#PAPERLESS_WORKER_TIMEOUT) {#PAPERLESS_WORKER_TIMEOUT}
@@ -1007,7 +1018,7 @@ still perform some basic text pre-processing before matching.
: See also `PAPERLESS_NLTK_DIR`. : See also `PAPERLESS_NLTK_DIR`.
Defaults to true, enabling the feature. Defaults to 1.
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES} #### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
@@ -1054,27 +1065,17 @@ should be a valid crontab(5) expression describing when to run.
#### [`PAPERLESS_SANITY_TASK_CRON=<cron expression>`](#PAPERLESS_SANITY_TASK_CRON) {#PAPERLESS_SANITY_TASK_CRON} #### [`PAPERLESS_SANITY_TASK_CRON=<cron expression>`](#PAPERLESS_SANITY_TASK_CRON) {#PAPERLESS_SANITY_TASK_CRON}
: Configures the scheduled sanity checker frequency. The value should be a : Configures the scheduled sanity checker frequency.
valid crontab(5) expression describing when to run.
: If set to the string "disable", the sanity checker will not run automatically. : If set to the string "disable", the sanity checker will not run automatically.
Defaults to `30 0 * * sun` or Sunday at 30 minutes past midnight. Defaults to `30 0 * * sun` or Sunday at 30 minutes past midnight.
#### [`PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON=<cron expression>`](#PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON) {#PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON}
: Configures the scheduled workflow check frequency. The value should be a
valid crontab(5) expression describing when to run.
: If set to the string "disable", scheduled workflows will not run.
Defaults to `5 */1 * * *` or every hour at 5 minutes past the hour.
#### [`PAPERLESS_ENABLE_COMPRESSION=<bool>`](#PAPERLESS_ENABLE_COMPRESSION) {#PAPERLESS_ENABLE_COMPRESSION} #### [`PAPERLESS_ENABLE_COMPRESSION=<bool>`](#PAPERLESS_ENABLE_COMPRESSION) {#PAPERLESS_ENABLE_COMPRESSION}
: Enables compression of the responses from the webserver. : Enables compression of the responses from the webserver.
: Defaults to true, enabling compression. : Defaults to 1, enabling compression.
!!! note !!! note
@@ -1281,6 +1282,30 @@ within your documents.
Defaults to false. Defaults to false.
## Workflow webhooks
#### [`PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES=<str>`](#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES) {#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES}
: A comma-separated list of allowed schemes for webhooks. This setting
controls which URL schemes are permitted for webhook URLs.
Defaults to `http,https`.
#### [`PAPERLESS_WEBHOOKS_ALLOWED_PORTS=<str>`](#PAPERLESS_WEBHOOKS_ALLOWED_PORTS) {#PAPERLESS_WEBHOOKS_ALLOWED_PORTS}
: A comma-separated list of allowed ports for webhooks. This setting
controls which ports are permitted for webhook URLs. For example, if you
set this to `80,443`, webhooks will only be sent to URLs that use these
ports.
Defaults to empty list, which allows all ports.
#### [`PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS=<bool>`](#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS) {#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS}
: If set to false, webhooks cannot be sent to internal URLs (e.g., localhost).
Defaults to true, which allows internal requests.
### Polling {#polling} ### Polling {#polling}
#### [`PAPERLESS_CONSUMER_POLLING=<num>`](#PAPERLESS_CONSUMER_POLLING) {#PAPERLESS_CONSUMER_POLLING} #### [`PAPERLESS_CONSUMER_POLLING=<num>`](#PAPERLESS_CONSUMER_POLLING) {#PAPERLESS_CONSUMER_POLLING}
@@ -1324,30 +1349,6 @@ consumers working on the same file. Configure this to prevent that.
Defaults to 0.5 seconds. Defaults to 0.5 seconds.
## Workflow webhooks
#### [`PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES=<str>`](#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES) {#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES}
: A comma-separated list of allowed schemes for webhooks. This setting
controls which URL schemes are permitted for webhook URLs.
Defaults to `http,https`.
#### [`PAPERLESS_WEBHOOKS_ALLOWED_PORTS=<str>`](#PAPERLESS_WEBHOOKS_ALLOWED_PORTS) {#PAPERLESS_WEBHOOKS_ALLOWED_PORTS}
: A comma-separated list of allowed ports for webhooks. This setting
controls which ports are permitted for webhook URLs. For example, if you
set this to `80,443`, webhooks will only be sent to URLs that use these
ports.
Defaults to empty list, which allows all ports.
#### [`PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS=<bool>`](#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS) {#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS}
: If set to false, webhooks cannot be sent to internal URLs (e.g., localhost).
Defaults to true, which allows internal requests.
## Incoming Mail {#incoming_mail} ## Incoming Mail {#incoming_mail}
### Email OAuth {#email_oauth} ### Email OAuth {#email_oauth}
@@ -1804,87 +1805,3 @@ password. All of these options come from their similarly-named [Django settings]
#### [`PAPERLESS_EMAIL_USE_SSL=<bool>`](#PAPERLESS_EMAIL_USE_SSL) {#PAPERLESS_EMAIL_USE_SSL} #### [`PAPERLESS_EMAIL_USE_SSL=<bool>`](#PAPERLESS_EMAIL_USE_SSL) {#PAPERLESS_EMAIL_USE_SSL}
: Defaults to false. : Defaults to false.
## Remote OCR
#### [`PAPERLESS_REMOTE_OCR_ENGINE=<str>`](#PAPERLESS_REMOTE_OCR_ENGINE) {#PAPERLESS_REMOTE_OCR_ENGINE}
: The remote OCR engine to use. Currently only Azure AI is supported as "azureai".
Defaults to None, which disables remote OCR.
#### [`PAPERLESS_REMOTE_OCR_API_KEY=<str>`](#PAPERLESS_REMOTE_OCR_API_KEY) {#PAPERLESS_REMOTE_OCR_API_KEY}
: The API key to use for the remote OCR engine.
Defaults to None.
#### [`PAPERLESS_REMOTE_OCR_ENDPOINT=<str>`](#PAPERLESS_REMOTE_OCR_ENDPOINT) {#PAPERLESS_REMOTE_OCR_ENDPOINT}
: The endpoint to use for the remote OCR engine. This is required for Azure AI.
Defaults to None.
## AI {#ai}
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
: Enables the AI features in Paperless. This includes the AI-based
suggestions. This setting is required to be set to true in order to use the AI features.
Defaults to false.
#### [`PAPERLESS_AI_LLM_EMBEDDING_BACKEND=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_BACKEND) {#PAPERLESS_AI_LLM_EMBEDDING_BACKEND}
: The embedding backend to use for RAG. This can be either "openai" or "huggingface".
Defaults to None.
#### [`PAPERLESS_AI_LLM_EMBEDDING_MODEL=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_MODEL) {#PAPERLESS_AI_LLM_EMBEDDING_MODEL}
: The model to use for the embedding backend for RAG. This can be set to any of the embedding models supported by the current embedding backend. If not supplied, defaults to "text-embedding-3-small" for OpenAI and "sentence-transformers/all-MiniLM-L6-v2" for Huggingface.
Defaults to None.
#### [`PAPERLESS_AI_BACKEND=<str>`](#PAPERLESS_AI_BACKEND) {#PAPERLESS_AI_BACKEND}
: The AI backend to use. This can be either "openai" or "ollama". If set to "ollama", the AI
features will be run locally on your machine. If set to "openai", the AI features will be run
using the OpenAI API. This setting is required to be set to use the AI features.
Defaults to None.
!!! note
The OpenAI API is a paid service. You will need to set up an OpenAI account and
will be charged for usage incurred by Paperless-ngx features and your document data
will (of course) be sent to the OpenAI API. Paperless-ngx does not endorse the use of the
OpenAI API in any way.
Refer to the OpenAI terms of service, and use at your own risk.
#### [`PAPERLESS_AI_LLM_MODEL=<str>`](#PAPERLESS_AI_LLM_MODEL) {#PAPERLESS_AI_LLM_MODEL}
: The model to use for the AI backend, i.e. "gpt-3.5-turbo", "gpt-4" or any of the models supported by the
current backend. If not supplied, defaults to "gpt-3.5-turbo" for OpenAI and "llama3" for Ollama.
Defaults to None.
#### [`PAPERLESS_AI_LLM_API_KEY=<str>`](#PAPERLESS_AI_LLM_API_KEY) {#PAPERLESS_AI_LLM_API_KEY}
: The API key to use for the AI backend. This is required for the OpenAI backend (optional for others).
Defaults to None.
#### [`PAPERLESS_AI_LLM_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_ENDPOINT) {#PAPERLESS_AI_LLM_ENDPOINT}
: The endpoint / url to use for the AI backend. This is required for the Ollama backend (optional for others).
Defaults to None.
#### [`PAPERLESS_AI_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_AI_LLM_INDEX_TASK_CRON) {#PAPERLESS_AI_LLM_INDEX_TASK_CRON}
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
AI is enabled and the LLM embedding backend is set.
Defaults to `10 2 * * *`, once per day.

View File

@@ -175,7 +175,7 @@ To add a new development package `uv add --dev <package>`
## Front end development ## Front end development
The front end is built using AngularJS. In order to get started, you need Node.js (version 24+) and The front end is built using AngularJS. In order to get started, you need Node.js (version 14.15+) and
`pnpm`. `pnpm`.
!!! note !!! note

View File

@@ -25,13 +25,11 @@ physical documents into a searchable online archive so you can keep, well, _less
## Features ## Features
- **Organize and index** your scanned documents with tags, correspondents, types, and more. - **Organize and index** your scanned documents with tags, correspondents, types, and more.
- _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way, unless you explicitly choose to do so. - _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way.
- Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images. - Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images.
- Utilizes the open-source Tesseract engine to recognize more than 100 languages. - Utilizes the open-source Tesseract engine to recognize more than 100 languages.
- _New!_ Supports remote OCR with Azure AI (opt-in).
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals. - Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
- Uses machine-learning to automatically add tags, correspondents and document types to your documents. - Uses machine-learning to automatically add tags, correspondents and document types to your documents.
- **New**: Paperless-ngx can now leverage AI (Large Language Models or LLMs) for document suggestions. This is an optional feature that can be enabled (and is disabled by default).
- Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more. - Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more.
- Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents. - Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents.
- **Beautiful, modern web application** that features: - **Beautiful, modern web application** that features:

View File

@@ -326,7 +326,7 @@ are released, dependency support is confirmed, etc.
!!! warning !!! warning
Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/). Ensure your Redis instance [is secured](https://redis.io/docs/getting-started/#securing-redis).
7. Create the following directories if they are missing: 7. Create the following directories if they are missing:

View File

@@ -278,28 +278,6 @@ Once setup, navigating to the email settings page in Paperless-ngx will allow yo
You can also submit a document using the REST API, see [POSTing documents](api.md#file-uploads) You can also submit a document using the REST API, see [POSTing documents](api.md#file-uploads)
for details. for details.
## Document Suggestions
Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user.
## AI Features
Paperless-ngx includes several features that use AI to enhance the document management experience. These features are optional and can be enabled or disabled in the settings. If you are using the AI features, you may want to also enable the "LLM index" feature, which supports Retrieval-Augmented Generation (RAG) designed to improve the quality of AI responses. The LLM index feature is not enabled by default and requires additional configuration.
!!! warning
Remember that Paperless-ngx will send document content to the AI provider you have configured, so consider the privacy implications of using these features, especially if using a remote model (e.g. OpenAI), instead of the default local model.
The AI features work by creating an embedding of the text content and metadata of documents, which is then used for various tasks such as similarity search and question answering. This uses the FAISS vector store.
### AI-Enhanced Suggestions
If enabled, Paperless-ngx can use an AI LLM model to suggest document titles, dates, tags, correspondents and document types for documents. This feature will always be "opt-in" and does not disable the existing classifier-based suggestion system. Currently, both remote (via the OpenAI API) and local (via Ollama) models are supported, see [configuration](configuration.md#ai) for details.
### Document Chat
Paperless-ngx can use an AI LLM model to answer questions about a document or across multiple documents. Again, this feature works best when RAG is enabled. The chat feature is available in the upper app toolbar and will switch between chatting across multiple documents or a single document based on the current view.
## Sharing documents from Paperless-ngx ## Sharing documents from Paperless-ngx
Paperless-ngx supports sharing documents with other users by assigning them [permissions](#object-permissions) Paperless-ngx supports sharing documents with other users by assigning them [permissions](#object-permissions)
@@ -465,10 +443,6 @@ flowchart TD
'Updated' 'Updated'
trigger(s)"} trigger(s)"}
scheduled{"Documents
matching
trigger(s)"}
A[New Document] --> consumption A[New Document] --> consumption
consumption --> |Yes| C[Workflow Actions Run] consumption --> |Yes| C[Workflow Actions Run]
consumption --> |No| D consumption --> |No| D
@@ -481,11 +455,6 @@ flowchart TD
updated --> |Yes| J[Workflow Actions Run] updated --> |Yes| J[Workflow Actions Run]
updated --> |No| K updated --> |No| K
J --> K[Document Saved] J --> K[Document Saved]
L[Scheduled Task Check<br/>hourly at :05] --> M[Get All Scheduled Triggers]
M --> scheduled
scheduled --> |Yes| N[Workflow Actions Run]
scheduled --> |No| O[Document Saved]
N --> O
``` ```
#### Filters {#workflow-trigger-filters} #### Filters {#workflow-trigger-filters}
@@ -493,24 +462,15 @@ flowchart TD
Workflows allow you to filter by: Workflows allow you to filter by:
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch - Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
- File name, including wildcards e.g. \*.pdf will apply to all pdfs. - File name, including wildcards e.g. \*.pdf will apply to all pdfs
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for - File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
example, automatically assigning documents to different owners based on the upload directory. example, automatically assigning documents to different owners based on the upload directory.
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source. - Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings. - Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
- Tags (`Added`, `Updated` and `Scheduled` triggers only). Filter for documents with any of the specified tags
There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers: - Document type (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this doc type
- Correspondent (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this correspondent
- Any Tags: Filter for documents with any of the specified tags. - Storage path (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this storage path
- All Tags: Filter for documents with all of the specified tags.
- No Tags: Filter for documents with none of the specified tags.
- Document type: Filter documents with this document type.
- Not Document types: Filter documents without any of these document types.
- Correspondent: Filter documents with this correspondent.
- Not Correspondents: Filter documents without any of these correspondents.
- Storage path: Filter documents with this storage path.
- Not Storage paths: Filter documents without any of these storage paths.
- Custom field query: Filter documents with a custom field query (the same as used for the document list filters).
### Workflow Actions ### Workflow Actions
@@ -584,7 +544,6 @@ applied. You can use the following placeholders in the template with any trigger
- `{{added_time}}`: added time in HH:MM format - `{{added_time}}`: added time in HH:MM format
- `{{original_filename}}`: original file name without extension - `{{original_filename}}`: original file name without extension
- `{{filename}}`: current file name without extension - `{{filename}}`: current file name without extension
- `{{doc_title}}`: current document title
The following placeholders are only available for "added" or "updated" triggers The following placeholders are only available for "added" or "updated" triggers
@@ -923,21 +882,6 @@ how regularly you intend to scan documents and use paperless.
performed the task associated with the document, move it to the performed the task associated with the document, move it to the
inbox. inbox.
## Remote OCR
!!! important
This feature is disabled by default and will always remain strictly "opt-in".
Paperless-ngx supports performing OCR on documents using remote services. At the moment, this is limited to
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details.
Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size
or page limitations (e.g. with a free tier).
## Architecture ## Architecture
Paperless-ngx consists of the following components: Paperless-ngx consists of the following components:

View File

@@ -374,7 +374,7 @@ fi
# of the provided folder # of the provided folder
if [[ -n $DATABASE_FOLDER ]] ; then if [[ -n $DATABASE_FOLDER ]] ; then
if [[ "$DATABASE_BACKEND" == "postgres" ]] ; then if [[ "$DATABASE_BACKEND" == "postgres" ]] ; then
sed -i "s#- pgdata:/var/lib/postgresql#- $DATABASE_FOLDER:/var/lib/postgresql#g" docker-compose.yml sed -i "s#- pgdata:/var/lib/postgresql/data#- $DATABASE_FOLDER:/var/lib/postgresql/data#g" docker-compose.yml
sed -i "/^\s*pgdata:/d" docker-compose.yml sed -i "/^\s*pgdata:/d" docker-compose.yml
elif [[ "$DATABASE_BACKEND" == "mariadb" ]]; then elif [[ "$DATABASE_BACKEND" == "mariadb" ]]; then
sed -i "s#- dbdata:/var/lib/mysql#- $DATABASE_FOLDER:/var/lib/mysql#g" docker-compose.yml sed -i "s#- dbdata:/var/lib/mysql#- $DATABASE_FOLDER:/var/lib/mysql#g" docker-compose.yml

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "2.20.3" version = "2.18.4"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
@@ -10,15 +10,13 @@ classifiers = [
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
] ]
# TODO: Move certain things to groups and then utilize that further # TODO: Move certain things to groups and then utilize that further
# This will allow testing to not install a webserver, mysql, etc # This will allow testing to not install a webserver, mysql, etc
dependencies = [ dependencies = [
"azure-ai-documentintelligence>=1.0.2",
"babel>=2.17", "babel>=2.17",
"bleach~=6.3.0", "bleach~=6.2.0",
"celery[redis]~=5.5.1", "celery[redis]~=5.5.1",
"channels~=4.2", "channels~=4.2",
"channels-redis~=4.2", "channels-redis~=4.2",
@@ -27,8 +25,8 @@ dependencies = [
# WARNING: django does not use semver. # WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes. # Only patch versions are guaranteed to not introduce breaking changes.
"django~=5.2.5", "django~=5.2.5",
"django-allauth[mfa,socialaccount]~=65.12.1", "django-allauth[socialaccount,mfa]~=65.4.0",
"django-auditlog~=3.3.0", "django-auditlog~=3.2.1",
"django-cachalot~=2.8.0", "django-cachalot~=2.8.0",
"django-celery-results~=2.6.0", "django-celery-results~=2.6.0",
"django-compression-middleware~=0.5.0", "django-compression-middleware~=0.5.0",
@@ -42,39 +40,29 @@ dependencies = [
"djangorestframework~=3.16", "djangorestframework~=3.16",
"djangorestframework-guardian~=0.4.0", "djangorestframework-guardian~=0.4.0",
"drf-spectacular~=0.28", "drf-spectacular~=0.28",
"drf-spectacular-sidecar~=2025.10.1", "drf-spectacular-sidecar~=2025.9.1",
"drf-writable-nested~=0.7.1", "drf-writable-nested~=0.7.1",
"faiss-cpu>=1.10", "filelock~=3.19.1",
"filelock~=3.20.0",
"flower~=2.0.1", "flower~=2.0.1",
"gotenberg-client~=0.12.0", "gotenberg-client~=0.11.0",
"httpx-oauth~=0.16", "httpx-oauth~=0.16",
"imap-tools~=1.11.0", "imap-tools~=1.11.0",
"inotifyrecursive~=0.3", "inotifyrecursive~=0.3",
"jinja2~=3.1.5", "jinja2~=3.1.5",
"langdetect~=1.0.9", "langdetect~=1.0.9",
"llama-index-core>=0.12.33.post1",
"llama-index-embeddings-huggingface>=0.5.3",
"llama-index-embeddings-openai>=0.3.1",
"llama-index-llms-ollama>=0.5.4",
"llama-index-llms-openai>=0.3.38",
"llama-index-vector-stores-faiss>=0.3",
"nltk~=3.9.1", "nltk~=3.9.1",
"ocrmypdf~=16.12.0", "ocrmypdf~=16.11.0",
"openai>=1.76",
"pathvalidate~=3.3.1", "pathvalidate~=3.3.1",
"pdf2image~=1.17.0", "pdf2image~=1.17.0",
"python-dateutil~=2.9.0", "python-dateutil~=2.9.0",
"python-dotenv~=1.2.1", "python-dotenv~=1.1.0",
"python-gnupg~=0.5.4", "python-gnupg~=0.5.4",
"python-ipware~=3.0.0", "python-ipware~=3.0.0",
"python-magic~=0.4.27", "python-magic~=0.4.27",
"pyzbar~=0.1.9", "pyzbar~=0.1.9",
"rapidfuzz~=3.14.0", "rapidfuzz~=3.14.0",
"redis[hiredis]~=5.2.1", "redis[hiredis]~=5.2.1",
"regex>=2025.9.18",
"scikit-learn~=1.7.0", "scikit-learn~=1.7.0",
"sentence-transformers>=4.1",
"setproctitle~=1.3.4", "setproctitle~=1.3.4",
"tika-client~=0.10.0", "tika-client~=0.10.0",
"tqdm~=4.67.1", "tqdm~=4.67.1",
@@ -88,10 +76,10 @@ optional-dependencies.mariadb = [
"mysqlclient~=2.2.7", "mysqlclient~=2.2.7",
] ]
optional-dependencies.postgres = [ optional-dependencies.postgres = [
"psycopg[c,pool]==3.2.12", "psycopg[c,pool]==3.2.9",
# Direct dependency for proper resolution of the pre-built wheels # Direct dependency for proper resolution of the pre-built wheels
"psycopg-c==3.2.12", "psycopg-c==3.2.9",
"psycopg-pool==3.2.7", "psycopg-pool==3.2.6",
] ]
optional-dependencies.webserver = [ optional-dependencies.webserver = [
"granian[uvloop]~=2.5.1", "granian[uvloop]~=2.5.1",
@@ -107,7 +95,7 @@ dev = [
docs = [ docs = [
"mkdocs-glightbox~=0.5.1", "mkdocs-glightbox~=0.5.1",
"mkdocs-material~=9.7.0", "mkdocs-material~=9.6.4",
] ]
testing = [ testing = [
@@ -126,9 +114,9 @@ testing = [
] ]
lint = [ lint = [
"pre-commit~=4.4.0", "pre-commit~=4.3.0",
"pre-commit-uv~=4.2.0", "pre-commit-uv~=4.1.3",
"ruff~=0.14.0", "ruff~=0.13.0",
] ]
typing = [ typing = [
@@ -150,25 +138,6 @@ typing = [
"types-tqdm", "types-tqdm",
] ]
[tool.uv]
required-version = ">=0.5.14"
package = false
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
]
[tool.uv.sources]
# Markers are chosen to select these almost exclusively when building the Docker image
psycopg-c = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-bookworm-3.2.12/psycopg_c-3.2.12-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-bookworm-3.2.12/psycopg_c-3.2.12-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
]
zxing-cpp = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
]
[tool.ruff] [tool.ruff]
target-version = "py310" target-version = "py310"
line-length = 88 line-length = 88
@@ -263,8 +232,6 @@ testpaths = [
"src/paperless_tesseract/tests/", "src/paperless_tesseract/tests/",
"src/paperless_tika/tests", "src/paperless_tika/tests",
"src/paperless_text/tests/", "src/paperless_text/tests/",
"src/paperless_remote/tests/",
"src/paperless_ai/tests",
] ]
addopts = [ addopts = [
"--pythonwarnings=all", "--pythonwarnings=all",
@@ -317,5 +284,24 @@ disallow_untyped_defs = true
warn_redundant_casts = true warn_redundant_casts = true
warn_unused_ignores = true warn_unused_ignores = true
[tool.uv]
required-version = ">=0.5.14"
package = false
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
]
[tool.uv.sources]
# Markers are chosen to select these almost exclusively when building the Docker image
psycopg-c = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.9/psycopg_c-3.2.9-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.9/psycopg_c-3.2.9-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
]
zxing-cpp = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
]
[tool.django-stubs] [tool.django-stubs]
django_settings_module = "paperless.settings" django_settings_module = "paperless.settings"

View File

@@ -31,7 +31,6 @@
"fi-FI": "src/locale/messages.fi_FI.xlf", "fi-FI": "src/locale/messages.fi_FI.xlf",
"fr-FR": "src/locale/messages.fr_FR.xlf", "fr-FR": "src/locale/messages.fr_FR.xlf",
"hu-HU": "src/locale/messages.hu_HU.xlf", "hu-HU": "src/locale/messages.hu_HU.xlf",
"id-ID": "src/locale/messages.id_ID.xlf",
"it-IT": "src/locale/messages.it_IT.xlf", "it-IT": "src/locale/messages.it_IT.xlf",
"ja-JP": "src/locale/messages.ja_JP.xlf", "ja-JP": "src/locale/messages.ja_JP.xlf",
"lb-LU": "src/locale/messages.lb_LU.xlf", "lb-LU": "src/locale/messages.lb_LU.xlf",
@@ -156,7 +155,16 @@
"builder": "@angular-builders/jest:run", "builder": "@angular-builders/jest:run",
"options": { "options": {
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"zoneless": false "assets": [
"src/favicon.ico",
"src/apple-touch-icon.png",
"src/assets",
"src/manifest.webmanifest"
],
"styles": [
"src/styles.scss"
],
"scripts": []
} }
}, },
"lint": { "lint": {

View File

@@ -1,23 +1,5 @@
const { createEsmPreset } = require('jest-preset-angular/presets')
const esmPreset = createEsmPreset({
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
})
module.exports = { module.exports = {
...esmPreset, preset: 'jest-preset-angular',
transform: {
...esmPreset.transform,
'^.+\\.(ts|js|mjs|html|svg)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
useESM: true,
},
],
},
setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'], setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
testPathIgnorePatterns: [ testPathIgnorePatterns: [
'/node_modules/', '/node_modules/',
@@ -26,10 +8,9 @@ module.exports = {
'abstract-paperless-service', 'abstract-paperless-service',
], ],
transformIgnorePatterns: [ transformIgnorePatterns: [
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|@angular/common/locales/.*\\.js$))', `<rootDir>/node_modules/.pnpm/(?!.*\\.mjs$|lodash-es|@angular\\+common.*locales)`,
], ],
moduleNameMapper: { moduleNameMapper: {
...esmPreset.moduleNameMapper,
'^src/(.*)': '<rootDir>/src/$1', '^src/(.*)': '<rootDir>/src/$1',
}, },
workerIdleMemoryLimit: '512MB', workerIdleMemoryLimit: '512MB',

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "2.20.3", "version": "2.18.4",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
@@ -11,17 +11,17 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^21.0.6", "@angular/cdk": "^20.2.6",
"@angular/common": "~21.0.8", "@angular/common": "~20.3.2",
"@angular/compiler": "~21.0.8", "@angular/compiler": "~20.3.2",
"@angular/core": "~21.0.8", "@angular/core": "~20.3.2",
"@angular/forms": "~21.0.8", "@angular/forms": "~20.3.2",
"@angular/localize": "~21.0.8", "@angular/localize": "~20.3.2",
"@angular/platform-browser": "~21.0.8", "@angular/platform-browser": "~20.3.2",
"@angular/platform-browser-dynamic": "~21.0.8", "@angular/platform-browser-dynamic": "~20.3.2",
"@angular/router": "~21.0.8", "@angular/router": "~20.3.2",
"@ng-bootstrap/ng-bootstrap": "^20.0.0", "@ng-bootstrap/ng-bootstrap": "^19.0.1",
"@ng-select/ng-select": "^21.1.4", "@ng-select/ng-select": "^20.2.2",
"@ngneat/dirty-check-forms": "^3.0.3", "@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
@@ -30,8 +30,8 @@
"ng2-pdf-viewer": "^10.4.0", "ng2-pdf-viewer": "^10.4.0",
"ngx-bootstrap-icons": "^1.9.3", "ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.1.0", "ngx-color": "^10.1.0",
"ngx-cookie-service": "^21.1.0", "ngx-cookie-service": "^20.1.0",
"ngx-device-detector": "^11.0.0", "ngx-device-detector": "^10.1.0",
"ngx-ui-tour-ng-bootstrap": "^17.0.1", "ngx-ui-tour-ng-bootstrap": "^17.0.1",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"tslib": "^2.8.1", "tslib": "^2.8.1",
@@ -40,35 +40,35 @@
"zone.js": "^0.15.1" "zone.js": "^0.15.1"
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "^21.0.0-beta.1", "@angular-builders/custom-webpack": "^20.0.0",
"@angular-builders/jest": "^21.0.0-beta.1", "@angular-builders/jest": "^20.0.0",
"@angular-devkit/core": "^21.0.5", "@angular-devkit/core": "^20.3.3",
"@angular-devkit/schematics": "^21.0.5", "@angular-devkit/schematics": "^20.3.3",
"@angular-eslint/builder": "21.1.0", "@angular-eslint/builder": "20.3.0",
"@angular-eslint/eslint-plugin": "21.1.0", "@angular-eslint/eslint-plugin": "20.3.0",
"@angular-eslint/eslint-plugin-template": "21.1.0", "@angular-eslint/eslint-plugin-template": "20.3.0",
"@angular-eslint/schematics": "21.1.0", "@angular-eslint/schematics": "20.3.0",
"@angular-eslint/template-parser": "21.1.0", "@angular-eslint/template-parser": "20.3.0",
"@angular/build": "^21.0.5", "@angular/build": "^20.3.3",
"@angular/cli": "~21.0.5", "@angular/cli": "~20.3.3",
"@angular/compiler-cli": "~21.0.8", "@angular/compiler-cli": "~20.3.2",
"@codecov/webpack-plugin": "^1.9.1", "@codecov/webpack-plugin": "^1.9.1",
"@playwright/test": "^1.57.0", "@playwright/test": "^1.55.1",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^24.10.1", "@types/node": "^24.6.1",
"@typescript-eslint/eslint-plugin": "^8.48.1", "@typescript-eslint/eslint-plugin": "^8.45.0",
"@typescript-eslint/parser": "^8.48.1", "@typescript-eslint/parser": "^8.45.0",
"@typescript-eslint/utils": "^8.48.1", "@typescript-eslint/utils": "^8.45.0",
"eslint": "^9.39.1", "eslint": "^9.36.0",
"jest": "30.2.0", "jest": "30.2.0",
"jest-environment-jsdom": "^30.2.0", "jest-environment-jsdom": "^30.2.0",
"jest-junit": "^16.0.0", "jest-junit": "^16.0.0",
"jest-preset-angular": "^16.0.0", "jest-preset-angular": "^15.0.2",
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",
"prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-organize-imports": "^4.3.0",
"ts-node": "~10.9.1", "ts-node": "~10.9.1",
"typescript": "^5.9.3", "typescript": "^5.8.3",
"webpack": "^5.103.0" "webpack": "^5.102.0"
}, },
"packageManager": "pnpm@10.17.1", "packageManager": "pnpm@10.17.1",
"pnpm": { "pnpm": {

6182
src-ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,6 @@ import localeFa from '@angular/common/locales/fa'
import localeFi from '@angular/common/locales/fi' import localeFi from '@angular/common/locales/fi'
import localeFr from '@angular/common/locales/fr' import localeFr from '@angular/common/locales/fr'
import localeHu from '@angular/common/locales/hu' import localeHu from '@angular/common/locales/hu'
import localeId from '@angular/common/locales/id'
import localeIt from '@angular/common/locales/it' import localeIt from '@angular/common/locales/it'
import localeJa from '@angular/common/locales/ja' import localeJa from '@angular/common/locales/ja'
import localeKo from '@angular/common/locales/ko' import localeKo from '@angular/common/locales/ko'
@@ -64,7 +63,6 @@ registerLocaleData(localeFa)
registerLocaleData(localeFi) registerLocaleData(localeFi)
registerLocaleData(localeFr) registerLocaleData(localeFr)
registerLocaleData(localeHu) registerLocaleData(localeHu)
registerLocaleData(localeId)
registerLocaleData(localeIt) registerLocaleData(localeIt)
registerLocaleData(localeJa) registerLocaleData(localeJa)
registerLocaleData(localeKo) registerLocaleData(localeKo)
@@ -147,10 +145,6 @@ HTMLCanvasElement.prototype.getContext = <
typeof HTMLCanvasElement.prototype.getContext typeof HTMLCanvasElement.prototype.getContext
>jest.fn() >jest.fn()
if (!HTMLElement.prototype.scrollTo) {
HTMLElement.prototype.scrollTo = jest.fn()
}
jest.mock('uuid', () => ({ jest.mock('uuid', () => ({
v4: jest.fn(() => v4: jest.fn(() =>
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char: string) => { 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char: string) => {

View File

@@ -35,12 +35,8 @@
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> } @case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> } @case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> } @case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
} }
</div> </div>
@if (option.note) {
<div class="form-text fst-italic">{{option.note}}</div>
}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -29,7 +29,6 @@ import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { FileComponent } from '../../common/input/file/file.component' import { FileComponent } from '../../common/input/file/file.component'
import { NumberComponent } from '../../common/input/number/number.component' import { NumberComponent } from '../../common/input/number/number.component'
import { PasswordComponent } from '../../common/input/password/password.component'
import { SelectComponent } from '../../common/input/select/select.component' import { SelectComponent } from '../../common/input/select/select.component'
import { SwitchComponent } from '../../common/input/switch/switch.component' import { SwitchComponent } from '../../common/input/switch/switch.component'
import { TextComponent } from '../../common/input/text/text.component' import { TextComponent } from '../../common/input/text/text.component'
@@ -47,7 +46,6 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
TextComponent, TextComponent,
NumberComponent, NumberComponent,
FileComponent, FileComponent,
PasswordComponent,
AsyncPipe, AsyncPipe,
NgbNavModule, NgbNavModule,
FormsModule, FormsModule,

View File

@@ -3,23 +3,9 @@
i18n-title i18n-title
info="Review the log files for the application and for email checking." info="Review the log files for the application and for email checking."
i18n-info> i18n-info>
<div class="input-group input-group-sm align-items-center"> <div class="form-check form-switch">
<div class="input-group input-group-sm me-3"> <input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
<span class="input-group-text text-muted" i18n>Show</span> <label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
<input
class="form-control"
type="number"
min="100"
step="100"
[(ngModel)]="limit"
(ngModelChange)="onLimitChange($event)"
style="width: 100px;">
<span class="input-group-text text-muted" i18n>lines</span>
</div>
<div class="form-check form-switch mt-1">
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div>
</div> </div>
</pngx-page-header> </pngx-page-header>
@@ -41,23 +27,16 @@
} }
</ul> </ul>
<div #logContainer class="bg-dark text-light font-monospace log-container p-3" (scroll)="onScroll()"> <div [ngbNavOutlet]="nav" class="mt-2"></div>
@if (loading && !logFiles.length) {
<div class="bg-dark p-3 text-light font-monospace log-container" #logContainer>
@if (loading && logFiles.length) {
<div> <div>
<div class="spinner-border spinner-border-sm me-2" role="status"></div> <div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container> <ng-container i18n>Loading...</ng-container>
</div> </div>
} @else { }
@for (log of logs; track log) { @for (log of logs; track $index) {
<p class="m-0 p-0" [ngClass]="'log-entry-' + log.level">{{log.message}}</p> <p class="m-0 p-0 log-entry-{{getLogLevel(log)}}">{{log}}</p>
}
} }
</div> </div>
<button
type="button"
class="btn btn-sm btn-secondary jump-to-bottom position-fixed bottom-0 end-0 m-5"
[class.visible]="showJumpToBottom"
(click)="scrollToBottom()"
>
<span i18n>Jump to bottom</span>
</button>

View File

@@ -16,21 +16,11 @@
} }
.log-container { .log-container {
height: calc(100vh - 190px); overflow-y: scroll;
overflow-y: auto; height: calc(100vh - 200px);
top: 70px;
p { p {
white-space: pre-wrap; white-space: pre-wrap;
} }
} }
.jump-to-bottom {
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease-in-out;
}
.jump-to-bottom.visible {
opacity: 1;
pointer-events: auto;
}

View File

@@ -1,8 +1,3 @@
import {
CdkVirtualScrollViewport,
ScrollingModule,
} from '@angular/cdk/scrolling'
import { CommonModule } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
@@ -43,9 +38,6 @@ describe('LogsComponent', () => {
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
LogsComponent, LogsComponent,
PageHeaderComponent, PageHeaderComponent,
CommonModule,
CdkVirtualScrollViewport,
ScrollingModule,
], ],
providers: [ providers: [
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
@@ -62,12 +54,13 @@ describe('LogsComponent', () => {
fixture = TestBed.createComponent(LogsComponent) fixture = TestBed.createComponent(LogsComponent)
component = fixture.componentInstance component = fixture.componentInstance
reloadSpy = jest.spyOn(component, 'reloadLogs') reloadSpy = jest.spyOn(component, 'reloadLogs')
window.HTMLElement.prototype.scroll = function () {} // mock scroll
jest.useFakeTimers() jest.useFakeTimers()
fixture.detectChanges() fixture.detectChanges()
}) })
it('should display logs with first log initially', () => { it('should display logs with first log initially', () => {
expect(logSpy).toHaveBeenCalledWith('paperless', 5000) expect(logSpy).toHaveBeenCalledWith('paperless')
fixture.detectChanges() fixture.detectChanges()
expect(fixture.debugElement.nativeElement.textContent).toContain( expect(fixture.debugElement.nativeElement.textContent).toContain(
paperless_logs[0] paperless_logs[0]
@@ -78,7 +71,7 @@ describe('LogsComponent', () => {
fixture.debugElement fixture.debugElement
.queryAll(By.directive(NgbNavLink))[1] .queryAll(By.directive(NgbNavLink))[1]
.nativeElement.dispatchEvent(new MouseEvent('click')) .nativeElement.dispatchEvent(new MouseEvent('click'))
expect(logSpy).toHaveBeenCalledWith('mail', 5000) expect(logSpy).toHaveBeenCalledWith('mail')
}) })
it('should handle error with no logs', () => { it('should handle error with no logs', () => {
@@ -90,10 +83,6 @@ describe('LogsComponent', () => {
}) })
it('should auto refresh, allow toggle', () => { it('should auto refresh, allow toggle', () => {
jest
.spyOn(CdkVirtualScrollViewport.prototype, 'scrollToIndex')
.mockImplementation(() => undefined)
jest.advanceTimersByTime(6000) jest.advanceTimersByTime(6000)
expect(reloadSpy).toHaveBeenCalledTimes(2) expect(reloadSpy).toHaveBeenCalledTimes(2)
@@ -101,20 +90,4 @@ describe('LogsComponent', () => {
jest.advanceTimersByTime(6000) jest.advanceTimersByTime(6000)
expect(reloadSpy).toHaveBeenCalledTimes(2) expect(reloadSpy).toHaveBeenCalledTimes(2)
}) })
it('should debounce limit changes before reloading logs', () => {
const initialCalls = reloadSpy.mock.calls.length
component.onLimitChange(6000)
jest.advanceTimersByTime(299)
expect(reloadSpy).toHaveBeenCalledTimes(initialCalls)
jest.advanceTimersByTime(1)
expect(reloadSpy).toHaveBeenCalledTimes(initialCalls + 1)
})
it('should update jump to bottom visibility on scroll', () => {
component.showJumpToBottom = false
jest.spyOn(component as any, 'isNearBottom').mockReturnValue(false)
component.onScroll()
expect(component.showJumpToBottom).toBe(true)
})
}) })

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common'
import { import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
@@ -10,7 +9,7 @@ import {
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap' import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
import { Subject, debounceTime, filter, takeUntil, timer } from 'rxjs' import { filter, takeUntil, timer } from 'rxjs'
import { LogService } from 'src/app/services/rest/log.service' import { LogService } from 'src/app/services/rest/log.service'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@@ -22,7 +21,6 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
imports: [ imports: [
PageHeaderComponent, PageHeaderComponent,
NgbNavModule, NgbNavModule,
CommonModule,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
], ],
@@ -34,7 +32,7 @@ export class LogsComponent
private logService = inject(LogService) private logService = inject(LogService)
private changedetectorRef = inject(ChangeDetectorRef) private changedetectorRef = inject(ChangeDetectorRef)
public logs: Array<{ message: string; level: number }> = [] public logs: string[] = []
public logFiles: string[] = [] public logFiles: string[] = []
@@ -42,19 +40,9 @@ export class LogsComponent
public autoRefreshEnabled: boolean = true public autoRefreshEnabled: boolean = true
public limit: number = 5000 @ViewChild('logContainer') logContainer: ElementRef
public showJumpToBottom = false
private readonly limitChange$ = new Subject<number>()
@ViewChild('logContainer') logContainer: ElementRef<HTMLElement>
ngOnInit(): void { ngOnInit(): void {
this.limitChange$
.pipe(debounceTime(300), takeUntil(this.unsubscribeNotifier))
.subscribe(() => this.reloadLogs())
this.logService this.logService
.list() .list()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
@@ -80,37 +68,16 @@ export class LogsComponent
super.ngOnDestroy() super.ngOnDestroy()
} }
onLimitChange(limit: number): void {
this.limitChange$.next(limit)
}
reloadLogs() { reloadLogs() {
this.loading = true this.loading = true
const shouldStickToBottom = this.isNearBottom()
this.logService this.logService
.get(this.activeLog, this.limit) .get(this.activeLog)
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (result) => { next: (result) => {
this.logs = result
this.loading = false this.loading = false
const parsed = this.parseLogsWithLevel(result) this.scrollToBottom()
const hasChanges =
parsed.length !== this.logs.length ||
parsed.some((log, idx) => {
const current = this.logs[idx]
return (
!current ||
current.message !== log.message ||
current.level !== log.level
)
})
if (hasChanges) {
this.logs = parsed
if (shouldStickToBottom) {
this.scrollToBottom()
}
this.showJumpToBottom = !shouldStickToBottom
}
}, },
error: () => { error: () => {
this.logs = [] this.logs = []
@@ -133,35 +100,12 @@ export class LogsComponent
} }
} }
private parseLogsWithLevel(
logs: string[]
): Array<{ message: string; level: number }> {
return logs.map((log) => ({
message: log,
level: this.getLogLevel(log),
}))
}
scrollToBottom(): void { scrollToBottom(): void {
const viewport = this.logContainer?.nativeElement
if (!viewport) {
return
}
this.changedetectorRef.detectChanges() this.changedetectorRef.detectChanges()
viewport.scrollTop = viewport.scrollHeight this.logContainer?.nativeElement.scroll({
this.showJumpToBottom = false top: this.logContainer.nativeElement.scrollHeight,
} left: 0,
behavior: 'auto',
private isNearBottom(): boolean { })
if (!this.logContainer?.nativeElement) return true
const distanceFromBottom =
this.logContainer.nativeElement.scrollHeight -
this.logContainer.nativeElement.scrollTop -
this.logContainer.nativeElement.clientHeight
return distanceFromBottom <= 40
}
onScroll(): void {
this.showJumpToBottom = !this.isNearBottom()
} }
} }

View File

@@ -28,6 +28,7 @@ import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { PermissionsService } from 'src/app/services/permissions.service' import { PermissionsService } from 'src/app/services/permissions.service'
import { GroupService } from 'src/app/services/rest/group.service' import { GroupService } from 'src/app/services/rest/group.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
@@ -91,9 +92,6 @@ const status: SystemStatus = {
sanity_check_status: SystemStatusItemStatus.ERROR, sanity_check_status: SystemStatusItemStatus.ERROR,
sanity_check_last_run: new Date().toISOString(), sanity_check_last_run: new Date().toISOString(),
sanity_check_error: 'Error running sanity check.', sanity_check_error: 'Error running sanity check.',
llmindex_status: SystemStatusItemStatus.DISABLED,
llmindex_last_modified: new Date().toISOString(),
llmindex_error: null,
}, },
} }
@@ -131,6 +129,7 @@ describe('SettingsComponent', () => {
ConfirmDialogComponent, ConfirmDialogComponent,
CheckComponent, CheckComponent,
ColorComponent, ColorComponent,
SafeHtmlPipe,
SelectComponent, SelectComponent,
TextComponent, TextComponent,
NumberComponent, NumberComponent,

View File

@@ -11,6 +11,7 @@ import {
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service' import { TrashService } from 'src/app/services/trash.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
@@ -52,6 +53,7 @@ describe('TrashComponent', () => {
TrashComponent, TrashComponent,
PageHeaderComponent, PageHeaderComponent,
ConfirmDialogComponent, ConfirmDialogComponent,
SafeHtmlPipe,
], ],
}).compileComponents() }).compileComponents()

View File

@@ -7,7 +7,7 @@
> >
</pngx-page-header> </pngx-page-header>
@if (canViewUsers && users) { @if (users) {
<h4 class="d-flex"> <h4 class="d-flex">
<ng-container i18n>Users</ng-container> <ng-container i18n>Users</ng-container>
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }"> <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
@@ -26,7 +26,7 @@
@for (user of users; track user) { @for (user of users; track user) {
<li class="list-group-item"> <li class="list-group-item">
<div class="row"> <div class="row">
<div class="col d-flex align-items-center" [class.opacity-50]="!user.is_active"><button class="btn btn-link p-0 text-start" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div> <div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
<div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div> <div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div>
<div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div> <div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div>
<div class="col"> <div class="col">
@@ -45,7 +45,7 @@
</ul> </ul>
} }
@if (canViewGroups && groups) { @if (groups) {
<h4 class="mt-4 d-flex"> <h4 class="mt-4 d-flex">
<ng-container i18n>Groups</ng-container> <ng-container i18n>Groups</ng-container>
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }"> <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
@@ -86,7 +86,7 @@
</ul> </ul>
} }
@if ((canViewUsers && !users) || (canViewGroups && !groups)) { @if (!users || !groups) {
<div> <div>
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div> <div class="visually-hidden" i18n>Loading...</div>

View File

@@ -5,11 +5,7 @@ import { Subject, first, takeUntil } from 'rxjs'
import { Group } from 'src/app/data/group' import { Group } from 'src/app/data/group'
import { User } from 'src/app/data/user' import { User } from 'src/app/data/user'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { import { PermissionsService } from 'src/app/services/permissions.service'
PermissionAction,
PermissionType,
PermissionsService,
} from 'src/app/services/permissions.service'
import { GroupService } from 'src/app/services/rest/group.service' import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
@@ -48,48 +44,30 @@ export class UsersAndGroupsComponent
unsubscribeNotifier: Subject<any> = new Subject() unsubscribeNotifier: Subject<any> = new Subject()
public get canViewUsers(): boolean {
return this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.User
)
}
public get canViewGroups(): boolean {
return this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Group
)
}
ngOnInit(): void { ngOnInit(): void {
if (this.canViewUsers) { this.usersService
this.usersService .listAll(null, null, { full_perms: true })
.listAll(null, null, { full_perms: true }) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .subscribe({
.subscribe({ next: (r) => {
next: (r) => { this.users = r.results
this.users = r.results },
}, error: (e) => {
error: (e) => { this.toastService.showError($localize`Error retrieving users`, e)
this.toastService.showError($localize`Error retrieving users`, e) },
}, })
})
}
if (this.canViewGroups) { this.groupsService
this.groupsService .listAll(null, null, { full_perms: true })
.listAll(null, null, { full_perms: true }) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .subscribe({
.subscribe({ next: (r) => {
next: (r) => { this.groups = r.results
this.groups = r.results },
}, error: (e) => {
error: (e) => { this.toastService.showError($localize`Error retrieving groups`, e)
this.toastService.showError($localize`Error retrieving groups`, e) },
}, })
})
}
} }
ngOnDestroy() { ngOnDestroy() {

View File

@@ -30,9 +30,6 @@
</div> </div>
</div> </div>
<ul ngbNav class="order-sm-3"> <ul ngbNav class="order-sm-3">
@if (aiEnabled) {
<pngx-chat></pngx-chat>
}
<pngx-toasts-dropdown></pngx-toasts-dropdown> <pngx-toasts-dropdown></pngx-toasts-dropdown>
<li ngbDropdown class="nav-item dropdown"> <li ngbDropdown class="nav-item dropdown">
<button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle> <button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle>
@@ -71,15 +68,13 @@
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse" <nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating" [ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating"
[ngbCollapse]="isMenuCollapsed"> [ngbCollapse]="isMenuCollapsed">
@if (canSaveSettings) { <button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()"> @if (slimSidebarEnabled) {
@if (slimSidebarEnabled) { <i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
<i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs> } @else {
} @else { <i-bs width="0.9em" height="0.9em" name="chevron-double-left"></i-bs>
<i-bs width="0.9em" height="0.9em" name="chevron-double-left"></i-bs> }
} </button>
</button>
}
<div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around"> <div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around">
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item app-link"> <li class="nav-item app-link">

View File

@@ -44,7 +44,6 @@ import { SettingsService } from 'src/app/services/settings.service'
import { TasksService } from 'src/app/services/tasks.service' import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { ChatComponent } from '../chat/chat/chat.component'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component' import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
@@ -60,7 +59,6 @@ import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.compo
DocumentTitlePipe, DocumentTitlePipe,
IfPermissionsDirective, IfPermissionsDirective,
ToastsDropdownComponent, ToastsDropdownComponent,
ChatComponent,
RouterModule, RouterModule,
NgClass, NgClass,
NgbDropdownModule, NgbDropdownModule,
@@ -154,19 +152,6 @@ export class AppFrameComponent
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE) return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
} }
get canSaveSettings(): boolean {
return (
this.permissionsService.currentUserCan(
PermissionAction.Change,
PermissionType.UISettings
) &&
this.permissionsService.currentUserCan(
PermissionAction.Add,
PermissionType.UISettings
)
)
}
get slimSidebarEnabled(): boolean { get slimSidebarEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR) return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
} }
@@ -186,10 +171,6 @@ export class AppFrameComponent
}) })
} }
get aiEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
}
closeMenu() { closeMenu() {
this.isMenuCollapsed = true this.isMenuCollapsed = true
} }

View File

@@ -411,9 +411,6 @@ export class GlobalSearchComponent implements OnInit {
const ruleType = this.useAdvancedForFullSearch const ruleType = this.useAdvancedForFullSearch
? FILTER_FULLTEXT_QUERY ? FILTER_FULLTEXT_QUERY
: FILTER_TITLE_CONTENT : FILTER_TITLE_CONTENT
this.documentService.searchQuery = this.useAdvancedForFullSearch
? this.query
: ''
this.documentListViewService.quickFilter([ this.documentListViewService.quickFilter([
{ rule_type: ruleType, value: this.query }, { rule_type: ruleType, value: this.query },
]) ])

View File

@@ -1,5 +1,5 @@
<li ngbDropdown class="nav-item mx-1" (openChange)="onOpenChange($event)"> <li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
@if (toasts.length) { @if (toasts.length) {
<span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts.length }}</span> <span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts.length }}</span>
} }

View File

@@ -1,35 +0,0 @@
<li ngbDropdown class="nav-item me-n2" (openChange)="onOpenChange($event)">
<button class="btn border-0" id="chatDropdown" ngbDropdownToggle>
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
</button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
<div class="chat-container bg-light p-2">
<div class="chat-messages font-monospace small">
@for (message of messages; track message) {
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
<span class="p-2 m-2" [class.bg-dark]="message.role === 'user'">
{{ message.content }}
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
</span>
</div>
}
<div #scrollAnchor></div>
</div>
<form class="chat-input">
<div class="input-group">
<input
#chatInput
class="form-control form-control-sm" name="chatInput" type="text"
[placeholder]="placeholder"
[disabled]="loading"
[(ngModel)]="input"
(keydown)="searchInputKeyDown($event)"
/>
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading">Send</button>
</div>
</form>
</div>
</div>
</li>

View File

@@ -1,37 +0,0 @@
.dropdown-menu {
width: var(--pngx-toast-max-width);
}
.chat-messages {
max-height: 350px;
overflow-y: auto;
}
.dropdown-toggle::after {
display: none;
}
.dropdown-item {
white-space: initial;
}
@media screen and (max-width: 400px) {
:host ::ng-deep .dropdown-menu-end {
right: -3rem;
}
}
.blinking-cursor {
font-weight: bold;
font-size: 1.2em;
animation: blink 1s step-end infinite;
}
@keyframes blink {
from, to {
opacity: 0;
}
50% {
opacity: 1;
}
}

View File

@@ -1,132 +0,0 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ElementRef } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NavigationEnd, Router } from '@angular/router'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs'
import { ChatService } from 'src/app/services/chat.service'
import { ChatComponent } from './chat.component'
describe('ChatComponent', () => {
let component: ChatComponent
let fixture: ComponentFixture<ChatComponent>
let chatService: ChatService
let router: Router
let routerEvents$: Subject<NavigationEnd>
let mockStream$: Subject<string>
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [NgxBootstrapIconsModule.pick(allIcons), ChatComponent],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(ChatComponent)
router = TestBed.inject(Router)
routerEvents$ = new Subject<any>()
jest
.spyOn(router, 'events', 'get')
.mockReturnValue(routerEvents$.asObservable())
chatService = TestBed.inject(ChatService)
mockStream$ = new Subject<string>()
jest
.spyOn(chatService, 'streamChat')
.mockReturnValue(mockStream$.asObservable())
component = fixture.componentInstance
jest.useFakeTimers()
fixture.detectChanges()
component.scrollAnchor.nativeElement.scrollIntoView = jest.fn()
})
it('should update documentId on initialization', () => {
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
component.ngOnInit()
expect(component.documentId).toBe(123)
})
it('should update documentId on navigation', () => {
component.ngOnInit()
routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456'))
expect(component.documentId).toBe(456)
})
it('should return correct placeholder based on documentId', () => {
component.documentId = 123
expect(component.placeholder).toBe('Ask a question about this document...')
component.documentId = undefined
expect(component.placeholder).toBe('Ask a question about a document...')
})
it('should send a message and handle streaming response', () => {
component.input = 'Hello'
component.sendMessage()
expect(component.messages.length).toBe(2)
expect(component.messages[0].content).toBe('Hello')
expect(component.loading).toBe(true)
mockStream$.next('Hi')
expect(component.messages[1].content).toBe('H')
mockStream$.next('Hi there')
// advance time to process the typewriter effect
jest.advanceTimersByTime(1000)
expect(component.messages[1].content).toBe('Hi there')
mockStream$.complete()
expect(component.loading).toBe(false)
expect(component.messages[1].isStreaming).toBe(false)
})
it('should handle errors during streaming', () => {
component.input = 'Hello'
component.sendMessage()
mockStream$.error('Error')
expect(component.messages[1].content).toContain(
'⚠️ Error receiving response.'
)
expect(component.loading).toBe(false)
})
it('should enqueue typewriter chunks correctly', () => {
const message = { content: '', role: 'assistant', isStreaming: true }
component.enqueueTypewriter(null, message as any) // coverage for null
component.enqueueTypewriter('Hello', message as any)
expect(component['typewriterBuffer'].length).toBe(4)
})
it('should scroll to bottom after sending a message', () => {
const scrollSpy = jest.spyOn(
ChatComponent.prototype as any,
'scrollToBottom'
)
component.input = 'Test'
component.sendMessage()
expect(scrollSpy).toHaveBeenCalled()
})
it('should focus chat input when dropdown is opened', () => {
const focus = jest.fn()
component.chatInput = {
nativeElement: { focus: focus },
} as unknown as ElementRef<HTMLInputElement>
component.onOpenChange(true)
jest.advanceTimersByTime(15)
expect(focus).toHaveBeenCalled()
})
it('should send message on Enter key press', () => {
jest.spyOn(component, 'sendMessage')
const event = new KeyboardEvent('keydown', { key: 'Enter' })
component.searchInputKeyDown(event)
expect(component.sendMessage).toHaveBeenCalled()
})
})

View File

@@ -1,140 +0,0 @@
import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { filter, map } from 'rxjs'
import { ChatMessage, ChatService } from 'src/app/services/chat.service'
@Component({
selector: 'pngx-chat',
imports: [
FormsModule,
ReactiveFormsModule,
NgxBootstrapIconsModule,
NgbDropdownModule,
],
templateUrl: './chat.component.html',
styleUrl: './chat.component.scss',
})
export class ChatComponent implements OnInit {
public messages: ChatMessage[] = []
public loading = false
public input: string = ''
public documentId!: number
private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router)
@ViewChild('scrollAnchor') scrollAnchor!: ElementRef<HTMLDivElement>
@ViewChild('chatInput') chatInput!: ElementRef<HTMLInputElement>
private typewriterBuffer: string[] = []
private typewriterActive = false
public get placeholder(): string {
return this.documentId
? $localize`Ask a question about this document...`
: $localize`Ask a question about a document...`
}
ngOnInit(): void {
this.updateDocumentId(this.router.url)
this.router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
map((event) => (event as NavigationEnd).url)
)
.subscribe((url) => {
this.updateDocumentId(url)
})
}
private updateDocumentId(url: string): void {
const docIdRe = url.match(/^\/documents\/(\d+)/)
this.documentId = docIdRe ? +docIdRe[1] : undefined
}
sendMessage(): void {
if (!this.input.trim()) return
const userMessage: ChatMessage = { role: 'user', content: this.input }
this.messages.push(userMessage)
this.scrollToBottom()
const assistantMessage: ChatMessage = {
role: 'assistant',
content: '',
isStreaming: true,
}
this.messages.push(assistantMessage)
this.loading = true
let lastPartialLength = 0
this.chatService.streamChat(this.documentId, this.input).subscribe({
next: (chunk) => {
const delta = chunk.substring(lastPartialLength)
lastPartialLength = chunk.length
this.enqueueTypewriter(delta, assistantMessage)
},
error: () => {
assistantMessage.content += '\n\n⚠ Error receiving response.'
assistantMessage.isStreaming = false
this.loading = false
},
complete: () => {
assistantMessage.isStreaming = false
this.loading = false
this.scrollToBottom()
},
})
this.input = ''
}
enqueueTypewriter(chunk: string, message: ChatMessage): void {
if (!chunk) return
this.typewriterBuffer.push(...chunk.split(''))
if (!this.typewriterActive) {
this.typewriterActive = true
this.playTypewriter(message)
}
}
playTypewriter(message: ChatMessage): void {
if (this.typewriterBuffer.length === 0) {
this.typewriterActive = false
return
}
const nextChar = this.typewriterBuffer.shift()
message.content += nextChar
this.scrollToBottom()
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
}
private scrollToBottom(): void {
setTimeout(() => {
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
}, 50)
}
public onOpenChange(open: boolean): void {
if (open) {
setTimeout(() => {
this.chatInput.nativeElement.focus()
}, 10)
}
}
public searchInputKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault()
this.sendMessage()
}
}
}

View File

@@ -8,7 +8,7 @@
<p><b>{{messageBold}}</b></p> <p><b>{{messageBold}}</b></p>
} }
@if (message) { @if (message) {
<p class="mb-0" [innerHTML]="message"></p> <p class="mb-0" [innerHTML]="message | safeHtml"></p>
} }
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@@ -1,6 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { ConfirmDialogComponent } from './confirm-dialog.component' import { ConfirmDialogComponent } from './confirm-dialog.component'
describe('ConfirmDialogComponent', () => { describe('ConfirmDialogComponent', () => {
@@ -10,8 +11,8 @@ describe('ConfirmDialogComponent', () => {
beforeEach(async () => { beforeEach(async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [NgbActiveModal], providers: [NgbActiveModal, SafeHtmlPipe],
imports: [ConfirmDialogComponent], imports: [ConfirmDialogComponent, SafeHtmlPipe],
}).compileComponents() }).compileComponents()
modal = TestBed.inject(NgbActiveModal) modal = TestBed.inject(NgbActiveModal)

View File

@@ -2,13 +2,14 @@ import { DecimalPipe } from '@angular/common'
import { Component, EventEmitter, Input, Output, inject } from '@angular/core' import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@Component({ @Component({
selector: 'pngx-confirm-dialog', selector: 'pngx-confirm-dialog',
templateUrl: './confirm-dialog.component.html', templateUrl: './confirm-dialog.component.html',
styleUrls: ['./confirm-dialog.component.scss'], styleUrls: ['./confirm-dialog.component.scss'],
imports: [DecimalPipe], imports: [DecimalPipe, SafeHtmlPipe],
}) })
export class ConfirmDialogComponent extends LoadingComponentWithPermissions { export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
activeModal = inject(NgbActiveModal) activeModal = inject(NgbActiveModal)

View File

@@ -1,75 +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">
@if (message) {
<p class="mb-3" [innerHTML]="message"></p>
}
<div class="btn-group mb-3" role="group">
<input
type="radio"
class="btn-check"
name="passwordRemoveMode"
id="removeReplace"
[(ngModel)]="updateDocument"
[value]="true"
(ngModelChange)="onUpdateDocumentChange($event)"
/>
<label class="btn btn-outline-primary btn-sm" for="removeReplace">
<i-bs name="pencil"></i-bs>
<span class="ms-2" i18n>Replace current document</span>
</label>
<input
type="radio"
class="btn-check"
name="passwordRemoveMode"
id="removeCreate"
[(ngModel)]="updateDocument"
[value]="false"
(ngModelChange)="onUpdateDocumentChange($event)"
/>
<label class="btn btn-outline-primary btn-sm" for="removeCreate">
<i-bs name="plus"></i-bs>
<span class="ms-2" i18n>Create new document</span>
</label>
</div>
@if (!updateDocument) {
<div class="d-flex flex-column flex-md-row w-100 gap-3 align-items-center">
<div class="form-group d-flex">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="copyMetaRemove" [(ngModel)]="includeMetadata" />
<label class="form-check-label" for="copyMetaRemove" i18n> Copy metadata
</label>
</div>
<div class="form-check ms-3">
<input class="form-check-input" type="checkbox" id="deleteOriginalRemove" [(ngModel)]="deleteOriginal" />
<label class="form-check-label" for="deleteOriginalRemove" i18n> Delete original</label>
</div>
</div>
</div>
}
</div>
<div class="modal-footer flex-nowrap gap-2">
<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>

View File

@@ -1,53 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { PasswordRemovalConfirmDialogComponent } from './password-removal-confirm-dialog.component'
describe('PasswordRemovalConfirmDialogComponent', () => {
let component: PasswordRemovalConfirmDialogComponent
let fixture: ComponentFixture<PasswordRemovalConfirmDialogComponent>
beforeEach(async () => {
await TestBed.configureTestingModule({
providers: [NgbActiveModal],
imports: [
NgxBootstrapIconsModule.pick(allIcons),
PasswordRemovalConfirmDialogComponent,
],
}).compileComponents()
fixture = TestBed.createComponent(PasswordRemovalConfirmDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should default to replacing the document', () => {
expect(component.updateDocument).toBe(true)
expect(
fixture.debugElement.query(By.css('#removeReplace')).nativeElement.checked
).toBe(true)
})
it('should allow creating a new document with metadata and delete toggle', () => {
component.onUpdateDocumentChange(false)
fixture.detectChanges()
expect(component.updateDocument).toBe(false)
expect(fixture.debugElement.query(By.css('#copyMetaRemove'))).not.toBeNull()
component.includeMetadata = false
component.deleteOriginal = true
component.onUpdateDocumentChange(true)
expect(component.updateDocument).toBe(true)
expect(component.includeMetadata).toBe(true)
expect(component.deleteOriginal).toBe(false)
})
it('should emit confirm when confirmed', () => {
let confirmed = false
component.confirmClicked.subscribe(() => (confirmed = true))
component.confirm()
expect(confirmed).toBe(true)
})
})

View File

@@ -1,38 +0,0 @@
import { Component, Input } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@Component({
selector: 'pngx-password-removal-confirm-dialog',
templateUrl: './password-removal-confirm-dialog.component.html',
styleUrls: ['./password-removal-confirm-dialog.component.scss'],
imports: [FormsModule, NgxBootstrapIconsModule],
})
export class PasswordRemovalConfirmDialogComponent extends ConfirmDialogComponent {
updateDocument: boolean = true
includeMetadata: boolean = true
deleteOriginal: boolean = false
@Input()
override title = $localize`Remove password protection`
@Input()
override message =
$localize`Create an unprotected copy or replace the existing file.`
@Input()
override btnCaption = $localize`Start`
constructor() {
super()
}
onUpdateDocumentChange(updateDocument: boolean) {
this.updateDocument = updateDocument
if (this.updateDocument) {
this.deleteOriginal = false
this.includeMetadata = true
}
}
}

View File

@@ -28,10 +28,10 @@
<div class="modal-footer flex-nowrap"> <div class="modal-footer flex-nowrap">
<div class="col"> <div class="col">
@if (message) { @if (message) {
<p>{{message}}</p> <p [innerHTML]="message | safeHtml"></p>
} }
@if (messageBold) { @if (messageBold) {
<p class="mb-0 small"><b>{{messageBold}}</b></p> <p class="mb-0 small"><b [innerHTML]="messageBold | safeHtml"></b></p>
} }
</div> </div>
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled"> <button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">

View File

@@ -3,6 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { RotateConfirmDialogComponent } from './rotate-confirm-dialog.component' import { RotateConfirmDialogComponent } from './rotate-confirm-dialog.component'
describe('RotateConfirmDialogComponent', () => { describe('RotateConfirmDialogComponent', () => {
@@ -14,9 +15,11 @@ describe('RotateConfirmDialogComponent', () => {
imports: [ imports: [
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
RotateConfirmDialogComponent, RotateConfirmDialogComponent,
SafeHtmlPipe,
], ],
providers: [ providers: [
NgbActiveModal, NgbActiveModal,
SafeHtmlPipe,
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
], ],

View File

@@ -1,6 +1,7 @@
import { NgStyle } from '@angular/common' import { NgStyle } from '@angular/common'
import { Component, inject } from '@angular/core' import { Component, inject } from '@angular/core'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../confirm-dialog.component' import { ConfirmDialogComponent } from '../confirm-dialog.component'
@@ -8,7 +9,7 @@ import { ConfirmDialogComponent } from '../confirm-dialog.component'
selector: 'pngx-rotate-confirm-dialog', selector: 'pngx-rotate-confirm-dialog',
templateUrl: './rotate-confirm-dialog.component.html', templateUrl: './rotate-confirm-dialog.component.html',
styleUrl: './rotate-confirm-dialog.component.scss', styleUrl: './rotate-confirm-dialog.component.scss',
imports: [NgStyle, NgxBootstrapIconsModule], imports: [NgStyle, NgxBootstrapIconsModule, SafeHtmlPipe],
}) })
export class RotateConfirmDialogComponent extends ConfirmDialogComponent { export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
documentService = inject(DocumentService) documentService = inject(DocumentService)

View File

@@ -1,7 +1,7 @@
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions"> <div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions" placement="bottom-end">
<button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
<i-bs name="ui-radios"></i-bs> <i-bs name="ui-radios"></i-bs>
<div class="d-none d-lg-inline">&nbsp;<ng-container i18n>Custom Fields</ng-container></div> <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Custom Fields</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown"> <div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
<div class="list-group list-group-flush" (keydown)="listKeyDown($event)"> <div class="list-group list-group-flush" (keydown)="listKeyDown($event)">

View File

@@ -1,36 +1,28 @@
@if (useDropdown) { <div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions"> <button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled"> <i-bs name="{{icon}}"></i-bs>
<i-bs name="{{icon}}"></i-bs> <div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div> @if (isActive) {
@if (isActive) { <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge> }
</button>
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<div class="list-group list-group-flush">
@for (element of selectionModel.queries; track element.id; let i = $index) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@switch (element.type) {
@case (CustomFieldQueryComponentType.Atom) {
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
}
@case (CustomFieldQueryComponentType.Expression) {
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
}
}
</div>
} }
</button>
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container>
</div> </div>
</div> </div>
} @else { </div>
<ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container>
}
<ng-template #list let-queries="queries">
<div class="list-group list-group-flush">
@for (element of queries; track element.id; let i = $index) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@switch (element.type) {
@case (CustomFieldQueryComponentType.Atom) {
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
}
@case (CustomFieldQueryComponentType.Expression) {
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
}
}
</div>
}
</div>
</ng-template>
<ng-template #comparisonValueTemplate let-atom="atom"> <ng-template #comparisonValueTemplate let-atom="atom">
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) { @if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
@@ -63,7 +55,6 @@
bindValue="id" bindValue="id"
[(ngModel)]="atom.value" [(ngModel)]="atom.value"
[disabled]="disabled" [disabled]="disabled"
[virtualScroll]="getSelectOptionsForField(atom.field)?.length > 100"
(mousedown)="$event.stopImmediatePropagation()" (mousedown)="$event.stopImmediatePropagation()"
></ng-select> ></ng-select>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) { } @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) {

View File

@@ -354,13 +354,5 @@ describe('CustomFieldsQueryDropdownComponent', () => {
model.removeElement(atom) model.removeElement(atom)
expect(completeSpy).toHaveBeenCalled() expect(completeSpy).toHaveBeenCalled()
}) })
it('should subscribe to existing elements when queries are assigned', () => {
const expression = new CustomFieldQueryExpression()
const nextSpy = jest.spyOn(model.changed, 'next')
model.queries = [expression]
expression.changed.next(expression)
expect(nextSpy).toHaveBeenCalledWith(model)
})
}) })
}) })

View File

@@ -17,7 +17,7 @@ import {
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select' import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, Subject, Subscription, takeUntil } from 'rxjs' import { first, Subject, takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { import {
CUSTOM_FIELD_QUERY_MAX_ATOMS, CUSTOM_FIELD_QUERY_MAX_ATOMS,
@@ -41,27 +41,10 @@ import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.comp
import { DocumentLinkComponent } from '../input/document-link/document-link.component' import { DocumentLinkComponent } from '../input/document-link/document-link.component'
export class CustomFieldQueriesModel { export class CustomFieldQueriesModel {
private _queries: CustomFieldQueryElement[] = [] public queries: CustomFieldQueryElement[] = []
private rootSubscriptions: Subscription[] = []
public readonly changed = new Subject<CustomFieldQueriesModel>() public readonly changed = new Subject<CustomFieldQueriesModel>()
public get queries(): CustomFieldQueryElement[] {
return this._queries
}
public set queries(value: CustomFieldQueryElement[]) {
this.teardownRootSubscriptions()
this._queries = value ?? []
for (const element of this._queries) {
this.rootSubscriptions.push(
element.changed.subscribe(() => {
this.changed.next(this)
})
)
}
}
public clear(fireEvent = true) { public clear(fireEvent = true) {
this.queries = [] this.queries = []
if (fireEvent) { if (fireEvent) {
@@ -124,25 +107,19 @@ export class CustomFieldQueriesModel {
public addExpression( public addExpression(
expression: CustomFieldQueryExpression = new CustomFieldQueryExpression() expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
) { ) {
if (this.queries.length === 0) { if (this.queries.length > 0) {
this.queries = [expression] ;(
return (this.queries[0] as CustomFieldQueryExpression)
.value as CustomFieldQueryElement[]
).push(expression)
} else {
this.queries.push(expression)
} }
;(
(this.queries[0] as CustomFieldQueryExpression)
.value as CustomFieldQueryElement[]
).push(expression)
expression.changed.subscribe(() => { expression.changed.subscribe(() => {
this.changed.next(this) this.changed.next(this)
}) })
} }
addInitialAtom() {
this.addAtom(
new CustomFieldQueryAtom([null, CustomFieldQueryOperator.Exists, 'true'])
)
}
private findElement( private findElement(
queryElement: CustomFieldQueryElement, queryElement: CustomFieldQueryElement,
elements: any[] elements: any[]
@@ -183,13 +160,6 @@ export class CustomFieldQueriesModel {
this.changed.next(this) this.changed.next(this)
} }
} }
private teardownRootSubscriptions() {
for (const subscription of this.rootSubscriptions) {
subscription.unsubscribe()
}
this.rootSubscriptions = []
}
} }
@Component({ @Component({
@@ -236,9 +206,6 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
@Input() @Input()
applyOnClose = false applyOnClose = false
@Input()
useDropdown: boolean = true
get name(): string { get name(): string {
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
} }
@@ -291,7 +258,13 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
public onOpenChange(open: boolean) { public onOpenChange(open: boolean) {
if (open) { if (open) {
if (this.selectionModel.queries.length === 0) { if (this.selectionModel.queries.length === 0) {
this.selectionModel.addInitialAtom() this.selectionModel.addAtom(
new CustomFieldQueryAtom([
null,
CustomFieldQueryOperator.Exists,
'true',
])
)
} }
if ( if (
this.selectionModel.queries.length === 1 && this.selectionModel.queries.length === 1 &&

View File

@@ -26,7 +26,7 @@
i18n-placeholder i18n-placeholder
(change)="onSetCreatedRelativeDate($event)"> (change)="onSetCreatedRelativeDate($event)">
<ng-template ng-option-tmp let-item="item"> <ng-template ng-option-tmp let-item="item">
<ng-container [ngTemplateOutlet]="relativeDateOptionTemplate" [ngTemplateOutletContext]="{ $implicit: item }"></ng-container> <div class="d-flex">{{ item.name }}<span class="ms-auto text-muted small">{{ item.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container></span></div>
</ng-template> </ng-template>
</ng-select> </ng-select>
</div> </div>
@@ -102,7 +102,7 @@
i18n-placeholder i18n-placeholder
(change)="onSetAddedRelativeDate($event)"> (change)="onSetAddedRelativeDate($event)">
<ng-template ng-option-tmp let-item="item"> <ng-template ng-option-tmp let-item="item">
<ng-container [ngTemplateOutlet]="relativeDateOptionTemplate" [ngTemplateOutletContext]="{ $implicit: item }"></ng-container> <div class="d-flex">{{ item.name }}<span class="ms-auto text-muted small">{{ item.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container></span></div>
</ng-template> </ng-template>
</ng-select> </ng-select>
</div> </div>
@@ -158,16 +158,3 @@
</div> </div>
</div> </div>
</div> </div>
<ng-template #relativeDateOptionTemplate let-item>
<div class="d-flex">
{{ item.name }}
<span class="ms-auto text-muted small">
@if (item.dateEnd) {
{{ item.date | customDate:'MMM d' }} &ndash; {{ item.dateEnd | customDate:'mediumDate' }}
} @else {
{{ item.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container>
}
</span>
</div>
</ng-template>

View File

@@ -1,4 +1,4 @@
import { NgClass, NgTemplateOutlet } from '@angular/common' import { NgClass } from '@angular/common'
import { import {
Component, Component,
EventEmitter, EventEmitter,
@@ -42,10 +42,6 @@ export enum RelativeDate {
THIS_MONTH = 6, THIS_MONTH = 6,
TODAY = 7, TODAY = 7,
YESTERDAY = 8, YESTERDAY = 8,
PREVIOUS_WEEK = 9,
PREVIOUS_MONTH = 10,
PREVIOUS_QUARTER = 11,
PREVIOUS_YEAR = 12,
} }
@Component({ @Component({
@@ -63,7 +59,6 @@ export enum RelativeDate {
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
NgClass, NgClass,
NgTemplateOutlet,
], ],
}) })
export class DatesDropdownComponent implements OnInit, OnDestroy { export class DatesDropdownComponent implements OnInit, OnDestroy {
@@ -116,46 +111,6 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
name: $localize`Yesterday`, name: $localize`Yesterday`,
date: new Date().setDate(new Date().getDate() - 1), date: new Date().setDate(new Date().getDate() - 1),
}, },
{
id: RelativeDate.PREVIOUS_WEEK,
name: $localize`Previous week`,
date: new Date(
new Date().getFullYear(),
new Date().getMonth(),
new Date().getDate() - new Date().getDay() - 6
),
dateEnd: new Date(
new Date().getFullYear(),
new Date().getMonth(),
new Date().getDate() - new Date().getDay()
),
},
{
id: RelativeDate.PREVIOUS_MONTH,
name: $localize`Previous month`,
date: new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1),
dateEnd: new Date(new Date().getFullYear(), new Date().getMonth(), 0),
},
{
id: RelativeDate.PREVIOUS_QUARTER,
name: $localize`Previous quarter`,
date: new Date(
new Date().getFullYear(),
Math.floor(new Date().getMonth() / 3) * 3 - 3,
1
),
dateEnd: new Date(
new Date().getFullYear(),
Math.floor(new Date().getMonth() / 3) * 3,
0
),
},
{
id: RelativeDate.PREVIOUS_YEAR,
name: $localize`Previous year`,
date: new Date('1/1/' + (new Date().getFullYear() - 1)),
dateEnd: new Date('12/31/' + (new Date().getFullYear() - 1)),
},
] ]
datePlaceHolder: string datePlaceHolder: string

View File

@@ -38,6 +38,9 @@
size="sm" size="sm"
></ngb-pagination> ></ngb-pagination>
} }
@if (object?.id) {
<small class="d-block mt-2" i18n>Warning: existing instances of this field will retain their current value index (e.g. option #1, #2, #3) after editing the options here</small>
}
} }
@case (CustomFieldDataType.Monetary) { @case (CustomFieldDataType.Monetary) {
<div class="my-3"> <div class="my-3">

View File

@@ -10,6 +10,7 @@ import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { CustomFieldDataType } from 'src/app/data/custom-field' import { CustomFieldDataType } from 'src/app/data/custom-field'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { SelectComponent } from '../../input/select/select.component' import { SelectComponent } from '../../input/select/select.component'
import { TextComponent } from '../../input/text/text.component' import { TextComponent } from '../../input/text/text.component'
@@ -34,6 +35,7 @@ describe('CustomFieldEditDialogComponent', () => {
IfOwnerDirective, IfOwnerDirective,
SelectComponent, SelectComponent,
TextComponent, TextComponent,
SafeHtmlPipe,
], ],
providers: [ providers: [
NgbActiveModal, NgbActiveModal,

View File

@@ -11,6 +11,7 @@ import {
} from 'src/app/data/mail-rule' } from 'src/app/data/mail-rule'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { MailAccountService } from 'src/app/services/rest/mail-account.service' import { MailAccountService } from 'src/app/services/rest/mail-account.service'
@@ -45,6 +46,7 @@ describe('MailRuleEditDialogComponent', () => {
PermissionsFormComponent, PermissionsFormComponent,
NumberComponent, NumberComponent,
TagsComponent, TagsComponent,
SafeHtmlPipe,
CheckComponent, CheckComponent,
SwitchComponent, SwitchComponent,
], ],

View File

@@ -14,7 +14,6 @@ import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
import { PasswordComponent } from '../../input/password/password.component' import { PasswordComponent } from '../../input/password/password.component'
import { SelectComponent } from '../../input/select/select.component' import { SelectComponent } from '../../input/select/select.component'
import { TextComponent } from '../../input/text/text.component' import { TextComponent } from '../../input/text/text.component'
@@ -29,7 +28,6 @@ import { PermissionsSelectComponent } from '../../permissions-select/permissions
SelectComponent, SelectComponent,
TextComponent, TextComponent,
PasswordComponent, PasswordComponent,
ConfirmButtonComponent,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
], ],

View File

@@ -77,11 +77,9 @@
</div> </div>
<div ngbAccordion [closeOthers]="true" cdkDropList (cdkDropListDropped)="onActionDrop($event)"> <div ngbAccordion [closeOthers]="true" cdkDropList (cdkDropListDropped)="onActionDrop($event)">
@for (action of object?.actions; track action; let i = $index){ @for (action of object?.actions; track action; let i = $index){
<div ngbAccordionItem [formGroup]="actionFields.controls[i]"> <div ngbAccordionItem cdkDrag [formGroup]="actionFields.controls[i]">
<div ngbAccordionHeader cdkDrag> <div ngbAccordionHeader>
<button ngbAccordionButton> <button ngbAccordionButton>{{i + 1}}. {{getActionTypeOptionName(actionFields.controls[i].value.type)}}
<i-bs name="grip-vertical" class="ms-n3 pe-1"></i-bs>
{{i + 1}}. {{getActionTypeOptionName(actionFields.controls[i].value.type)}}
@if(action.id) { @if(action.id) {
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{action.id}}</span> <span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{action.id}}</span>
} }
@@ -158,97 +156,31 @@
<p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p> <p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" horizontal="true" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text> <pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text>
@if (formGroup.get('type').value === WorkflowTriggerType.Consumption) { @if (formGroup.get('type').value === WorkflowTriggerType.Consumption) {
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" horizontal="true" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select> <pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized." [error]="error?.filter_path"></pngx-input-text> <pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a>" [error]="error?.filter_path"></pngx-input-text>
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select> <pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
} }
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) { @if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> <pngx-input-select i18n-title title="Content matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@if (matchingPatternRequired(formGroup)) { @if (patternRequired) {
<pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text> <pngx-input-text i18n-title title="Content matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
} }
@if (matchingPatternRequired(formGroup)) { @if (patternRequired) {
<pngx-input-check i18n-title title="Case insensitive" horizontal="true" formControlName="is_insensitive"></pngx-input-check> <pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive"></pngx-input-check>
} }
} }
</div> </div>
</div> @if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) { <div class="col-md-6">
<div class="row mt-3"> <pngx-input-tags [allowCreate]="false" i18n-title title="Has any of tags" formControlName="filter_has_tags"></pngx-input-tags>
<div class="col"> <pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
<div class="trigger-filters mb-3"> <pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select>
<div class="d-flex align-items-center"> <pngx-input-select i18n-title title="Has storage path" [items]="storagePaths" [allowNull]="true" formControlName="filter_has_storage_path"></pngx-input-select>
<label class="form-label mb-0" i18n>Advanced Filters</label>
<button
type="button"
class="btn btn-sm btn-outline-primary ms-auto"
(click)="addFilter(formGroup)"
[disabled]="!canAddFilter(formGroup)"
>
<i-bs name="plus-circle"></i-bs>&nbsp;<span i18n>Add filter</span>
</button>
</div>
<ul class="mt-2 list-group filters" formArrayName="filters">
@if (getFiltersFormArray(formGroup).length === 0) {
<p class="text-muted small" i18n>No advanced workflow filters defined.</p>
}
@for (filter of getFiltersFormArray(formGroup).controls; track filter; let filterIndex = $index) {
<li [formGroupName]="filterIndex" class="list-group-item">
<div class="d-flex align-items-center gap-2">
<div class="w-25">
<pngx-input-select
i18n-title
[items]="getFilterTypeOptions(formGroup, filterIndex)"
formControlName="type"
[allowNull]="false"
></pngx-input-select>
</div>
<div class="flex-grow-1">
@if (isTagsFilter(filter.get('type').value)) {
<pngx-input-tags
[allowCreate]="false"
[title]="null"
formControlName="values"
></pngx-input-tags>
} @else if (
isCustomFieldQueryFilter(filter.get('type').value)
) {
<pngx-custom-fields-query-dropdown
[selectionModel]="getCustomFieldQueryModel(filter)"
(selectionModelChange)="onCustomFieldQuerySelectionChange(filter, $event)"
[useDropdown]="false"
></pngx-custom-fields-query-dropdown>
@if (!isCustomFieldQueryValid(filter)) {
<div class="text-danger small" i18n>
Complete the custom field query configuration.
</div>
}
} @else {
<pngx-input-select
[items]="getFilterSelectItems(filter.get('type').value)"
[allowNull]="true"
[multiple]="isSelectMultiple(filter.get('type').value)"
formControlName="values"
></pngx-input-select>
}
</div>
<button
type="button"
class="btn btn-link text-danger p-0"
(click)="removeFilter(formGroup, filterIndex)"
>
<i-bs name="trash"></i-bs><span class="ms-1" i18n>Delete</span>
</button>
</div>
</li>
}
</ul>
</div>
</div> </div>
</div> }
} </div>
</div> </div>
</ng-template> </ng-template>

View File

@@ -7,11 +7,3 @@
.accordion-button { .accordion-button {
font-size: 1rem; font-size: 1rem;
} }
:host ::ng-deep .filters .paperless-input-select.mb-3 {
margin-bottom: 0 !important;
}
.ms-n3 {
margin-left: -1rem !important;
}

View File

@@ -11,14 +11,8 @@ import {
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs' import { of } from 'rxjs'
import { CustomFieldQueriesModel } from 'src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import { CustomFieldDataType } from 'src/app/data/custom-field' import { CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query' import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
import {
MATCHING_ALGORITHMS,
MATCH_AUTO,
MATCH_NONE,
} from 'src/app/data/matching-model'
import { Workflow } from 'src/app/data/workflow' import { Workflow } from 'src/app/data/workflow'
import { import {
WorkflowAction, WorkflowAction,
@@ -30,13 +24,13 @@ import {
} from 'src/app/data/workflow-trigger' } from 'src/app/data/workflow-trigger'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { MailRuleService } from 'src/app/services/rest/mail-rule.service' import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
import { NumberComponent } from '../../input/number/number.component' import { NumberComponent } from '../../input/number/number.component'
import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component' import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component'
@@ -49,7 +43,6 @@ import { EditDialogMode } from '../edit-dialog.component'
import { import {
DOCUMENT_SOURCE_OPTIONS, DOCUMENT_SOURCE_OPTIONS,
SCHEDULE_DATE_FIELD_OPTIONS, SCHEDULE_DATE_FIELD_OPTIONS,
TriggerFilterType,
WORKFLOW_ACTION_OPTIONS, WORKFLOW_ACTION_OPTIONS,
WORKFLOW_TYPE_OPTIONS, WORKFLOW_TYPE_OPTIONS,
WorkflowEditDialogComponent, WorkflowEditDialogComponent,
@@ -104,6 +97,7 @@ describe('WorkflowEditDialogComponent', () => {
TagsComponent, TagsComponent,
PermissionsUserComponent, PermissionsUserComponent,
PermissionsGroupComponent, PermissionsGroupComponent,
SafeHtmlPipe,
ConfirmButtonComponent, ConfirmButtonComponent,
], ],
providers: [ providers: [
@@ -381,562 +375,6 @@ describe('WorkflowEditDialogComponent', () => {
expect(component.objectForm.get('actions').value[0].webhook).toBeNull() expect(component.objectForm.get('actions').value[0].webhook).toBeNull()
}) })
it('should require matching pattern when algorithm is not none', () => {
const triggerGroup = new FormGroup({
matching_algorithm: new FormControl(MATCH_AUTO),
match: new FormControl(''),
})
expect(component.matchingPatternRequired(triggerGroup)).toBe(true)
triggerGroup.get('matching_algorithm').setValue(MATCHING_ALGORITHMS[0].id)
expect(component.matchingPatternRequired(triggerGroup)).toBe(true)
triggerGroup.get('matching_algorithm').setValue(MATCH_NONE)
expect(component.matchingPatternRequired(triggerGroup)).toBe(false)
})
it('should map filter builder values into trigger filters on save', () => {
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0)
component.addFilter(triggerGroup as FormGroup)
component.addFilter(triggerGroup as FormGroup)
component.addFilter(triggerGroup as FormGroup)
const filters = component.getFiltersFormArray(triggerGroup as FormGroup)
expect(filters.length).toBe(3)
filters.at(0).get('values').setValue([1])
filters.at(1).get('values').setValue([2, 3])
filters.at(2).get('values').setValue([4])
const addFilterOfType = (type: TriggerFilterType) => {
const newFilter = component.addFilter(triggerGroup as FormGroup)
newFilter.get('type').setValue(type)
return newFilter
}
const correspondentIs = addFilterOfType(TriggerFilterType.CorrespondentIs)
correspondentIs.get('values').setValue(1)
const correspondentNot = addFilterOfType(TriggerFilterType.CorrespondentNot)
correspondentNot.get('values').setValue([1])
const documentTypeIs = addFilterOfType(TriggerFilterType.DocumentTypeIs)
documentTypeIs.get('values').setValue(1)
const documentTypeNot = addFilterOfType(TriggerFilterType.DocumentTypeNot)
documentTypeNot.get('values').setValue([1])
const storagePathIs = addFilterOfType(TriggerFilterType.StoragePathIs)
storagePathIs.get('values').setValue(1)
const storagePathNot = addFilterOfType(TriggerFilterType.StoragePathNot)
storagePathNot.get('values').setValue([1])
const customFieldFilter = addFilterOfType(
TriggerFilterType.CustomFieldQuery
)
const customFieldQuery = JSON.stringify(['AND', [[1, 'exact', 'test']]])
customFieldFilter.get('values').setValue(customFieldQuery)
const formValues = component['getFormValues']()
expect(formValues.triggers[0].filter_has_tags).toEqual([1])
expect(formValues.triggers[0].filter_has_all_tags).toEqual([2, 3])
expect(formValues.triggers[0].filter_has_not_tags).toEqual([4])
expect(formValues.triggers[0].filter_has_correspondent).toEqual(1)
expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([1])
expect(formValues.triggers[0].filter_has_document_type).toEqual(1)
expect(formValues.triggers[0].filter_has_not_document_types).toEqual([1])
expect(formValues.triggers[0].filter_has_storage_path).toEqual(1)
expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([1])
expect(formValues.triggers[0].filter_custom_field_query).toEqual(
customFieldQuery
)
expect(formValues.triggers[0].filters).toBeUndefined()
})
it('should ignore empty and null filter values when mapping filters', () => {
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0) as FormGroup
const tagsFilter = component.addFilter(triggerGroup)
tagsFilter.get('type').setValue(TriggerFilterType.TagsAny)
tagsFilter.get('values').setValue([])
const correspondentFilter = component.addFilter(triggerGroup)
correspondentFilter.get('type').setValue(TriggerFilterType.CorrespondentIs)
correspondentFilter.get('values').setValue(null)
const formValues = component['getFormValues']()
expect(formValues.triggers[0].filter_has_tags).toEqual([])
expect(formValues.triggers[0].filter_has_correspondent).toBeNull()
})
it('should derive single select filters from array values', () => {
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0) as FormGroup
const addFilterOfType = (type: TriggerFilterType, value: any) => {
const filter = component.addFilter(triggerGroup)
filter.get('type').setValue(type)
filter.get('values').setValue(value)
}
addFilterOfType(TriggerFilterType.CorrespondentIs, [5])
addFilterOfType(TriggerFilterType.DocumentTypeIs, [6])
addFilterOfType(TriggerFilterType.StoragePathIs, [7])
const formValues = component['getFormValues']()
expect(formValues.triggers[0].filter_has_correspondent).toEqual(5)
expect(formValues.triggers[0].filter_has_document_type).toEqual(6)
expect(formValues.triggers[0].filter_has_storage_path).toEqual(7)
})
it('should convert multi-value filter values when aggregating filters', () => {
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0) as FormGroup
const setFilter = (type: TriggerFilterType, value: number): void => {
const filter = component.addFilter(triggerGroup) as FormGroup
filter.get('type').setValue(type)
filter.get('values').setValue(value)
}
setFilter(TriggerFilterType.TagsAll, 11)
setFilter(TriggerFilterType.TagsNone, 12)
setFilter(TriggerFilterType.CorrespondentNot, 13)
setFilter(TriggerFilterType.DocumentTypeNot, 14)
setFilter(TriggerFilterType.StoragePathNot, 15)
const formValues = component['getFormValues']()
expect(formValues.triggers[0].filter_has_all_tags).toEqual([11])
expect(formValues.triggers[0].filter_has_not_tags).toEqual([12])
expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([13])
expect(formValues.triggers[0].filter_has_not_document_types).toEqual([14])
expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([15])
})
it('should reuse filter type options and update disabled state', () => {
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0) as FormGroup
component.addFilter(triggerGroup)
const optionsFirst = component.getFilterTypeOptions(triggerGroup, 0)
const optionsSecond = component.getFilterTypeOptions(triggerGroup, 0)
expect(optionsFirst).toBe(optionsSecond)
// to force disabled flag
component.addFilter(triggerGroup)
const filterArray = component.getFiltersFormArray(triggerGroup)
const firstFilter = filterArray.at(0)
firstFilter.get('type').setValue(TriggerFilterType.CorrespondentIs)
component.addFilter(triggerGroup)
const updatedFilters = component.getFiltersFormArray(triggerGroup)
const secondFilter = updatedFilters.at(1)
const options = component.getFilterTypeOptions(triggerGroup, 1)
const correspondentIsOption = options.find(
(option) => option.id === TriggerFilterType.CorrespondentIs
)
expect(correspondentIsOption.disabled).toBe(true)
firstFilter.get('type').setValue(TriggerFilterType.DocumentTypeNot)
secondFilter.get('type').setValue(TriggerFilterType.TagsAll)
const postChangeOptions = component.getFilterTypeOptions(triggerGroup, 1)
const correspondentOptionAfter = postChangeOptions.find(
(option) => option.id === TriggerFilterType.CorrespondentIs
)
expect(correspondentOptionAfter.disabled).toBe(false)
})
it('should keep multi-entry filter options enabled and allow duplicates', () => {
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0) as FormGroup
component.filterDefinitions = [
{
id: TriggerFilterType.TagsAny,
name: 'Any tags',
inputType: 'tags',
allowMultipleEntries: true,
allowMultipleValues: true,
} as any,
{
id: TriggerFilterType.CorrespondentIs,
name: 'Correspondent is',
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: false,
selectItems: 'correspondents',
} as any,
]
const firstFilter = component.addFilter(triggerGroup)
firstFilter.get('type').setValue(TriggerFilterType.TagsAny)
const secondFilter = component.addFilter(triggerGroup)
expect(secondFilter).not.toBeNull()
const options = component.getFilterTypeOptions(triggerGroup, 1)
const multiEntryOption = options.find(
(option) => option.id === TriggerFilterType.TagsAny
)
expect(multiEntryOption.disabled).toBe(false)
expect(component.canAddFilter(triggerGroup)).toBe(true)
})
it('should return null when no filter definitions remain available', () => {
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0) as FormGroup
component.filterDefinitions = [
{
id: TriggerFilterType.TagsAny,
name: 'Any tags',
inputType: 'tags',
allowMultipleEntries: false,
allowMultipleValues: true,
} as any,
{
id: TriggerFilterType.CorrespondentIs,
name: 'Correspondent is',
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: false,
selectItems: 'correspondents',
} as any,
]
const firstFilter = component.addFilter(triggerGroup)
firstFilter.get('type').setValue(TriggerFilterType.TagsAny)
const secondFilter = component.addFilter(triggerGroup)
secondFilter.get('type').setValue(TriggerFilterType.CorrespondentIs)
expect(component.canAddFilter(triggerGroup)).toBe(false)
expect(component.addFilter(triggerGroup)).toBeNull()
})
it('should skip filter definitions without handlers when building form array', () => {
const originalDefinitions = component.filterDefinitions
component.filterDefinitions = [
{
id: 999,
name: 'Unsupported',
inputType: 'text',
allowMultipleEntries: false,
allowMultipleValues: false,
} as any,
]
const trigger = {
filter_has_tags: [],
filter_has_all_tags: [],
filter_has_not_tags: [],
filter_has_not_correspondents: [],
filter_has_not_document_types: [],
filter_has_not_storage_paths: [],
filter_has_correspondent: null,
filter_has_document_type: null,
filter_has_storage_path: null,
filter_custom_field_query: null,
} as any
const filters = component['buildFiltersFormArray'](trigger)
expect(filters.length).toBe(0)
component.filterDefinitions = originalDefinitions
})
it('should return null when adding filter for unknown trigger form group', () => {
expect(component.addFilter(new FormGroup({}) as any)).toBeNull()
})
it('should ignore remove filter calls for unknown trigger form group', () => {
expect(() =>
component.removeFilter(new FormGroup({}) as any, 0)
).not.toThrow()
})
it('should teardown custom field query model when removing a custom field filter', () => {
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0) as FormGroup
component.addFilter(triggerGroup)
const filters = component.getFiltersFormArray(triggerGroup)
const filterGroup = filters.at(0) as FormGroup
filterGroup.get('type').setValue(TriggerFilterType.CustomFieldQuery)
const model = component.getCustomFieldQueryModel(filterGroup)
expect(model).toBeDefined()
expect(
component['getStoredCustomFieldQueryModel'](filterGroup as any)
).toBe(model)
component.removeFilter(triggerGroup, 0)
expect(
component['getStoredCustomFieldQueryModel'](filterGroup as any)
).toBeNull()
})
it('should return readable filter names', () => {
expect(component.getFilterName(TriggerFilterType.TagsAny)).toBe(
'Has any of these tags'
)
expect(component.getFilterName(999 as any)).toBe('')
})
it('should build filter form array from existing trigger filters', () => {
const trigger = workflow.triggers[0]
trigger.filter_has_tags = [1]
trigger.filter_has_all_tags = [2, 3]
trigger.filter_has_not_tags = [4]
trigger.filter_has_correspondent = 5 as any
trigger.filter_has_not_correspondents = [6] as any
trigger.filter_has_document_type = 7 as any
trigger.filter_has_not_document_types = [8] as any
trigger.filter_has_storage_path = 9 as any
trigger.filter_has_not_storage_paths = [10] as any
trigger.filter_custom_field_query = JSON.stringify([
'AND',
[[1, 'exact', 'value']],
]) as any
component.object = workflow
component.ngOnInit()
const triggerGroup = component.triggerFields.at(0) as FormGroup
const filters = component.getFiltersFormArray(triggerGroup)
expect(filters.length).toBe(10)
const customFieldFilter = filters.at(9) as FormGroup
expect(customFieldFilter.get('type').value).toBe(
TriggerFilterType.CustomFieldQuery
)
const model = component.getCustomFieldQueryModel(customFieldFilter)
expect(model.isValid()).toBe(true)
})
it('should expose select metadata helpers', () => {
expect(component.isSelectMultiple(TriggerFilterType.CorrespondentNot)).toBe(
true
)
expect(component.isSelectMultiple(TriggerFilterType.CorrespondentIs)).toBe(
false
)
component.correspondents = [{ id: 1, name: 'C1' } as any]
component.documentTypes = [{ id: 2, name: 'DT' } as any]
component.storagePaths = [{ id: 3, name: 'SP' } as any]
expect(
component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
).toEqual(component.correspondents)
expect(
component.getFilterSelectItems(TriggerFilterType.DocumentTypeIs)
).toEqual(component.documentTypes)
expect(
component.getFilterSelectItems(TriggerFilterType.StoragePathIs)
).toEqual(component.storagePaths)
expect(component.getFilterSelectItems(TriggerFilterType.TagsAll)).toEqual(
[]
)
expect(
component.isCustomFieldQueryFilter(TriggerFilterType.CustomFieldQuery)
).toBe(true)
})
it('should return empty select items when definition is missing', () => {
const originalDefinitions = component.filterDefinitions
component.filterDefinitions = []
expect(
component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
).toEqual([])
component.filterDefinitions = originalDefinitions
})
it('should return empty select items when definition has unknown source', () => {
const originalDefinitions = component.filterDefinitions
component.filterDefinitions = [
{
id: TriggerFilterType.CorrespondentIs,
name: 'Correspondent is',
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: false,
selectItems: 'unknown',
} as any,
]
expect(
component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
).toEqual([])
component.filterDefinitions = originalDefinitions
})
it('should handle custom field query selection change and validation states', () => {
const formGroup = new FormGroup({
values: new FormControl(null),
})
const model = new CustomFieldQueriesModel()
const changeSpy = jest.spyOn(
component as any,
'onCustomFieldQueryModelChanged'
)
component.onCustomFieldQuerySelectionChange(formGroup, model)
expect(changeSpy).toHaveBeenCalledWith(formGroup, model)
expect(component.isCustomFieldQueryValid(formGroup)).toBe(true)
component['setCustomFieldQueryModel'](formGroup as any, model as any)
const validSpy = jest.spyOn(model, 'isValid').mockReturnValue(false)
const emptySpy = jest.spyOn(model, 'isEmpty').mockReturnValue(false)
expect(component.isCustomFieldQueryValid(formGroup)).toBe(false)
expect(validSpy).toHaveBeenCalled()
validSpy.mockReturnValue(true)
emptySpy.mockReturnValue(true)
expect(component.isCustomFieldQueryValid(formGroup)).toBe(true)
emptySpy.mockReturnValue(false)
expect(component.isCustomFieldQueryValid(formGroup)).toBe(true)
component['clearCustomFieldQueryModel'](formGroup as any)
})
it('should recover from invalid custom field query json and update control on changes', () => {
const filterGroup = new FormGroup({
values: new FormControl('not-json'),
})
component['ensureCustomFieldQueryModel'](filterGroup, 'not-json')
const model = component['getStoredCustomFieldQueryModel'](
filterGroup as any
)
expect(model).toBeDefined()
expect(model.queries.length).toBeGreaterThan(0)
const valuesControl = filterGroup.get('values')
expect(valuesControl.value).toBeNull()
const expression = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[[1, 'exact', 'value']],
])
model.queries = [expression]
jest.spyOn(model, 'isValid').mockReturnValue(true)
jest.spyOn(model, 'isEmpty').mockReturnValue(false)
model.changed.next(model)
expect(valuesControl.value).toEqual(JSON.stringify(expression.serialize()))
component['clearCustomFieldQueryModel'](filterGroup as any)
})
it('should handle custom field query model change edge cases', () => {
const groupWithoutControl = new FormGroup({})
const dummyModel = {
isValid: jest.fn().mockReturnValue(true),
isEmpty: jest.fn().mockReturnValue(false),
}
expect(() =>
component['onCustomFieldQueryModelChanged'](
groupWithoutControl as any,
dummyModel as any
)
).not.toThrow()
const groupWithControl = new FormGroup({
values: new FormControl('initial'),
})
const emptyModel = {
isValid: jest.fn().mockReturnValue(true),
isEmpty: jest.fn().mockReturnValue(true),
}
component['onCustomFieldQueryModelChanged'](
groupWithControl as any,
emptyModel as any
)
expect(groupWithControl.get('values').value).toBeNull()
})
it('should normalize filter values for single and multi selects', () => {
expect(
component['normalizeFilterValue'](TriggerFilterType.TagsAny)
).toEqual([])
expect(
component['normalizeFilterValue'](TriggerFilterType.TagsAny, 5)
).toEqual([5])
expect(
component['normalizeFilterValue'](TriggerFilterType.TagsAny, [5, 6])
).toEqual([5, 6])
expect(
component['normalizeFilterValue'](TriggerFilterType.CorrespondentIs, [7])
).toEqual(7)
expect(
component['normalizeFilterValue'](TriggerFilterType.CorrespondentIs, 8)
).toEqual(8)
const customFieldJson = JSON.stringify(['AND', [[1, 'exact', 'test']]])
expect(
component['normalizeFilterValue'](
TriggerFilterType.CustomFieldQuery,
customFieldJson
)
).toEqual(customFieldJson)
const customFieldObject = ['AND', [[1, 'exact', 'other']]]
expect(
component['normalizeFilterValue'](
TriggerFilterType.CustomFieldQuery,
customFieldObject
)
).toEqual(JSON.stringify(customFieldObject))
expect(
component['normalizeFilterValue'](
TriggerFilterType.CustomFieldQuery,
false
)
).toBeNull()
})
it('should add and remove filter form groups', () => {
component['changeDetector'] = { detectChanges: jest.fn() } as any
component.object = undefined
component.addTrigger()
const triggerGroup = component.triggerFields.at(0) as FormGroup
component.addFilter(triggerGroup)
component.removeFilter(triggerGroup, 0)
expect(component.getFiltersFormArray(triggerGroup).length).toBe(0)
component.addFilter(triggerGroup)
const filterArrayAfterAdd = component.getFiltersFormArray(triggerGroup)
filterArrayAfterAdd.at(0).get('type').setValue(TriggerFilterType.TagsAll)
expect(component.getFiltersFormArray(triggerGroup).length).toBe(1)
})
it('should remove selected custom field from the form group', () => { it('should remove selected custom field from the form group', () => {
const formGroup = new FormGroup({ const formGroup = new FormGroup({
assign_custom_fields: new FormControl([1, 2, 3]), assign_custom_fields: new FormControl([1, 2, 3]),

View File

@@ -6,7 +6,6 @@ import {
import { NgTemplateOutlet } from '@angular/common' import { NgTemplateOutlet } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core' import { Component, OnInit, inject } from '@angular/core'
import { import {
AbstractControl,
FormArray, FormArray,
FormControl, FormControl,
FormGroup, FormGroup,
@@ -15,7 +14,7 @@ import {
} from '@angular/forms' } from '@angular/forms'
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap' import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subscription, first, takeUntil } from 'rxjs' import { first } from 'rxjs'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
@@ -46,12 +45,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service' import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component' import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
import {
CustomFieldQueriesModel,
CustomFieldsQueryDropdownComponent,
} from '../../custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import { CheckComponent } from '../../input/check/check.component' import { CheckComponent } from '../../input/check/check.component'
import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component' import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component'
import { EntriesComponent } from '../../input/entries/entries.component' import { EntriesComponent } from '../../input/entries/entries.component'
@@ -141,235 +135,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
}, },
] ]
export enum TriggerFilterType {
TagsAny = 'tags_any',
TagsAll = 'tags_all',
TagsNone = 'tags_none',
CorrespondentIs = 'correspondent_is',
CorrespondentNot = 'correspondent_not',
DocumentTypeIs = 'document_type_is',
DocumentTypeNot = 'document_type_not',
StoragePathIs = 'storage_path_is',
StoragePathNot = 'storage_path_not',
CustomFieldQuery = 'custom_field_query',
}
interface TriggerFilterDefinition {
id: TriggerFilterType
name: string
inputType: 'tags' | 'select' | 'customFieldQuery'
allowMultipleEntries: boolean
allowMultipleValues: boolean
selectItems?: 'correspondents' | 'documentTypes' | 'storagePaths'
disabled?: boolean
}
type TriggerFilterOption = TriggerFilterDefinition & {
disabled?: boolean
}
type TriggerFilterAggregate = {
filter_has_tags: number[]
filter_has_all_tags: number[]
filter_has_not_tags: number[]
filter_has_not_correspondents: number[]
filter_has_not_document_types: number[]
filter_has_not_storage_paths: number[]
filter_has_correspondent: number | null
filter_has_document_type: number | null
filter_has_storage_path: number | null
filter_custom_field_query: string | null
}
interface FilterHandler {
apply: (aggregate: TriggerFilterAggregate, values: any) => void
extract: (trigger: WorkflowTrigger) => any
hasValue: (value: any) => boolean
}
const CUSTOM_FIELD_QUERY_MODEL_KEY = Symbol('customFieldQueryModel')
const CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY = Symbol(
'customFieldQuerySubscription'
)
type CustomFieldFilterGroup = FormGroup & {
[CUSTOM_FIELD_QUERY_MODEL_KEY]?: CustomFieldQueriesModel
[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?: Subscription
}
const TRIGGER_FILTER_DEFINITIONS: TriggerFilterDefinition[] = [
{
id: TriggerFilterType.TagsAny,
name: $localize`Has any of these tags`,
inputType: 'tags',
allowMultipleEntries: false,
allowMultipleValues: true,
},
{
id: TriggerFilterType.TagsAll,
name: $localize`Has all of these tags`,
inputType: 'tags',
allowMultipleEntries: false,
allowMultipleValues: true,
},
{
id: TriggerFilterType.TagsNone,
name: $localize`Does not have these tags`,
inputType: 'tags',
allowMultipleEntries: false,
allowMultipleValues: true,
},
{
id: TriggerFilterType.CorrespondentIs,
name: $localize`Has correspondent`,
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: false,
selectItems: 'correspondents',
},
{
id: TriggerFilterType.CorrespondentNot,
name: $localize`Does not have correspondents`,
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: true,
selectItems: 'correspondents',
},
{
id: TriggerFilterType.DocumentTypeIs,
name: $localize`Has document type`,
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: false,
selectItems: 'documentTypes',
},
{
id: TriggerFilterType.DocumentTypeNot,
name: $localize`Does not have document types`,
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: true,
selectItems: 'documentTypes',
},
{
id: TriggerFilterType.StoragePathIs,
name: $localize`Has storage path`,
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: false,
selectItems: 'storagePaths',
},
{
id: TriggerFilterType.StoragePathNot,
name: $localize`Does not have storage paths`,
inputType: 'select',
allowMultipleEntries: false,
allowMultipleValues: true,
selectItems: 'storagePaths',
},
{
id: TriggerFilterType.CustomFieldQuery,
name: $localize`Matches custom field query`,
inputType: 'customFieldQuery',
allowMultipleEntries: false,
allowMultipleValues: false,
},
]
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter( const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
(a) => a.id !== MATCH_AUTO (a) => a.id !== MATCH_AUTO
) )
const FILTER_HANDLERS: Record<TriggerFilterType, FilterHandler> = {
[TriggerFilterType.TagsAny]: {
apply: (aggregate, values) => {
aggregate.filter_has_tags = Array.isArray(values) ? [...values] : [values]
},
extract: (trigger) => trigger.filter_has_tags,
hasValue: (value) => Array.isArray(value) && value.length > 0,
},
[TriggerFilterType.TagsAll]: {
apply: (aggregate, values) => {
aggregate.filter_has_all_tags = Array.isArray(values)
? [...values]
: [values]
},
extract: (trigger) => trigger.filter_has_all_tags,
hasValue: (value) => Array.isArray(value) && value.length > 0,
},
[TriggerFilterType.TagsNone]: {
apply: (aggregate, values) => {
aggregate.filter_has_not_tags = Array.isArray(values)
? [...values]
: [values]
},
extract: (trigger) => trigger.filter_has_not_tags,
hasValue: (value) => Array.isArray(value) && value.length > 0,
},
[TriggerFilterType.CorrespondentIs]: {
apply: (aggregate, values) => {
aggregate.filter_has_correspondent = Array.isArray(values)
? (values[0] ?? null)
: values
},
extract: (trigger) => trigger.filter_has_correspondent,
hasValue: (value) => value !== null && value !== undefined,
},
[TriggerFilterType.CorrespondentNot]: {
apply: (aggregate, values) => {
aggregate.filter_has_not_correspondents = Array.isArray(values)
? [...values]
: [values]
},
extract: (trigger) => trigger.filter_has_not_correspondents,
hasValue: (value) => Array.isArray(value) && value.length > 0,
},
[TriggerFilterType.DocumentTypeIs]: {
apply: (aggregate, values) => {
aggregate.filter_has_document_type = Array.isArray(values)
? (values[0] ?? null)
: values
},
extract: (trigger) => trigger.filter_has_document_type,
hasValue: (value) => value !== null && value !== undefined,
},
[TriggerFilterType.DocumentTypeNot]: {
apply: (aggregate, values) => {
aggregate.filter_has_not_document_types = Array.isArray(values)
? [...values]
: [values]
},
extract: (trigger) => trigger.filter_has_not_document_types,
hasValue: (value) => Array.isArray(value) && value.length > 0,
},
[TriggerFilterType.StoragePathIs]: {
apply: (aggregate, values) => {
aggregate.filter_has_storage_path = Array.isArray(values)
? (values[0] ?? null)
: values
},
extract: (trigger) => trigger.filter_has_storage_path,
hasValue: (value) => value !== null && value !== undefined,
},
[TriggerFilterType.StoragePathNot]: {
apply: (aggregate, values) => {
aggregate.filter_has_not_storage_paths = Array.isArray(values)
? [...values]
: [values]
},
extract: (trigger) => trigger.filter_has_not_storage_paths,
hasValue: (value) => Array.isArray(value) && value.length > 0,
},
[TriggerFilterType.CustomFieldQuery]: {
apply: (aggregate, values) => {
aggregate.filter_custom_field_query = values as string
},
extract: (trigger) => trigger.filter_custom_field_query,
hasValue: (value) =>
typeof value === 'string' && value !== null && value.trim().length > 0,
},
}
@Component({ @Component({
selector: 'pngx-workflow-edit-dialog', selector: 'pngx-workflow-edit-dialog',
templateUrl: './workflow-edit-dialog.component.html', templateUrl: './workflow-edit-dialog.component.html',
@@ -384,7 +153,6 @@ const FILTER_HANDLERS: Record<TriggerFilterType, FilterHandler> = {
TextAreaComponent, TextAreaComponent,
TagsComponent, TagsComponent,
CustomFieldsValuesComponent, CustomFieldsValuesComponent,
CustomFieldsQueryDropdownComponent,
PermissionsGroupComponent, PermissionsGroupComponent,
PermissionsUserComponent, PermissionsUserComponent,
ConfirmButtonComponent, ConfirmButtonComponent,
@@ -402,8 +170,6 @@ export class WorkflowEditDialogComponent
{ {
public WorkflowTriggerType = WorkflowTriggerType public WorkflowTriggerType = WorkflowTriggerType
public WorkflowActionType = WorkflowActionType public WorkflowActionType = WorkflowActionType
public TriggerFilterType = TriggerFilterType
public filterDefinitions = TRIGGER_FILTER_DEFINITIONS
private correspondentService: CorrespondentService private correspondentService: CorrespondentService
private documentTypeService: DocumentTypeService private documentTypeService: DocumentTypeService
@@ -423,11 +189,6 @@ export class WorkflowEditDialogComponent
private allowedActionTypes = [] private allowedActionTypes = []
private readonly triggerFilterOptionsMap = new WeakMap<
FormArray,
TriggerFilterOption[]
>()
constructor() { constructor() {
super() super()
this.service = inject(WorkflowService) this.service = inject(WorkflowService)
@@ -629,416 +390,6 @@ export class WorkflowEditDialogComponent
return this.objectForm.get('actions') as FormArray return this.objectForm.get('actions') as FormArray
} }
protected override getFormValues(): any {
const formValues = super.getFormValues()
if (formValues?.triggers?.length) {
formValues.triggers = formValues.triggers.map(
(trigger: any, index: number) => {
const triggerFormGroup = this.triggerFields.at(index) as FormGroup
const filters = this.getFiltersFormArray(triggerFormGroup)
const aggregate: TriggerFilterAggregate = {
filter_has_tags: [],
filter_has_all_tags: [],
filter_has_not_tags: [],
filter_has_not_correspondents: [],
filter_has_not_document_types: [],
filter_has_not_storage_paths: [],
filter_has_correspondent: null,
filter_has_document_type: null,
filter_has_storage_path: null,
filter_custom_field_query: null,
}
for (const control of filters.controls) {
const type = control.get('type').value as TriggerFilterType
const values = control.get('values').value
if (values === null || values === undefined) {
continue
}
if (Array.isArray(values) && values.length === 0) {
continue
}
const handler = FILTER_HANDLERS[type]
handler?.apply(aggregate, values)
}
trigger.filter_has_tags = aggregate.filter_has_tags
trigger.filter_has_all_tags = aggregate.filter_has_all_tags
trigger.filter_has_not_tags = aggregate.filter_has_not_tags
trigger.filter_has_not_correspondents =
aggregate.filter_has_not_correspondents
trigger.filter_has_not_document_types =
aggregate.filter_has_not_document_types
trigger.filter_has_not_storage_paths =
aggregate.filter_has_not_storage_paths
trigger.filter_has_correspondent =
aggregate.filter_has_correspondent ?? null
trigger.filter_has_document_type =
aggregate.filter_has_document_type ?? null
trigger.filter_has_storage_path =
aggregate.filter_has_storage_path ?? null
trigger.filter_custom_field_query =
aggregate.filter_custom_field_query ?? null
delete trigger.filters
return trigger
}
)
}
return formValues
}
public matchingPatternRequired(formGroup: FormGroup): boolean {
return formGroup.get('matching_algorithm').value !== MATCH_NONE
}
private createFilterFormGroup(
type: TriggerFilterType,
initialValue?: any
): FormGroup {
const group = new FormGroup({
type: new FormControl(type),
values: new FormControl(this.normalizeFilterValue(type, initialValue)),
})
group.get('type').valueChanges.subscribe((newType: TriggerFilterType) => {
if (newType === TriggerFilterType.CustomFieldQuery) {
this.ensureCustomFieldQueryModel(group)
} else {
this.clearCustomFieldQueryModel(group)
group.get('values').setValue(this.getDefaultFilterValue(newType), {
emitEvent: false,
})
}
})
if (type === TriggerFilterType.CustomFieldQuery) {
this.ensureCustomFieldQueryModel(group, initialValue)
}
return group
}
private buildFiltersFormArray(trigger: WorkflowTrigger): FormArray {
const filters = new FormArray([])
for (const definition of this.filterDefinitions) {
const handler = FILTER_HANDLERS[definition.id]
if (!handler) {
continue
}
const value = handler.extract(trigger)
if (!handler.hasValue(value)) {
continue
}
filters.push(this.createFilterFormGroup(definition.id, value))
}
return filters
}
getFiltersFormArray(formGroup: FormGroup): FormArray {
return formGroup.get('filters') as FormArray
}
getFilterTypeOptions(formGroup: FormGroup, filterIndex: number) {
const filters = this.getFiltersFormArray(formGroup)
const options = this.getFilterTypeOptionsForArray(filters)
const currentType = filters.at(filterIndex).get('type')
.value as TriggerFilterType
const usedTypes = new Set(
filters.controls.map(
(control) => control.get('type').value as TriggerFilterType
)
)
for (const option of options) {
if (option.allowMultipleEntries) {
option.disabled = false
continue
}
option.disabled = usedTypes.has(option.id) && option.id !== currentType
}
return options
}
canAddFilter(formGroup: FormGroup): boolean {
const filters = this.getFiltersFormArray(formGroup)
const usedTypes = new Set(
filters.controls.map(
(control) => control.get('type').value as TriggerFilterType
)
)
return this.filterDefinitions.some((definition) => {
if (definition.allowMultipleEntries) {
return true
}
return !usedTypes.has(definition.id)
})
}
addFilter(triggerFormGroup: FormGroup): FormGroup | null {
const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup)
if (triggerIndex === -1) {
return null
}
const filters = this.getFiltersFormArray(triggerFormGroup)
const availableDefinition = this.filterDefinitions.find((definition) => {
if (definition.allowMultipleEntries) {
return true
}
return !filters.controls.some(
(control) => control.get('type').value === definition.id
)
})
if (!availableDefinition) {
return null
}
filters.push(this.createFilterFormGroup(availableDefinition.id))
triggerFormGroup.markAsDirty()
triggerFormGroup.markAsTouched()
return filters.at(-1) as FormGroup
}
removeFilter(triggerFormGroup: FormGroup, filterIndex: number) {
const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup)
if (triggerIndex === -1) {
return
}
const filters = this.getFiltersFormArray(triggerFormGroup)
const filterGroup = filters.at(filterIndex) as FormGroup
if (filterGroup?.get('type').value === TriggerFilterType.CustomFieldQuery) {
this.clearCustomFieldQueryModel(filterGroup)
}
filters.removeAt(filterIndex)
triggerFormGroup.markAsDirty()
triggerFormGroup.markAsTouched()
}
getFilterDefinition(
type: TriggerFilterType
): TriggerFilterDefinition | undefined {
return this.filterDefinitions.find((definition) => definition.id === type)
}
getFilterName(type: TriggerFilterType): string {
return this.getFilterDefinition(type)?.name ?? ''
}
isTagsFilter(type: TriggerFilterType): boolean {
return this.getFilterDefinition(type)?.inputType === 'tags'
}
isCustomFieldQueryFilter(type: TriggerFilterType): boolean {
return this.getFilterDefinition(type)?.inputType === 'customFieldQuery'
}
isMultiValueFilter(type: TriggerFilterType): boolean {
switch (type) {
case TriggerFilterType.TagsAny:
case TriggerFilterType.TagsAll:
case TriggerFilterType.TagsNone:
case TriggerFilterType.CorrespondentNot:
case TriggerFilterType.DocumentTypeNot:
case TriggerFilterType.StoragePathNot:
return true
default:
return false
}
}
isSelectMultiple(type: TriggerFilterType): boolean {
return !this.isTagsFilter(type) && this.isMultiValueFilter(type)
}
getFilterSelectItems(type: TriggerFilterType) {
const definition = this.getFilterDefinition(type)
if (!definition || definition.inputType !== 'select') {
return []
}
switch (definition.selectItems) {
case 'correspondents':
return this.correspondents
case 'documentTypes':
return this.documentTypes
case 'storagePaths':
return this.storagePaths
default:
return []
}
}
getCustomFieldQueryModel(control: AbstractControl): CustomFieldQueriesModel {
return this.ensureCustomFieldQueryModel(control as FormGroup)
}
onCustomFieldQuerySelectionChange(
control: AbstractControl,
model: CustomFieldQueriesModel
) {
this.onCustomFieldQueryModelChanged(control as FormGroup, model)
}
isCustomFieldQueryValid(control: AbstractControl): boolean {
const model = this.getStoredCustomFieldQueryModel(control as FormGroup)
if (!model) {
return true
}
return model.isEmpty() || model.isValid()
}
private getFilterTypeOptionsForArray(
filters: FormArray
): TriggerFilterOption[] {
let cached = this.triggerFilterOptionsMap.get(filters)
if (!cached) {
cached = this.filterDefinitions.map((definition) => ({
...definition,
disabled: false,
}))
this.triggerFilterOptionsMap.set(filters, cached)
}
return cached
}
private ensureCustomFieldQueryModel(
filterGroup: FormGroup,
initialValue?: any
): CustomFieldQueriesModel {
const existingModel = this.getStoredCustomFieldQueryModel(filterGroup)
if (existingModel) {
return existingModel
}
const model = new CustomFieldQueriesModel()
this.setCustomFieldQueryModel(filterGroup, model)
const rawValue =
typeof initialValue === 'string'
? initialValue
: (filterGroup.get('values').value as string)
if (rawValue) {
try {
const parsed = JSON.parse(rawValue)
const expression = new CustomFieldQueryExpression(parsed)
model.queries = [expression]
} catch {
model.clear(false)
model.addInitialAtom()
}
}
const subscription = model.changed
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.onCustomFieldQueryModelChanged(filterGroup, model)
})
filterGroup[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?.unsubscribe()
filterGroup[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY] = subscription
this.onCustomFieldQueryModelChanged(filterGroup, model)
return model
}
private clearCustomFieldQueryModel(filterGroup: FormGroup) {
const group = filterGroup as CustomFieldFilterGroup
group[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?.unsubscribe()
delete group[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]
delete group[CUSTOM_FIELD_QUERY_MODEL_KEY]
}
private getStoredCustomFieldQueryModel(
filterGroup: FormGroup
): CustomFieldQueriesModel | null {
return (
(filterGroup as CustomFieldFilterGroup)[CUSTOM_FIELD_QUERY_MODEL_KEY] ??
null
)
}
private setCustomFieldQueryModel(
filterGroup: FormGroup,
model: CustomFieldQueriesModel
) {
const group = filterGroup as CustomFieldFilterGroup
group[CUSTOM_FIELD_QUERY_MODEL_KEY] = model
}
private onCustomFieldQueryModelChanged(
filterGroup: FormGroup,
model: CustomFieldQueriesModel
) {
const control = filterGroup.get('values')
if (!control) {
return
}
if (!model.isValid()) {
control.setValue(null, { emitEvent: false })
return
}
if (model.isEmpty()) {
control.setValue(null, { emitEvent: false })
return
}
const serialized = JSON.stringify(model.queries[0].serialize())
control.setValue(serialized, { emitEvent: false })
}
private getDefaultFilterValue(type: TriggerFilterType) {
if (type === TriggerFilterType.CustomFieldQuery) {
return null
}
return this.isMultiValueFilter(type) ? [] : null
}
private normalizeFilterValue(type: TriggerFilterType, value?: any) {
if (value === undefined || value === null) {
return this.getDefaultFilterValue(type)
}
if (type === TriggerFilterType.CustomFieldQuery) {
if (typeof value === 'string') {
return value
}
return value ? JSON.stringify(value) : null
}
if (this.isMultiValueFilter(type)) {
return Array.isArray(value) ? [...value] : [value]
}
if (Array.isArray(value)) {
return value.length > 0 ? value[0] : null
}
return value
}
private createTriggerField( private createTriggerField(
trigger: WorkflowTrigger, trigger: WorkflowTrigger,
emitEvent: boolean = false emitEvent: boolean = false
@@ -1054,7 +405,16 @@ export class WorkflowEditDialogComponent
matching_algorithm: new FormControl(trigger.matching_algorithm), matching_algorithm: new FormControl(trigger.matching_algorithm),
match: new FormControl(trigger.match), match: new FormControl(trigger.match),
is_insensitive: new FormControl(trigger.is_insensitive), is_insensitive: new FormControl(trigger.is_insensitive),
filters: this.buildFiltersFormArray(trigger), filter_has_tags: new FormControl(trigger.filter_has_tags),
filter_has_correspondent: new FormControl(
trigger.filter_has_correspondent
),
filter_has_document_type: new FormControl(
trigger.filter_has_document_type
),
filter_has_storage_path: new FormControl(
trigger.filter_has_storage_path
),
schedule_offset_days: new FormControl(trigger.schedule_offset_days), schedule_offset_days: new FormControl(trigger.schedule_offset_days),
schedule_is_recurring: new FormControl(trigger.schedule_is_recurring), schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
schedule_recurring_interval_days: new FormControl( schedule_recurring_interval_days: new FormControl(
@@ -1177,12 +537,6 @@ export class WorkflowEditDialogComponent
filter_path: null, filter_path: null,
filter_mailrule: null, filter_mailrule: null,
filter_has_tags: [], filter_has_tags: [],
filter_has_all_tags: [],
filter_has_not_tags: [],
filter_has_not_correspondents: [],
filter_has_not_document_types: [],
filter_has_not_storage_paths: [],
filter_custom_field_query: null,
filter_has_correspondent: null, filter_has_correspondent: null,
filter_has_document_type: null, filter_has_document_type: null,
filter_has_storage_path: null, filter_has_storage_path: null,

View File

@@ -1,9 +1,5 @@
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title" i18n>{ <h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
documentIds.length,
plural,
=1 {Email Document} other {Email {{documentIds.length}} Documents}
}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button> <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -26,14 +22,11 @@
<input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion"> <input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
<label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label> <label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label>
</div> </div>
<button type="submit" class="btn btn-outline-primary" (click)="emailDocuments()" [disabled]="loading || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0"> <button type="submit" class="btn btn-outline-primary" (click)="emailDocument()" [disabled]="loading || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0">
@if (loading) { @if (loading) {
<div class="spinner-border spinner-border-sm me-2" role="status"></div> <div class="spinner-border spinner-border-sm me-2" role="status"></div>
} }
<ng-container i18n>Send email</ng-container> <ng-container i18n>Send email</ng-container>
</button> </button>
</div> </div>
<div class="text-light fst-italic small mt-2">
<ng-container i18n>Some email servers may reject messages with large attachments.</ng-container>
</div>
</div> </div>

View File

@@ -36,59 +36,31 @@ describe('EmailDocumentDialogComponent', () => {
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
component = fixture.componentInstance component = fixture.componentInstance
component.documentIds = [1]
fixture.detectChanges() fixture.detectChanges()
}) })
it('should set hasArchiveVersion and useArchiveVersion', () => { it('should set hasArchiveVersion and useArchiveVersion', () => {
expect(component.hasArchiveVersion).toBeTruthy() expect(component.hasArchiveVersion).toBeTruthy()
expect(component.useArchiveVersion).toBeTruthy()
component.hasArchiveVersion = false component.hasArchiveVersion = false
expect(component.hasArchiveVersion).toBeFalsy() expect(component.hasArchiveVersion).toBeFalsy()
expect(component.useArchiveVersion).toBeFalsy() expect(component.useArchiveVersion).toBeFalsy()
}) })
it('should support sending single document via email, showing error if needed', () => { it('should support sending document via email, showing error if needed', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
component.documentIds = [1]
component.emailAddress = 'hello@paperless-ngx.com' component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello' component.emailSubject = 'Hello'
component.emailMessage = 'World' component.emailMessage = 'World'
jest jest
.spyOn(documentService, 'emailDocuments') .spyOn(documentService, 'emailDocument')
.mockReturnValue(throwError(() => new Error('Unable to email document'))) .mockReturnValue(throwError(() => new Error('Unable to email document')))
component.emailDocuments() component.emailDocument()
expect(toastErrorSpy).toHaveBeenCalledWith( expect(toastErrorSpy).toHaveBeenCalled()
'Error emailing document',
expect.any(Error)
)
jest.spyOn(documentService, 'emailDocuments').mockReturnValue(of(true)) jest.spyOn(documentService, 'emailDocument').mockReturnValue(of(true))
component.emailDocuments() component.emailDocument()
expect(toastSuccessSpy).toHaveBeenCalledWith('Email sent') expect(toastSuccessSpy).toHaveBeenCalled()
})
it('should support sending multiple documents via email, showing appropriate messages', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
component.documentIds = [1, 2, 3]
component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello'
component.emailMessage = 'World'
jest
.spyOn(documentService, 'emailDocuments')
.mockReturnValue(throwError(() => new Error('Unable to email documents')))
component.emailDocuments()
expect(toastErrorSpy).toHaveBeenCalledWith(
'Error emailing documents',
expect.any(Error)
)
jest.spyOn(documentService, 'emailDocuments').mockReturnValue(of(true))
component.emailDocuments()
expect(toastSuccessSpy).toHaveBeenCalledWith('Email sent')
}) })
it('should close the dialog', () => { it('should close the dialog', () => {

View File

@@ -18,7 +18,10 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
private toastService = inject(ToastService) private toastService = inject(ToastService)
@Input() @Input()
documentIds: number[] title = $localize`Email Document`
@Input()
documentId: number
private _hasArchiveVersion: boolean = true private _hasArchiveVersion: boolean = true
@@ -43,11 +46,11 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
this.loading = false this.loading = false
} }
public emailDocuments() { public emailDocument() {
this.loading = true this.loading = true
this.documentService this.documentService
.emailDocuments( .emailDocument(
this.documentIds, this.documentId,
this.emailAddress, this.emailAddress,
this.emailSubject, this.emailSubject,
this.emailMessage, this.emailMessage,
@@ -64,11 +67,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
}, },
error: (e) => { error: (e) => {
this.loading = false this.loading = false
const errorMessage = this.toastService.showError($localize`Error emailing document`, e)
this.documentIds.length > 1
? $localize`Error emailing documents`
: $localize`Error emailing document`
this.toastService.showError(errorMessage, e)
}, },
}) })
} }

View File

@@ -564,208 +564,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
]) ])
}) })
it('keeps children with their parent when parent has document count', () => {
const parent: Tag = {
id: 10,
name: 'Parent Tag',
orderIndex: 0,
document_count: 2,
}
const child: Tag = {
id: 11,
name: 'Child Tag',
parent: parent.id,
orderIndex: 1,
document_count: 0,
}
const otherRoot: Tag = {
id: 20,
name: 'Other Tag',
orderIndex: 2,
document_count: 0,
}
component.selectionModel.items = [parent, child, otherRoot]
component.selectionModel = selectionModel
component.documentCounts = [
{ id: parent.id, document_count: 2 },
{ id: otherRoot.id, document_count: 0 },
]
selectionModel.apply()
expect(component.selectionModel.items).toEqual([
nullItem,
parent,
child,
otherRoot,
])
})
it('keeps selected branches ahead of document-based ordering', () => {
const selectedRoot: Tag = {
id: 30,
name: 'Selected Root',
orderIndex: 0,
document_count: 0,
}
const otherRoot: Tag = {
id: 40,
name: 'Other Root',
orderIndex: 1,
document_count: 2,
}
component.selectionModel.items = [selectedRoot, otherRoot]
component.selectionModel = selectionModel
selectionModel.set(selectedRoot.id, ToggleableItemState.Selected)
component.documentCounts = [
{ id: selectedRoot.id, document_count: 0 },
{ id: otherRoot.id, document_count: 2 },
]
selectionModel.apply()
expect(component.selectionModel.items).toEqual([
nullItem,
selectedRoot,
otherRoot,
])
})
it('resorts items immediately when document count sorting enabled', () => {
const apple: Tag = { id: 55, name: 'Apple' }
const zebra: Tag = { id: 56, name: 'Zebra' }
selectionModel.documentCountSortingEnabled = true
selectionModel.items = [apple, zebra]
expect(selectionModel.items.map((item) => item?.id ?? null)).toEqual([
null,
apple.id,
zebra.id,
])
selectionModel.documentCounts = [
{ id: zebra.id, document_count: 5 },
{ id: apple.id, document_count: 0 },
]
expect(selectionModel.items.map((item) => item?.id ?? null)).toEqual([
null,
zebra.id,
apple.id,
])
})
it('does not resort items by default when document counts are set', () => {
const first: Tag = { id: 57, name: 'First' }
const second: Tag = { id: 58, name: 'Second' }
selectionModel.items = [first, second]
selectionModel.documentCounts = [
{ id: second.id, document_count: 10 },
{ id: first.id, document_count: 0 },
]
expect(selectionModel.items.map((item) => item?.id ?? null)).toEqual([
null,
first.id,
second.id,
])
})
it('uses fallback document counts when selection data is missing', () => {
const fallbackRoot: Tag = {
id: 50,
name: 'Fallback Root',
orderIndex: 0,
document_count: 3,
}
const fallbackChild: Tag = {
id: 51,
name: 'Fallback Child',
parent: fallbackRoot.id,
orderIndex: 1,
document_count: 0,
}
const otherRoot: Tag = {
id: 60,
name: 'Other Root',
orderIndex: 2,
document_count: 0,
}
component.selectionModel = selectionModel
selectionModel.items = [fallbackRoot, fallbackChild, otherRoot]
component.documentCounts = [{ id: otherRoot.id, document_count: 0 }]
selectionModel.apply()
expect(selectionModel.items).toEqual([
nullItem,
fallbackRoot,
fallbackChild,
otherRoot,
])
})
it('handles special and non-numeric ids when promoting branches', () => {
const rootWithDocs: Tag = {
id: 70,
name: 'Root With Docs',
orderIndex: 0,
document_count: 1,
}
const miscItem: any = { id: 'misc', name: 'Misc Item' }
component.selectionModel = selectionModel
selectionModel.intersection = Intersection.Exclude
selectionModel.items = [rootWithDocs, miscItem as any]
component.documentCounts = [{ id: rootWithDocs.id, document_count: 1 }]
selectionModel.apply()
expect(selectionModel.items.map((item) => item.id)).toEqual([
NEGATIVE_NULL_FILTER_VALUE,
rootWithDocs.id,
'misc',
])
})
it('memoizes root document counts between lookups', () => {
const memoRoot: Tag = { id: 80, name: 'Memo Root' }
selectionModel.items = [memoRoot]
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
const getRootDocCount = (selectionModel as any).createRootDocCounter()
expect(getRootDocCount(memoRoot.id)).toEqual(9)
selectionModel.documentCounts = []
expect(getRootDocCount(memoRoot.id)).toEqual(9)
})
it('falls back to model stored document counts if selection data missing entry', () => {
const rootWithoutSelection: Tag = {
id: 90,
name: 'Fallback Root',
document_count: 4,
}
selectionModel.items = [rootWithoutSelection]
selectionModel.documentCounts = []
const getRootDocCount = (selectionModel as any).createRootDocCounter()
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
})
it('defaults to zero document count when neither selection nor model provide it', () => {
const rootWithoutCounts: Tag = { id: 91, name: 'Fallback Zero Root' }
selectionModel.items = [rootWithoutCounts]
selectionModel.documentCounts = []
const getRootDocCount = (selectionModel as any).createRootDocCounter()
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
})
it('should set support create, keep open model and call createRef method', fakeAsync(() => { it('should set support create, keep open model and call createRef method', fakeAsync(() => {
component.selectionModel.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'

View File

@@ -32,14 +32,6 @@ export interface ChangedItems {
itemsToRemove: MatchingModel[] itemsToRemove: MatchingModel[]
} }
type BranchSummary = {
items: MatchingModel[]
firstIndex: number
special: boolean
selected: boolean
hasDocs: boolean
}
export enum LogicalOperator { export enum LogicalOperator {
And = 'and', And = 'and',
Or = 'or', Or = 'or',
@@ -61,13 +53,8 @@ export class FilterableDropdownSelectionModel {
temporaryIntersection: Intersection = this._intersection temporaryIntersection: Intersection = this._intersection
private _documentCounts: SelectionDataItem[] = [] private _documentCounts: SelectionDataItem[] = []
public documentCountSortingEnabled = false
public set documentCounts(counts: SelectionDataItem[]) { public set documentCounts(counts: SelectionDataItem[]) {
this._documentCounts = counts this._documentCounts = counts
if (this.documentCountSortingEnabled) {
this.sortItems()
}
} }
private _items: MatchingModel[] = [] private _items: MatchingModel[] = []
@@ -160,10 +147,6 @@ export class FilterableDropdownSelectionModel {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
} }
}) })
if (this._documentCounts.length) {
this.promoteBranchesWithDocumentCounts()
}
} }
private selectionStates = new Map<number, ToggleableItemState>() private selectionStates = new Map<number, ToggleableItemState>()
@@ -397,180 +380,6 @@ export class FilterableDropdownSelectionModel {
return this._documentCounts.find((c) => c.id === id)?.document_count return this._documentCounts.find((c) => c.id === id)?.document_count
} }
private promoteBranchesWithDocumentCounts() {
const parentById = this.buildParentById()
const findRootId = this.createRootFinder(parentById)
const getRootDocCount = this.createRootDocCounter()
const summaries = this.buildBranchSummaries(findRootId, getRootDocCount)
const orderedBranches = this.orderBranchesByPriority(summaries)
this._items = orderedBranches.flatMap((summary) => summary.items)
}
private buildParentById(): Map<number, number | null> {
const parentById = new Map<number, number | null>()
for (const item of this._items) {
if (typeof item?.id === 'number') {
const parentValue = (item as any)['parent']
parentById.set(
item.id,
typeof parentValue === 'number' ? parentValue : null
)
}
}
return parentById
}
private createRootFinder(
parentById: Map<number, number | null>
): (id: number) => number {
const rootMemo = new Map<number, number>()
const findRootId = (id: number): number => {
const cached = rootMemo.get(id)
if (cached !== undefined) {
return cached
}
const parentId = parentById.get(id)
if (parentId === undefined || parentId === null) {
rootMemo.set(id, id)
return id
}
const rootId = findRootId(parentId)
rootMemo.set(id, rootId)
return rootId
}
return findRootId
}
private createRootDocCounter(): (rootId: number) => number {
const docCountMemo = new Map<number, number>()
return (rootId: number): number => {
const cached = docCountMemo.get(rootId)
if (cached !== undefined) {
return cached
}
const explicit = this.getDocumentCount(rootId)
if (typeof explicit === 'number') {
docCountMemo.set(rootId, explicit)
return explicit
}
const rootItem = this._items.find((i) => i.id === rootId)
const fallback =
typeof (rootItem as any)?.['document_count'] === 'number'
? (rootItem as any)['document_count']
: 0
docCountMemo.set(rootId, fallback)
return fallback
}
}
private buildBranchSummaries(
findRootId: (id: number) => number,
getRootDocCount: (rootId: number) => number
): Map<string, BranchSummary> {
const summaries = new Map<string, BranchSummary>()
for (const [index, item] of this._items.entries()) {
const { key, special, rootId } = this.describeBranchItem(
item,
index,
findRootId
)
let summary = summaries.get(key)
if (!summary) {
summary = {
items: [],
firstIndex: index,
special,
selected: false,
hasDocs:
special || rootId === null ? false : getRootDocCount(rootId) > 0,
}
summaries.set(key, summary)
}
summary.items.push(item)
if (this.shouldMarkSummarySelected(summary, item)) {
summary.selected = true
}
}
return summaries
}
private describeBranchItem(
item: MatchingModel,
index: number,
findRootId: (id: number) => number
): { key: string; special: boolean; rootId: number | null } {
if (item?.id === null) {
return { key: 'null', special: true, rootId: null }
}
if (item?.id === NEGATIVE_NULL_FILTER_VALUE) {
return { key: 'neg-null', special: true, rootId: null }
}
if (typeof item?.id === 'number') {
const rootId = findRootId(item.id)
return { key: `root-${rootId}`, special: false, rootId }
}
return { key: `misc-${index}`, special: false, rootId: null }
}
private shouldMarkSummarySelected(
summary: BranchSummary,
item: MatchingModel
): boolean {
if (summary.special) {
return false
}
if (typeof item?.id !== 'number') {
return false
}
return this.getNonTemporary(item.id) !== ToggleableItemState.NotSelected
}
private orderBranchesByPriority(
summaries: Map<string, BranchSummary>
): BranchSummary[] {
return Array.from(summaries.values()).sort((a, b) => {
const rankDiff = this.branchRank(a) - this.branchRank(b)
if (rankDiff !== 0) {
return rankDiff
}
if (a.hasDocs !== b.hasDocs) {
return a.hasDocs ? -1 : 1
}
return a.firstIndex - b.firstIndex
})
}
private branchRank(summary: BranchSummary): number {
if (summary.special) {
return -1
}
if (summary.selected) {
return 0
}
return 1
}
init(map: Map<number, ToggleableItemState>) { init(map: Map<number, ToggleableItemState>) {
this.temporarySelectionStates = map this.temporarySelectionStates = map
this.apply() this.apply()
@@ -656,9 +465,8 @@ export class FilterableDropdownComponent
this.selectionModel.changed.complete() this.selectionModel.changed.complete()
model.items = this.selectionModel.items model.items = this.selectionModel.items
model.manyToOne = this.selectionModel.manyToOne model.manyToOne = this.selectionModel.manyToOne
model.singleSelect = this._editing && !model.manyToOne model.singleSelect = this.editing && !this.selectionModel.manyToOne
} }
model.documentCountSortingEnabled = this._editing
model.changed.subscribe((updatedModel) => { model.changed.subscribe((updatedModel) => {
this.selectionModelChange.next(updatedModel) this.selectionModelChange.next(updatedModel)
}) })
@@ -688,21 +496,8 @@ export class FilterableDropdownComponent
@Input() @Input()
allowSelectNone: boolean = false allowSelectNone: boolean = false
private _editing = false
@Input() @Input()
set editing(value: boolean) { editing = false
this._editing = value
if (this.selectionModel) {
this.selectionModel.singleSelect =
this._editing && !this.selectionModel.manyToOne
this.selectionModel.documentCountSortingEnabled = this._editing
}
}
get editing() {
return this._editing
}
@Input() @Input()
applyOnClose = false applyOnClose = false

View File

@@ -14,7 +14,7 @@
</div> </div>
</div> </div>
<div class="mt-2 align-items-center bg-light p-2"> <div class="mt-2 align-items-center bg-light p-2">
<div class="d-flex flex-wrap flex-row gap-2 w-100" style="min-height: 1em;" <div class="d-flex flex-wrap flex-row gap-2 w-100"
cdkDropList #unselectedList="cdkDropList" cdkDropList #unselectedList="cdkDropList"
cdkDropListOrientation="mixed" cdkDropListOrientation="mixed"
(cdkDropListDropped)="drop($event)" (cdkDropListDropped)="drop($event)"

View File

@@ -19,7 +19,7 @@
</div> </div>
} }
@if (hint) { @if (hint) {
<small class="form-text text-muted" [innerHTML]="hint"></small> <small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
} }
<div class="invalid-feedback position-absolute top-100"> <div class="invalid-feedback position-absolute top-100">
{{error}} {{error}}

View File

@@ -24,7 +24,7 @@
} }
<input #inputField type="hidden" class="form-control small" [(ngModel)]="value" [disabled]="true"> <input #inputField type="hidden" class="form-control small" [(ngModel)]="value" [disabled]="true">
@if (hint) { @if (hint) {
<small class="form-text text-muted" [innerHTML]="hint"></small> <small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
} }
<div class="invalid-feedback position-absolute top-100"> <div class="invalid-feedback position-absolute top-100">
{{error}} {{error}}

View File

@@ -1,24 +1,17 @@
<div class="mb-3" [class.pb-3]="error"> <div class="mb-3">
<div class="row"> <label class="form-label" [for]="inputId">{{title}}</label>
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal"> <div class="input-group" [class.is-invalid]="error">
@if (title) { <input #inputField [type]="showReveal && textVisible ? 'text' : 'password'" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (focus)="onFocus()" (focusout)="onFocusOut()" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete">
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label> @if (showReveal) {
} <button type="button" class="btn btn-outline-secondary" (click)="toggleVisibility()" i18n-title title="Show password" [disabled]="disabled || disableRevealToggle">
</div> <i-bs name="eye"></i-bs>
<div class="position-relative" [class.col-md-9]="horizontal"> </button>
<div class="input-group" [class.is-invalid]="error">
<input #inputField [type]="showReveal && textVisible ? 'text' : 'password'" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (focus)="onFocus()" (focusout)="onFocusOut()" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete">
@if (showReveal) {
<button type="button" class="btn btn-outline-secondary" (click)="toggleVisibility()" i18n-title title="Show password" [disabled]="disabled || disableRevealToggle">
<i-bs name="eye"></i-bs>
</button>
}
</div>
<div class="invalid-feedback">
{{error}}
</div>
@if (hint) {
<small class="form-text text-muted" [innerHTML]="hint"></small>
} }
</div> </div>
<div class="invalid-feedback">
{{error}}
</div>
@if (hint) {
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
}
</div> </div>

View File

@@ -1,69 +1,66 @@
<div class="mb-3 paperless-input-select" [class.disabled]="disabled"> <div class="mb-3 paperless-input-select" [class.disabled]="disabled">
<div class="row"> <div class="row">
@if (title || removable) { <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal"> @if (title) {
@if (title) { <label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label> }
@if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
</button>
} }
@if (removable) { </div>
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> <div [class.col-md-9]="horizontal">
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container> <div [class.input-group]="allowCreateNew || showFilter" [class.is-invalid]="error">
<ng-select name="inputId" [(ngModel)]="value"
[disabled]="disabled"
[style.color]="textColor"
[style.background]="backgroundColor"
[class.private]="isPrivate"
[clearable]="allowNull"
[items]="items"
[addTag]="allowCreateNew && addItemRef"
addTagText="Add item"
i18n-addTagText="Used for both types, correspondents, storage paths"
[placeholder]="placeholder"
[notFoundText]="notFoundText"
[multiple]="multiple"
[bindLabel]="bindLabel"
bindValue="id"
(change)="onChange(value)"
(search)="onSearch($event)"
(focus)="clearLastSearchTerm()"
(clear)="clearLastSearchTerm()"
(blur)="onBlur()">
<ng-template ng-option-tmp let-item="item">
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
</ng-template>
</ng-select>
@if (allowCreateNew && !hideAddButton) {
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
</button> </button>
}
</div>
}
<div [class.col-md-9]="horizontal">
<div [class.input-group]="allowCreateNew || showFilter" [class.is-invalid]="error">
<ng-select name="inputId" [(ngModel)]="value"
[disabled]="disabled"
[style.color]="textColor"
[style.background]="backgroundColor"
[class.private]="isPrivate"
[clearable]="allowNull"
[items]="items"
[addTag]="allowCreateNew && addItemRef"
addTagText="Add item"
i18n-addTagText="Used for both types, correspondents, storage paths"
[placeholder]="placeholder"
[notFoundText]="notFoundText"
[multiple]="multiple"
[bindLabel]="bindLabel"
bindValue="id"
[virtualScroll]="items?.length > 100"
(change)="onChange(value)"
(search)="onSearch($event)"
(focus)="clearLastSearchTerm()"
(clear)="clearLastSearchTerm()"
(blur)="onBlur()">
<ng-template ng-option-tmp let-item="item">
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
</ng-template>
</ng-select>
@if (allowCreateNew && !hideAddButton) {
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
</button>
}
@if (showFilter) {
<button class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
<i-bs width="1.2em" height="1.2em" name="filter"></i-bs>
</button>
}
</div>
<div class="invalid-feedback">
{{error}}
</div>
@if (hint) {
<small class="form-text text-muted">{{hint}}</small>
}
@if (getSuggestions().length > 0) {
<small>
<span i18n>Suggestions:</span>&nbsp;
@for (s of getSuggestions(); track s) {
<a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>&nbsp;
} }
</small> @if (showFilter) {
} <button class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
<i-bs width="1.2em" height="1.2em" name="filter"></i-bs>
</button>
}
</div>
<div class="invalid-feedback">
{{error}}
</div>
@if (hint) {
<small class="form-text text-muted">{{hint}}</small>
}
@if (getSuggestions().length > 0) {
<small>
<span i18n>Suggestions:</span>&nbsp;
@for (s of getSuggestions(); track s) {
<a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>&nbsp;
}
</small>
}
</div>
</div> </div>
</div> </div>
</div>

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