mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-01-10 21:34:20 -06:00
Compare commits
1 Commits
feature-si
...
feature-di
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44d25f72b7 |
@@ -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"
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
2
.github/DISCUSSION_TEMPLATE/support.yml
vendored
2
.github/DISCUSSION_TEMPLATE/support.yml
vendored
@@ -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
|
||||||
|
|||||||
10
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
10
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -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:
|
||||||
|
|||||||
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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.
|
||||||
|
|||||||
50
.github/dependabot.yml
vendored
50
.github/dependabot.yml
vendored
@@ -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"
|
||||||
|
|||||||
104
.github/workflows/ci-backend.yml
vendored
104
.github/workflows/ci-backend.yml
vendored
@@ -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
|
|
||||||
233
.github/workflows/ci-docker.yml
vendored
233
.github/workflows/ci-docker.yml
vendored
@@ -1,233 +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: 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
|
|
||||||
88
.github/workflows/ci-docs.yml
vendored
88
.github/workflows/ci-docs.yml
vendored
@@ -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
|
|
||||||
189
.github/workflows/ci-frontend.yml
vendored
189
.github/workflows/ci-frontend.yml
vendored
@@ -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
|
|
||||||
24
.github/workflows/ci-lint.yml
vendored
24
.github/workflows/ci-lint.yml
vendored
@@ -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
|
|
||||||
237
.github/workflows/ci-release.yml
vendored
237
.github/workflows/ci-release.yml
vendored
@@ -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
673
.github/workflows/ci.yml
vendored
Normal 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']
|
||||||
|
});
|
||||||
4
.github/workflows/cleanup-tags.yml
vendored
4
.github/workflows/cleanup-tags.yml
vendored
@@ -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 }}"
|
||||||
|
|||||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -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
|
||||||
|
|||||||
2
.github/workflows/crowdin.yml
vendored
2
.github/workflows/crowdin.yml
vendored
@@ -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
|
||||||
|
|||||||
2
.github/workflows/repo-maintenance.yml
vendored
2
.github/workflows/repo-maintenance.yml
vendored
@@ -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'
|
||||||
|
|||||||
18
.github/workflows/translate-strings.yml
vendored
18
.github/workflows/translate-strings.yml
vendored
@@ -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"
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
35
Dockerfile
35
Dockerfile
@@ -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", \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,23 +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.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -25,10 +25,9 @@ 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.
|
||||||
- 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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -443,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
|
||||||
@@ -459,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}
|
||||||
@@ -471,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
|
||||||
|
|
||||||
@@ -562,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
|
||||||
|
|
||||||
@@ -901,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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,33 +40,33 @@ 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",
|
||||||
"filelock~=3.20.0",
|
"filelock~=3.19.1",
|
||||||
"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",
|
||||||
"jinja2~=3.1.5",
|
"jinja2~=3.1.5",
|
||||||
"langdetect~=1.0.9",
|
"langdetect~=1.0.9",
|
||||||
"nltk~=3.9.1",
|
"nltk~=3.9.1",
|
||||||
"ocrmypdf~=16.12.0",
|
"ocrmypdf~=16.11.0",
|
||||||
"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",
|
||||||
"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",
|
||||||
"watchfiles>=1.1.1",
|
"watchdog~=6.0",
|
||||||
"whitenoise~=6.9",
|
"whitenoise~=6.9",
|
||||||
"whoosh-reloaded>=2.7.5",
|
"whoosh-reloaded>=2.7.5",
|
||||||
"zxing-cpp~=2.3.0",
|
"zxing-cpp~=2.3.0",
|
||||||
@@ -78,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",
|
||||||
@@ -97,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 = [
|
||||||
@@ -116,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 = [
|
||||||
@@ -140,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
|
||||||
@@ -253,7 +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/",
|
|
||||||
]
|
]
|
||||||
addopts = [
|
addopts = [
|
||||||
"--pythonwarnings=all",
|
"--pythonwarnings=all",
|
||||||
@@ -306,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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
1600
src-ui/messages.xlf
1600
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
@@ -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
6182
src-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -128,6 +129,7 @@ describe('SettingsComponent', () => {
|
|||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
CheckComponent,
|
CheckComponent,
|
||||||
ColorComponent,
|
ColorComponent,
|
||||||
|
SafeHtmlPipe,
|
||||||
SelectComponent,
|
SelectComponent,
|
||||||
TextComponent,
|
TextComponent,
|
||||||
NumberComponent,
|
NumberComponent,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -68,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">
|
||||||
|
|||||||
@@ -152,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"> {{title}}</div>
|
||||||
<div class="d-none d-sm-inline"> {{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) {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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' }} – <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' }} – <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' }} – {{ item.dateEnd | customDate:'mediumDate' }}
|
|
||||||
} @else {
|
|
||||||
{{ item.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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> <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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -12,6 +12,6 @@
|
|||||||
{{error}}
|
{{error}}
|
||||||
</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>
|
</div>
|
||||||
|
|||||||
@@ -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> <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> <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>
|
|
||||||
@for (s of getSuggestions(); track s) {
|
|
||||||
<a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>
|
|
||||||
}
|
}
|
||||||
</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>
|
||||||
|
@for (s of getSuggestions(); track s) {
|
||||||
|
<a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>
|
||||||
|
}
|
||||||
|
</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0">
|
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@if (title) {
|
<div class="d-flex align-items-center" [class.col-md-3]="horizontal">
|
||||||
<div class="d-flex align-items-center" [class.col-md-3]="horizontal">
|
<label class="form-label" [class.mb-md-0]="horizontal" for="tags">{{title}}</label>
|
||||||
<label class="form-label" [class.mb-md-0]="horizontal" for="tags">{{title}}</label>
|
</div>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="position-relative" [class.col-md-9]="horizontal">
|
<div class="position-relative" [class.col-md-9]="horizontal">
|
||||||
<div class="input-group flex-nowrap">
|
<div class="input-group flex-nowrap">
|
||||||
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ describe('TagsComponent', () => {
|
|||||||
|
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser = { id: 1 }
|
|
||||||
fixture = TestBed.createComponent(TagsComponent)
|
fixture = TestBed.createComponent(TagsComponent)
|
||||||
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
@@ -139,7 +138,7 @@ describe('TagsComponent', () => {
|
|||||||
settingsService.currentUser = { id: 1 }
|
settingsService.currentUser = { id: 1 }
|
||||||
let activeInstances: NgbModalRef[]
|
let activeInstances: NgbModalRef[]
|
||||||
modalService.activeInstances.subscribe((v) => (activeInstances = v))
|
modalService.activeInstances.subscribe((v) => (activeInstances = v))
|
||||||
component.select.filter('foobar')
|
component.select.searchTerm = 'foobar'
|
||||||
component.createTag()
|
component.createTag()
|
||||||
expect(modalService.hasOpenModals()).toBeTruthy()
|
expect(modalService.hasOpenModals()).toBeTruthy()
|
||||||
expect(activeInstances[0].componentInstance.object.name).toEqual('foobar')
|
expect(activeInstances[0].componentInstance.object.name).toEqual('foobar')
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
if (name) modal.componentInstance.object = { name: name }
|
if (name) modal.componentInstance.object = { name: name }
|
||||||
else if (this.select.searchTerm)
|
else if (this.select.searchTerm)
|
||||||
modal.componentInstance.object = { name: this.select.searchTerm }
|
modal.componentInstance.object = { name: this.select.searchTerm }
|
||||||
this.select.filter(null)
|
this.select.searchTerm = null
|
||||||
this.select.detectChanges()
|
this.select.detectChanges()
|
||||||
return firstValueFrom(
|
return firstValueFrom(
|
||||||
(modal.componentInstance as TagEditDialogComponent).succeeded.pipe(
|
(modal.componentInstance as TagEditDialogComponent).succeeded.pipe(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="position-relative" [class.col-md-9]="horizontal">
|
<div class="position-relative" [class.col-md-9]="horizontal">
|
||||||
<input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete" [placeholder]="placeholder">
|
<input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete" [placeholder]="placeholder">
|
||||||
@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}}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||||
import { AbstractInputComponent } from '../abstract-input'
|
import { AbstractInputComponent } from '../abstract-input'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -18,7 +19,12 @@ import { AbstractInputComponent } from '../abstract-input'
|
|||||||
selector: 'pngx-input-text',
|
selector: 'pngx-input-text',
|
||||||
templateUrl: './text.component.html',
|
templateUrl: './text.component.html',
|
||||||
styleUrls: ['./text.component.scss'],
|
styleUrls: ['./text.component.scss'],
|
||||||
imports: [FormsModule, ReactiveFormsModule, NgxBootstrapIconsModule],
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
SafeHtmlPipe,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class TextComponent extends AbstractInputComponent<string> {
|
export class TextComponent extends AbstractInputComponent<string> {
|
||||||
@Input()
|
@Input()
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
rows="4">
|
rows="4">
|
||||||
</textarea>
|
</textarea>
|
||||||
@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}}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||||
import { AbstractInputComponent } from '../abstract-input'
|
import { AbstractInputComponent } from '../abstract-input'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -18,7 +19,12 @@ import { AbstractInputComponent } from '../abstract-input'
|
|||||||
selector: 'pngx-input-textarea',
|
selector: 'pngx-input-textarea',
|
||||||
templateUrl: './textarea.component.html',
|
templateUrl: './textarea.component.html',
|
||||||
styleUrls: ['./textarea.component.scss'],
|
styleUrls: ['./textarea.component.scss'],
|
||||||
imports: [FormsModule, ReactiveFormsModule, NgxBootstrapIconsModule],
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
SafeHtmlPipe,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class TextAreaComponent extends AbstractInputComponent<string> {
|
export class TextAreaComponent extends AbstractInputComponent<string> {
|
||||||
@Input()
|
@Input()
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
|
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
|
||||||
<div class="col-md text-truncate">
|
<div class="col-md text-truncate">
|
||||||
<h3 class="text-truncate" style="line-height: 1.4">
|
<h3 class="text-truncate d-flex align-items-center" style="line-height: 1.4">
|
||||||
{{title}}
|
{{title}}
|
||||||
|
@if (id) {
|
||||||
|
<span class="badge bg-primary text-primary-text-contrast ms-2 small fs-normal">ID: {{id}}</span>
|
||||||
|
}
|
||||||
@if (subTitle) {
|
@if (subTitle) {
|
||||||
<span class="h6 mb-0 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
|
<span class="h6 mb-0 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
h3 {
|
h3 {
|
||||||
min-height: calc(1.325rem + 0.9vw);
|
min-height: calc(1.325rem + 0.9vw);
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
@media (min-width: 1200px) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user