mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-12-06 06:45:05 +01:00
Compare commits
131 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21032ac008 | ||
|
|
b63e095a60 | ||
|
|
ce642409e8 | ||
|
|
2e5bd02e7e | ||
|
|
6f3451bce0 | ||
|
|
8c5b5cd77b | ||
|
|
919c54c6ba | ||
|
|
4632ad3a36 | ||
|
|
0c43b50f01 | ||
|
|
67d079fe14 | ||
|
|
ca674e5a02 | ||
|
|
71e08a1e98 | ||
|
|
1e61a6cd6a | ||
|
|
a76731ca89 | ||
|
|
ffc56bddda | ||
|
|
4c2cc373f2 | ||
|
|
76bb6d3422 | ||
|
|
85a2a0a416 | ||
|
|
5036aa1ea3 | ||
|
|
f7da273ab7 | ||
|
|
93338a0a82 | ||
|
|
a96db50b0a | ||
|
|
c5e80a7e4f | ||
|
|
bc622d67fc | ||
|
|
4a8d3c858c | ||
|
|
8c335321cd | ||
|
|
27966858fd | ||
|
|
d3bfb186e0 | ||
|
|
cf5ac596ed | ||
|
|
25b5e8fede | ||
|
|
80be6793cf | ||
|
|
7b175ec1b3 | ||
|
|
36d45ecf4d | ||
|
|
4bf681387a | ||
|
|
c05d75dab0 | ||
|
|
7a50157164 | ||
|
|
a93d83119e | ||
|
|
addaf92a61 | ||
|
|
8c7fa4e165 | ||
|
|
22a47a28dc | ||
|
|
20d921142e | ||
|
|
1ed8f1d086 | ||
|
|
46853e10dc | ||
|
|
c31c244b54 | ||
|
|
56493d6640 | ||
|
|
f7f94762b6 | ||
|
|
beb5fe2232 | ||
|
|
c924213f32 | ||
|
|
b053b35332 | ||
|
|
a45692aa0f | ||
|
|
c3ac102eba | ||
|
|
0e5ab7f3e0 | ||
|
|
533b64cb70 | ||
|
|
b3d6359afc | ||
|
|
b6e3827ab1 | ||
|
|
7cd802cf48 | ||
|
|
7470b799a3 | ||
|
|
b5df90156e | ||
|
|
733d2e19a0 | ||
|
|
fe7419484b | ||
|
|
0d827e8511 | ||
|
|
69514d8d70 | ||
|
|
dd6f7fad32 | ||
|
|
c5ad148dc7 | ||
|
|
b12f1e757c | ||
|
|
0cbab1ae80 | ||
|
|
0219df5b67 | ||
|
|
005ef4fce6 | ||
|
|
44f0191bfb | ||
|
|
e9f846ca24 | ||
|
|
2049497b76 | ||
|
|
2a9d1fce0d | ||
|
|
808c074f48 | ||
|
|
7927e5c436 | ||
|
|
cac48c9855 | ||
|
|
3fda648f37 | ||
|
|
95736eebc4 | ||
|
|
85027dbffd | ||
|
|
74f72e417d | ||
|
|
fe3c424d7d | ||
|
|
a0172a2754 | ||
|
|
810bf3d612 | ||
|
|
846cc47565 | ||
|
|
1d396d9160 | ||
|
|
2a4e8f9acd | ||
|
|
a9dfe8f3f7 | ||
|
|
906e841ded | ||
|
|
6684e80ffc | ||
|
|
3dc7cf3da1 | ||
|
|
819f606335 | ||
|
|
ad45e3f747 | ||
|
|
74b10db028 | ||
|
|
cffb9c34f0 | ||
|
|
6f52614817 | ||
|
|
a0d3527d20 | ||
|
|
4e64ca7ca6 | ||
|
|
e9511bd3da | ||
|
|
8b9ca75a90 | ||
|
|
9f0a4ac19d | ||
|
|
8f969ecab5 | ||
|
|
245e52a4eb | ||
|
|
a8c75d95d8 | ||
|
|
d6e2456baf | ||
|
|
3b75d3271e | ||
|
|
e88816d141 | ||
|
|
e5bd4713ac | ||
|
|
b9aced07fb | ||
|
|
6b55740f56 | ||
|
|
9aee063347 | ||
|
|
7fe411bb1a | ||
|
|
34b5f4c565 | ||
|
|
3808a4e14a | ||
|
|
3bd4135aba | ||
|
|
b60fb8ed82 | ||
|
|
3f32ed319a | ||
|
|
03e6d58f86 | ||
|
|
c197487374 | ||
|
|
d718d7d29f | ||
|
|
ce112cda0e | ||
|
|
d904aaef60 | ||
|
|
35bc673648 | ||
|
|
d0bd111eab | ||
|
|
cd81f750b4 | ||
|
|
48d21da13b | ||
|
|
701aafce06 | ||
|
|
cbe8bc35d6 | ||
|
|
1c4fa7237c | ||
|
|
63dab0ab09 | ||
|
|
276dc31abe | ||
|
|
a11a2ec13f | ||
|
|
df9136e7d4 |
196 changed files with 33223 additions and 14909 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-slim as main-app
|
||||
FROM --platform=$BUILDPLATFORM docker.io/node:20-trixie-slim as main-app
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
|
|
@ -8,16 +8,17 @@ ARG DEBIAN_FRONTEND=noninteractive
|
|||
ARG TARGETARCH
|
||||
|
||||
# Can be workflow provided, defaults set for manual building
|
||||
ARG JBIG2ENC_VERSION=0.29
|
||||
ARG QPDF_VERSION=11.9.0
|
||||
ARG GS_VERSION=10.03.1
|
||||
ARG JBIG2ENC_VERSION=0.30
|
||||
|
||||
# Set Python environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
# Ignore warning from Whitenoise
|
||||
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
|
||||
|
|
@ -83,37 +84,15 @@ RUN set -eux \
|
|||
&& apt-get update \
|
||||
&& apt-get install --yes --quiet ${PYTHON_PACKAGES}
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.7.8 /uv /bin/uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.10 /uv /bin/uv
|
||||
|
||||
|
||||
RUN set -eux \
|
||||
&& 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" \
|
||||
&& curl --fail --silent --show-error --location \
|
||||
--output jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
||||
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-${JBIG2ENC_VERSION}/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 \
|
||||
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb
|
||||
|
||||
# setup docker-specific things
|
||||
|
|
@ -127,6 +106,7 @@ COPY [ \
|
|||
|
||||
RUN set -eux \
|
||||
&& echo "Configuring ImageMagick" \
|
||||
&& mkdir -p /etc/ImageMagick-6 \
|
||||
&& mv paperless-policy.xml /etc/ImageMagick-6/policy.xml
|
||||
|
||||
|
||||
|
|
@ -142,7 +122,7 @@ ARG BUILD_PACKAGES="\
|
|||
pkg-config"
|
||||
|
||||
# hadolint ignore=DL3042
|
||||
RUN --mount=type=cache,target=/root/.cache/uv,id=pip-cache \
|
||||
RUN --mount=type=cache,target=/cache/uv/,id=uv-cache \
|
||||
set -eux \
|
||||
&& echo "Installing build system packages" \
|
||||
&& apt-get update \
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ end_of_line = lf
|
|||
charset = utf-8
|
||||
max_line_length = 79
|
||||
|
||||
[*.sh]
|
||||
indent_style = tab
|
||||
indent_size = 1
|
||||
|
||||
[{*.html,*.css,*.js}]
|
||||
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
|
||||
attributes:
|
||||
label: Relevant logs or output
|
||||
description: If you have logs, errors that might help, paste it here.
|
||||
description: If you have logs, errors that might help, paste it here. For example other containers or services (database, redis, etc).
|
||||
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
|
||||
attributes:
|
||||
value: |
|
||||
### ⚠️ Please remember: issues are for *bugs*
|
||||
That is, something you believe affects every single user of Paperless-ngx, not just you. If you're not sure, start with one of the other options below.
|
||||
### ⚠️ Please remember: issues are for *bugs* only! ⚠️
|
||||
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.
|
||||
|
||||
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
|
||||
|
|
@ -59,6 +59,12 @@ body:
|
|||
label: Browser logs
|
||||
description: Logs from the web browser related to your issue, if needed
|
||||
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
|
||||
id: version
|
||||
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).
|
||||
- [ ] 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 new features & regressions on both mobile & desktop devices, using the latest version of major browsers.
|
||||
- [ ] 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 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 made corresponding changes to the documentation as needed.
|
||||
- [ ] I have checked my modifications for any breaking changes.
|
||||
- [ ] In the description of the PR above I have disclosed the use of AI tools in the coding of this PR.
|
||||
|
|
|
|||
50
.github/dependabot.yml
vendored
50
.github/dependabot.yml
vendored
|
|
@ -41,30 +41,56 @@ updates:
|
|||
- "backend"
|
||||
- "dependencies"
|
||||
groups:
|
||||
# Development & CI/CD Tooling
|
||||
development:
|
||||
patterns:
|
||||
- "*pytest*"
|
||||
- "ruff"
|
||||
- "mkdocs-material"
|
||||
- "pre-commit*"
|
||||
django:
|
||||
# Django & DRF Ecosystem
|
||||
django-ecosystem:
|
||||
patterns:
|
||||
- "*django*"
|
||||
- "drf-*"
|
||||
major-versions:
|
||||
- "djangorestframework"
|
||||
- "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:
|
||||
- "major"
|
||||
small-changes:
|
||||
- "patch"
|
||||
# Utilities (Minor Updates)
|
||||
utilities-minor:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
exclude-patterns:
|
||||
- "*django*"
|
||||
- "drf-*"
|
||||
pre-built:
|
||||
patterns:
|
||||
- psycopg*
|
||||
- zxing-cpp
|
||||
# Enable updates for GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
target-branch: "dev"
|
||||
|
|
|
|||
73
.github/workflows/ci.yml
vendored
73
.github/workflows/ci.yml
vendored
|
|
@ -88,7 +88,7 @@ jobs:
|
|||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
|
@ -115,7 +115,7 @@ jobs:
|
|||
--frozen \
|
||||
mkdocs gh-deploy --force --no-history
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: documentation
|
||||
path: site/
|
||||
|
|
@ -142,7 +142,7 @@ jobs:
|
|||
with:
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
|
@ -181,10 +181,11 @@ jobs:
|
|||
pytest
|
||||
- name: Upload backend test results to Codecov
|
||||
if: always()
|
||||
uses: codecov/test-results-action@v1
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
flags: backend-python-${{ matrix.python-version }}
|
||||
files: junit.xml
|
||||
report_type: test_results
|
||||
- name: Upload backend coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
|
@ -207,7 +208,7 @@ jobs:
|
|||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'pnpm'
|
||||
|
|
@ -240,7 +241,7 @@ jobs:
|
|||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'pnpm'
|
||||
|
|
@ -260,11 +261,12 @@ jobs:
|
|||
- 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()
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
flags: frontend-node-${{ matrix.node-version }}
|
||||
directory: src-ui/
|
||||
report_type: test_results
|
||||
- name: Upload frontend coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
|
@ -288,7 +290,7 @@ jobs:
|
|||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'pnpm'
|
||||
|
|
@ -331,7 +333,7 @@ jobs:
|
|||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'pnpm'
|
||||
|
|
@ -351,9 +353,9 @@ jobs:
|
|||
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 }}
|
||||
name: Build Docker image for ${{ github.event_name == 'pull_request' && github.head_ref || 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_'))
|
||||
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_'))) || (github.event_name == 'pull_request' && (startsWith(github.head_ref, 'feature-') || startsWith(github.head_ref, 'fix-') || github.head_ref == 'dev' || github.head_ref == 'beta' || contains(github.head_ref, 'beta.rc') || startsWith(github.head_ref, 'l10n_')))
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
|
@ -362,6 +364,23 @@ jobs:
|
|||
- tests-frontend
|
||||
- tests-frontend-e2e
|
||||
steps:
|
||||
- name: Prepare build variables
|
||||
id: build-vars
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
const isPR = context.eventName === 'pull_request';
|
||||
const defaultRefName = context.ref.replace('refs/heads/', '');
|
||||
const headRef = isPR ? context.payload.pull_request.head.ref : defaultRefName;
|
||||
const buildRef = isPR ? `refs/heads/${headRef}` : context.ref;
|
||||
const buildCacheKey = headRef.split('/').join('-');
|
||||
const canPush = context.eventName === 'push' || (isPR && context.payload.pull_request.head.repo.full_name === `${context.repo.owner}/${context.repo.repo}`);
|
||||
|
||||
core.setOutput('build-ref', buildRef);
|
||||
core.setOutput('build-ref-name', headRef);
|
||||
core.setOutput('build-cache-key', buildCacheKey);
|
||||
core.setOutput('can-push', canPush ? 'true' : 'false');
|
||||
- name: Check pushing to Docker Hub
|
||||
id: push-other-places
|
||||
# Only push to Dockerhub from the main repo AND the ref is either:
|
||||
|
|
@ -370,8 +389,11 @@ jobs:
|
|||
# beta
|
||||
# a tag
|
||||
# Otherwise forks would require a Docker Hub account and secrets setup
|
||||
env:
|
||||
BUILD_REF: ${{ steps.build-vars.outputs.build-ref }}
|
||||
BUILD_REF_NAME: ${{ steps.build-vars.outputs.build-ref-name }}
|
||||
run: |
|
||||
if [[ ${{ github.repository_owner }} == "paperless-ngx" && ( ${{ github.ref_name }} == "dev" || ${{ github.ref_name }} == "beta" || ${{ startsWith(github.ref, 'refs/tags/v') }} == "true" ) ]] ; then
|
||||
if [[ ${{ github.repository_owner }} == "paperless-ngx" && ( "$BUILD_REF_NAME" == "dev" || "$BUILD_REF_NAME" == "beta" || $BUILD_REF == refs/tags/v* || $BUILD_REF == *beta.rc* ) ]] ; then
|
||||
echo "Enabling DockerHub image push"
|
||||
echo "enable=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
|
|
@ -395,6 +417,8 @@ jobs:
|
|||
tags: |
|
||||
# Tag branches with branch name
|
||||
type=ref,event=branch
|
||||
# Pull requests need a sanitized branch tag for pushing images
|
||||
type=raw,value=${{ steps.build-vars.outputs.build-cache-key }},enable=${{ github.event_name == 'pull_request' }}
|
||||
# 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}}
|
||||
|
|
@ -437,7 +461,7 @@ jobs:
|
|||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ steps.build-vars.outputs.can-push == 'true' }}
|
||||
tags: ${{ steps.docker-meta.outputs.tags }}
|
||||
labels: ${{ steps.docker-meta.outputs.labels }}
|
||||
build-args: |
|
||||
|
|
@ -445,19 +469,21 @@ jobs:
|
|||
# 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:${{ steps.build-vars.outputs.build-cache-key }}
|
||||
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 }}
|
||||
cache-to: ${{ steps.build-vars.outputs.can-push == 'true' && format('type=registry,mode=max,ref=ghcr.io/{0}/builder/cache/app:{1}', steps.set-ghcr-repository.outputs.ghcr-repository, steps.build-vars.outputs.build-cache-key) || '' }}
|
||||
- name: Inspect image
|
||||
if: steps.build-vars.outputs.can-push == 'true'
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }}
|
||||
- name: Export frontend artifact from docker
|
||||
if: steps.build-vars.outputs.can-push == 'true'
|
||||
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
|
||||
if: steps.build-vars.outputs.can-push == 'true'
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: frontend-compiled
|
||||
path: src/documents/static/frontend/
|
||||
|
|
@ -467,6 +493,7 @@ jobs:
|
|||
needs:
|
||||
- build-docker-image
|
||||
- documentation
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
@ -477,7 +504,7 @@ jobs:
|
|||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
|
@ -490,12 +517,12 @@ jobs:
|
|||
sudo apt-get update -qq
|
||||
sudo apt-get install -qq --no-install-recommends gettext liblept5
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: frontend-compiled
|
||||
path: src/documents/static/frontend/
|
||||
- name: Download documentation artifact
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: documentation
|
||||
path: docs/_build/html/
|
||||
|
|
@ -558,7 +585,7 @@ jobs:
|
|||
sudo chown -R 1000:1000 paperless-ngx/
|
||||
tar -cJf paperless-ngx.tar.xz paperless-ngx/
|
||||
- name: Upload release artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: release
|
||||
path: dist/paperless-ngx.tar.xz
|
||||
|
|
@ -575,7 +602,7 @@ jobs:
|
|||
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
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: release
|
||||
path: ./
|
||||
|
|
@ -625,7 +652,7 @@ jobs:
|
|||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
|
|
|||
4
.github/workflows/cleanup-tags.yml
vendored
4
.github/workflows/cleanup-tags.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
steps:
|
||||
- name: Clean temporary images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.11.0
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.12.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "${{ github.repository_owner }}"
|
||||
|
|
@ -53,7 +53,7 @@ jobs:
|
|||
steps:
|
||||
- name: Clean untagged images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.11.0
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.12.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "${{ github.repository_owner }}"
|
||||
|
|
|
|||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
|
|
@ -37,7 +37,7 @@ jobs:
|
|||
uses: actions/checkout@v5
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# 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.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
|
|
|
|||
6
.github/workflows/translate-strings.yml
vendored
6
.github/workflows/translate-strings.yml
vendored
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
sudo apt-get update -qq
|
||||
sudo apt-get install -qq --no-install-recommends gettext
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Install backend python dependencies
|
||||
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'pnpm'
|
||||
|
|
@ -61,7 +61,7 @@ jobs:
|
|||
cd src-ui
|
||||
pnpm run ng extract-i18n
|
||||
- name: Commit changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v6
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po'
|
||||
commit_message: "Auto translate strings"
|
||||
|
|
|
|||
|
|
@ -49,12 +49,12 @@ repos:
|
|||
- 'prettier-plugin-organize-imports@4.1.0'
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.0
|
||||
rev: v0.14.5
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: "v2.11.0"
|
||||
rev: "v2.11.1"
|
||||
hooks:
|
||||
- id: pyproject-fmt
|
||||
# Dockerfile hooks
|
||||
|
|
@ -64,11 +64,11 @@ repos:
|
|||
- id: hadolint
|
||||
# Shell script hooks
|
||||
- repo: https://github.com/lovesegfault/beautysh
|
||||
rev: v6.2.1
|
||||
rev: v6.4.2
|
||||
hooks:
|
||||
- id: beautysh
|
||||
additional_dependencies:
|
||||
- setuptools
|
||||
types: [file]
|
||||
files: (\.sh$|/run$|/finish$)
|
||||
args:
|
||||
- "--tab"
|
||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||
|
|
@ -76,7 +76,7 @@ repos:
|
|||
hooks:
|
||||
- id: shellcheck
|
||||
- repo: https://github.com/google/yamlfmt
|
||||
rev: v0.18.0
|
||||
rev: v0.20.0
|
||||
hooks:
|
||||
- id: yamlfmt
|
||||
exclude: "^src-ui/pnpm-lock.yaml"
|
||||
|
|
|
|||
25
Dockerfile
25
Dockerfile
|
|
@ -5,7 +5,7 @@
|
|||
# Purpose: Compiles the frontend
|
||||
# Notes:
|
||||
# - Does PNPM stuff with Typescript and such
|
||||
FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-slim AS compile-frontend
|
||||
FROM --platform=$BUILDPLATFORM docker.io/node:20-trixie-slim AS compile-frontend
|
||||
|
||||
COPY ./src-ui /src/src-ui
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ RUN set -eux \
|
|||
# Purpose: Installs s6-overlay and rootfs
|
||||
# Comments:
|
||||
# - Don't leave anything extra in here either
|
||||
FROM ghcr.io/astral-sh/uv:0.9.4-python3.12-bookworm-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.9.15-python3.12-trixie-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
|
|
@ -102,8 +102,6 @@ ARG TARGETARCH
|
|||
|
||||
# Can be workflow provided, defaults set for manual building
|
||||
ARG JBIG2ENC_VERSION=0.30
|
||||
ARG QPDF_VERSION=11.9.0
|
||||
ARG GS_VERSION=10.03.1
|
||||
|
||||
# Set Python environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
|
|
@ -170,20 +168,8 @@ RUN set -eux \
|
|||
&& apt-get update \
|
||||
&& apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES} \
|
||||
&& echo "Installing pre-built updates" \
|
||||
&& curl --fail --silent --no-progress-meter --show-error --location --remote-name-all --parallel --parallel-max 4 \
|
||||
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 \
|
||||
&& curl --fail --silent --no-progress-meter --show-error --location --remote-name-all \
|
||||
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
||||
&& echo "Installing jbig2enc" \
|
||||
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
||||
&& echo "Configuring imagemagick" \
|
||||
|
|
@ -254,7 +240,8 @@ RUN set -eux \
|
|||
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
|
||||
&& echo "Collecting static files" \
|
||||
&& 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", \
|
||||
"/usr/src/paperless/media", \
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
# correct networking for the tests
|
||||
services:
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.24
|
||||
image: docker.io/gotenberg/gotenberg:8.25
|
||||
hostname: gotenberg
|
||||
container_name: gotenberg
|
||||
network_mode: host
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ services:
|
|||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.24
|
||||
image: docker.io/gotenberg/gotenberg:8.25
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ services:
|
|||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.24
|
||||
image: docker.io/gotenberg/gotenberg:8.25
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ services:
|
|||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.24
|
||||
image: docker.io/gotenberg/gotenberg:8.25
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# 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
|
||||
done
|
||||
else
|
||||
echo "${log_prefix} No *_FILE environment found"
|
||||
echo "${log_prefix} No *_FILE environment found"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -1,70 +1,66 @@
|
|||
#!/command/with-contenv /usr/bin/bash
|
||||
# shellcheck shell=bash
|
||||
# vim: set ft=bash ts=4 sw=4 sts=4 et :
|
||||
|
||||
declare -r log_prefix="[init-db-wait]"
|
||||
set -euo pipefail
|
||||
|
||||
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() {
|
||||
local attempt_num=1
|
||||
local -r max_attempts=5
|
||||
|
||||
echo "${log_prefix} Waiting for PostgreSQL to start..."
|
||||
echo "${LOG_PREFIX} Waiting for PostgreSQL to start..."
|
||||
|
||||
local -r host="${PAPERLESS_DBHOST:-localhost}"
|
||||
local -r port="${PAPERLESS_DBPORT:-5432}"
|
||||
local -r user="${PAPERLESS_DBUSER:-paperless}"
|
||||
|
||||
# Disable warning, host and port can't have spaces
|
||||
# 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
|
||||
while ! pg_isready -h "${host}" -p "${port}" --username "${user}"; do
|
||||
delay_next_attempt
|
||||
done
|
||||
# Extra in case this is a first start
|
||||
sleep 5
|
||||
echo "Connected to PostgreSQL"
|
||||
echo "${LOG_PREFIX} Connected to PostgreSQL"
|
||||
}
|
||||
|
||||
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 port="${PAPERLESS_DBPORT:=3306}"
|
||||
local -r host="${PAPERLESS_DBHOST:-localhost}"
|
||||
local -r port="${PAPERLESS_DBPORT:-3306}"
|
||||
|
||||
local attempt_num=1
|
||||
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
|
||||
while ! mariadb-admin --host="${host}" --port="${port}" --skip-ssl ping --silent >/dev/null 2>&1; do
|
||||
delay_next_attempt
|
||||
done
|
||||
echo "Connected to MariaDB"
|
||||
echo "${LOG_PREFIX} Connected to MariaDB"
|
||||
}
|
||||
|
||||
if [[ "${PAPERLESS_DBENGINE}" == "mariadb" ]]; then
|
||||
echo "${log_prefix} Waiting for MariaDB to report ready"
|
||||
if [[ "${PAPERLESS_DBENGINE:-}" == "mariadb" ]]; then
|
||||
wait_for_mariadb
|
||||
elif [[ -n "${PAPERLESS_DBHOST}" ]]; then
|
||||
echo "${log_prefix} Waiting for postgresql to report ready"
|
||||
elif [[ -n "${PAPERLESS_DBHOST:-}" ]]; then
|
||||
wait_for_postgres
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
|
|
|||
167
docker/rootfs/usr/local/bin/deduplicate.py
Executable file
167
docker/rootfs/usr/local/bin/deduplicate.py
Executable file
|
|
@ -0,0 +1,167 @@
|
|||
#!/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()
|
||||
|
|
@ -1,5 +1,218 @@
|
|||
# Changelog
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -326,7 +326,7 @@ are released, dependency support is confirmed, etc.
|
|||
|
||||
!!! warning
|
||||
|
||||
Ensure your Redis instance [is secured](https://redis.io/docs/getting-started/#securing-redis).
|
||||
Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/).
|
||||
|
||||
7. Create the following directories if they are missing:
|
||||
|
||||
|
|
|
|||
|
|
@ -553,6 +553,7 @@ applied. You can use the following placeholders in the template with any trigger
|
|||
- `{{added_time}}`: added time in HH:MM format
|
||||
- `{{original_filename}}`: original 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
|
||||
|
||||
|
|
|
|||
|
|
@ -374,7 +374,7 @@ fi
|
|||
# of the provided folder
|
||||
if [[ -n $DATABASE_FOLDER ]] ; then
|
||||
if [[ "$DATABASE_BACKEND" == "postgres" ]] ; then
|
||||
sed -i "s#- pgdata:/var/lib/postgresql/data#- $DATABASE_FOLDER:/var/lib/postgresql/data#g" docker-compose.yml
|
||||
sed -i "s#- pgdata:/var/lib/postgresql#- $DATABASE_FOLDER:/var/lib/postgresql#g" docker-compose.yml
|
||||
sed -i "/^\s*pgdata:/d" docker-compose.yml
|
||||
elif [[ "$DATABASE_BACKEND" == "mariadb" ]]; then
|
||||
sed -i "s#- dbdata:/var/lib/mysql#- $DATABASE_FOLDER:/var/lib/mysql#g" docker-compose.yml
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "2.19.2"
|
||||
version = "2.20.1"
|
||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
|
@ -17,7 +17,7 @@ classifiers = [
|
|||
|
||||
dependencies = [
|
||||
"babel>=2.17",
|
||||
"bleach~=6.2.0",
|
||||
"bleach~=6.3.0",
|
||||
"celery[redis]~=5.5.1",
|
||||
"channels~=4.2",
|
||||
"channels-redis~=4.2",
|
||||
|
|
@ -26,8 +26,8 @@ dependencies = [
|
|||
# WARNING: django does not use semver.
|
||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||
"django~=5.2.5",
|
||||
"django-allauth[mfa,socialaccount]~=65.4.0",
|
||||
"django-auditlog~=3.2.1",
|
||||
"django-allauth[mfa,socialaccount]~=65.12.1",
|
||||
"django-auditlog~=3.3.0",
|
||||
"django-cachalot~=2.8.0",
|
||||
"django-celery-results~=2.6.0",
|
||||
"django-compression-middleware~=0.5.0",
|
||||
|
|
@ -41,7 +41,7 @@ dependencies = [
|
|||
"djangorestframework~=3.16",
|
||||
"djangorestframework-guardian~=0.4.0",
|
||||
"drf-spectacular~=0.28",
|
||||
"drf-spectacular-sidecar~=2025.9.1",
|
||||
"drf-spectacular-sidecar~=2025.10.1",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"filelock~=3.20.0",
|
||||
"flower~=2.0.1",
|
||||
|
|
@ -52,11 +52,11 @@ dependencies = [
|
|||
"jinja2~=3.1.5",
|
||||
"langdetect~=1.0.9",
|
||||
"nltk~=3.9.1",
|
||||
"ocrmypdf~=16.11.0",
|
||||
"ocrmypdf~=16.12.0",
|
||||
"pathvalidate~=3.3.1",
|
||||
"pdf2image~=1.17.0",
|
||||
"python-dateutil~=2.9.0",
|
||||
"python-dotenv~=1.1.0",
|
||||
"python-dotenv~=1.2.1",
|
||||
"python-gnupg~=0.5.4",
|
||||
"python-ipware~=3.0.0",
|
||||
"python-magic~=0.4.27",
|
||||
|
|
@ -77,10 +77,10 @@ optional-dependencies.mariadb = [
|
|||
"mysqlclient~=2.2.7",
|
||||
]
|
||||
optional-dependencies.postgres = [
|
||||
"psycopg[c,pool]==3.2.9",
|
||||
"psycopg[c,pool]==3.2.12",
|
||||
# Direct dependency for proper resolution of the pre-built wheels
|
||||
"psycopg-c==3.2.9",
|
||||
"psycopg-pool==3.2.6",
|
||||
"psycopg-c==3.2.12",
|
||||
"psycopg-pool==3.2.7",
|
||||
]
|
||||
optional-dependencies.webserver = [
|
||||
"granian[uvloop]~=2.5.1",
|
||||
|
|
@ -96,7 +96,7 @@ dev = [
|
|||
|
||||
docs = [
|
||||
"mkdocs-glightbox~=0.5.1",
|
||||
"mkdocs-material~=9.6.4",
|
||||
"mkdocs-material~=9.7.0",
|
||||
]
|
||||
|
||||
testing = [
|
||||
|
|
@ -115,7 +115,7 @@ testing = [
|
|||
]
|
||||
|
||||
lint = [
|
||||
"pre-commit~=4.3.0",
|
||||
"pre-commit~=4.4.0",
|
||||
"pre-commit-uv~=4.2.0",
|
||||
"ruff~=0.14.0",
|
||||
]
|
||||
|
|
@ -150,8 +150,8 @@ environments = [
|
|||
[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'" },
|
||||
{ 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'" },
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "2.19.2",
|
||||
"version": "2.20.0",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
|
|
@ -11,17 +11,17 @@
|
|||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^20.2.6",
|
||||
"@angular/common": "~20.3.2",
|
||||
"@angular/compiler": "~20.3.2",
|
||||
"@angular/core": "~20.3.2",
|
||||
"@angular/forms": "~20.3.2",
|
||||
"@angular/localize": "~20.3.2",
|
||||
"@angular/platform-browser": "~20.3.2",
|
||||
"@angular/platform-browser-dynamic": "~20.3.2",
|
||||
"@angular/router": "~20.3.2",
|
||||
"@angular/cdk": "^20.2.13",
|
||||
"@angular/common": "~20.3.14",
|
||||
"@angular/compiler": "~20.3.12",
|
||||
"@angular/core": "~20.3.12",
|
||||
"@angular/forms": "~20.3.12",
|
||||
"@angular/localize": "~20.3.12",
|
||||
"@angular/platform-browser": "~20.3.12",
|
||||
"@angular/platform-browser-dynamic": "~20.3.12",
|
||||
"@angular/router": "~20.3.12",
|
||||
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
||||
"@ng-select/ng-select": "^20.2.2",
|
||||
"@ng-select/ng-select": "^20.7.0",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
"ng2-pdf-viewer": "^10.4.0",
|
||||
"ngx-bootstrap-icons": "^1.9.3",
|
||||
"ngx-color": "^10.1.0",
|
||||
"ngx-cookie-service": "^20.1.0",
|
||||
"ngx-cookie-service": "^20.1.1",
|
||||
"ngx-device-detector": "^10.1.0",
|
||||
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
|
||||
"rxjs": "^7.8.2",
|
||||
|
|
@ -42,33 +42,33 @@
|
|||
"devDependencies": {
|
||||
"@angular-builders/custom-webpack": "^20.0.0",
|
||||
"@angular-builders/jest": "^20.0.0",
|
||||
"@angular-devkit/core": "^20.3.3",
|
||||
"@angular-devkit/schematics": "^20.3.3",
|
||||
"@angular-eslint/builder": "20.3.0",
|
||||
"@angular-eslint/eslint-plugin": "20.3.0",
|
||||
"@angular-eslint/eslint-plugin-template": "20.3.0",
|
||||
"@angular-eslint/schematics": "20.3.0",
|
||||
"@angular-eslint/template-parser": "20.3.0",
|
||||
"@angular/build": "^20.3.3",
|
||||
"@angular/cli": "~20.3.3",
|
||||
"@angular/compiler-cli": "~20.3.2",
|
||||
"@angular-devkit/core": "^20.3.10",
|
||||
"@angular-devkit/schematics": "^20.3.10",
|
||||
"@angular-eslint/builder": "20.6.0",
|
||||
"@angular-eslint/eslint-plugin": "20.6.0",
|
||||
"@angular-eslint/eslint-plugin-template": "20.6.0",
|
||||
"@angular-eslint/schematics": "20.6.0",
|
||||
"@angular-eslint/template-parser": "20.6.0",
|
||||
"@angular/build": "^20.3.10",
|
||||
"@angular/cli": "~20.3.10",
|
||||
"@angular/compiler-cli": "~20.3.12",
|
||||
"@codecov/webpack-plugin": "^1.9.1",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.6.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.45.0",
|
||||
"@typescript-eslint/parser": "^8.45.0",
|
||||
"@typescript-eslint/utils": "^8.45.0",
|
||||
"eslint": "^9.36.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.47.0",
|
||||
"@typescript-eslint/parser": "^8.47.0",
|
||||
"@typescript-eslint/utils": "^8.47.0",
|
||||
"eslint": "^9.39.1",
|
||||
"jest": "30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"jest-preset-angular": "^15.0.2",
|
||||
"jest-preset-angular": "^15.0.3",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"ts-node": "~10.9.1",
|
||||
"typescript": "^5.8.3",
|
||||
"webpack": "^5.102.0"
|
||||
"webpack": "^5.102.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"pnpm": {
|
||||
|
|
|
|||
2413
src-ui/pnpm-lock.yaml
generated
2413
src-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -145,6 +145,10 @@ HTMLCanvasElement.prototype.getContext = <
|
|||
typeof HTMLCanvasElement.prototype.getContext
|
||||
>jest.fn()
|
||||
|
||||
if (!HTMLElement.prototype.scrollTo) {
|
||||
HTMLElement.prototype.scrollTo = jest.fn()
|
||||
}
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: jest.fn(() =>
|
||||
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char: string) => {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,23 @@
|
|||
i18n-title
|
||||
info="Review the log files for the application and for email checking."
|
||||
i18n-info>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||
<div class="input-group input-group-sm align-items-center">
|
||||
<div class="input-group input-group-sm me-3">
|
||||
<span class="input-group-text text-muted" i18n>Show</span>
|
||||
<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>
|
||||
</pngx-page-header>
|
||||
|
||||
|
|
@ -27,16 +41,21 @@
|
|||
}
|
||||
</ul>
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="mt-2"></div>
|
||||
|
||||
<div class="bg-dark p-3 text-light font-monospace log-container" #logContainer>
|
||||
@if (loading && logFiles.length) {
|
||||
<div #logContainer class="bg-dark text-light font-monospace log-container p-3" (scroll)="onScroll()">
|
||||
@if (loading && !logFiles.length) {
|
||||
<div>
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<ng-container i18n>Loading...</ng-container>
|
||||
</div>
|
||||
}
|
||||
@for (log of logs; track $index) {
|
||||
<p class="m-0 p-0 log-entry-{{getLogLevel(log)}}">{{log}}</p>
|
||||
} @else {
|
||||
<p *ngFor="let log of logs" class="m-0 p-0" [ngClass]="'log-entry-' + log.level">{{log.message}}</p>
|
||||
}
|
||||
</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,11 +16,21 @@
|
|||
}
|
||||
|
||||
.log-container {
|
||||
overflow-y: scroll;
|
||||
height: calc(100vh - 200px);
|
||||
top: 70px;
|
||||
height: calc(100vh - 190px);
|
||||
overflow-y: auto;
|
||||
|
||||
p {
|
||||
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,3 +1,8 @@
|
|||
import {
|
||||
CdkVirtualScrollViewport,
|
||||
ScrollingModule,
|
||||
} from '@angular/cdk/scrolling'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
|
@ -38,6 +43,9 @@ describe('LogsComponent', () => {
|
|||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
LogsComponent,
|
||||
PageHeaderComponent,
|
||||
CommonModule,
|
||||
CdkVirtualScrollViewport,
|
||||
ScrollingModule,
|
||||
],
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
|
|
@ -54,13 +62,12 @@ describe('LogsComponent', () => {
|
|||
fixture = TestBed.createComponent(LogsComponent)
|
||||
component = fixture.componentInstance
|
||||
reloadSpy = jest.spyOn(component, 'reloadLogs')
|
||||
window.HTMLElement.prototype.scroll = function () {} // mock scroll
|
||||
jest.useFakeTimers()
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should display logs with first log initially', () => {
|
||||
expect(logSpy).toHaveBeenCalledWith('paperless')
|
||||
expect(logSpy).toHaveBeenCalledWith('paperless', 5000)
|
||||
fixture.detectChanges()
|
||||
expect(fixture.debugElement.nativeElement.textContent).toContain(
|
||||
paperless_logs[0]
|
||||
|
|
@ -71,7 +78,7 @@ describe('LogsComponent', () => {
|
|||
fixture.debugElement
|
||||
.queryAll(By.directive(NgbNavLink))[1]
|
||||
.nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||
expect(logSpy).toHaveBeenCalledWith('mail')
|
||||
expect(logSpy).toHaveBeenCalledWith('mail', 5000)
|
||||
})
|
||||
|
||||
it('should handle error with no logs', () => {
|
||||
|
|
@ -83,6 +90,10 @@ describe('LogsComponent', () => {
|
|||
})
|
||||
|
||||
it('should auto refresh, allow toggle', () => {
|
||||
jest
|
||||
.spyOn(CdkVirtualScrollViewport.prototype, 'scrollToIndex')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
jest.advanceTimersByTime(6000)
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
||||
|
||||
|
|
@ -90,4 +101,20 @@ describe('LogsComponent', () => {
|
|||
jest.advanceTimersByTime(6000)
|
||||
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,3 +1,4 @@
|
|||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
|
|
@ -9,7 +10,7 @@ import {
|
|||
} from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { filter, takeUntil, timer } from 'rxjs'
|
||||
import { Subject, debounceTime, filter, takeUntil, timer } from 'rxjs'
|
||||
import { LogService } from 'src/app/services/rest/log.service'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
|
|
@ -21,6 +22,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||
imports: [
|
||||
PageHeaderComponent,
|
||||
NgbNavModule,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
|
|
@ -32,7 +34,7 @@ export class LogsComponent
|
|||
private logService = inject(LogService)
|
||||
private changedetectorRef = inject(ChangeDetectorRef)
|
||||
|
||||
public logs: string[] = []
|
||||
public logs: Array<{ message: string; level: number }> = []
|
||||
|
||||
public logFiles: string[] = []
|
||||
|
||||
|
|
@ -40,9 +42,19 @@ export class LogsComponent
|
|||
|
||||
public autoRefreshEnabled: boolean = true
|
||||
|
||||
@ViewChild('logContainer') logContainer: ElementRef
|
||||
public limit: number = 5000
|
||||
|
||||
public showJumpToBottom = false
|
||||
|
||||
private readonly limitChange$ = new Subject<number>()
|
||||
|
||||
@ViewChild('logContainer') logContainer: ElementRef<HTMLElement>
|
||||
|
||||
ngOnInit(): void {
|
||||
this.limitChange$
|
||||
.pipe(debounceTime(300), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => this.reloadLogs())
|
||||
|
||||
this.logService
|
||||
.list()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
|
|
@ -68,16 +80,37 @@ export class LogsComponent
|
|||
super.ngOnDestroy()
|
||||
}
|
||||
|
||||
onLimitChange(limit: number): void {
|
||||
this.limitChange$.next(limit)
|
||||
}
|
||||
|
||||
reloadLogs() {
|
||||
this.loading = true
|
||||
const shouldStickToBottom = this.isNearBottom()
|
||||
this.logService
|
||||
.get(this.activeLog)
|
||||
.get(this.activeLog, this.limit)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.logs = result
|
||||
this.loading = false
|
||||
this.scrollToBottom()
|
||||
const parsed = this.parseLogsWithLevel(result)
|
||||
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: () => {
|
||||
this.logs = []
|
||||
|
|
@ -100,12 +133,35 @@ export class LogsComponent
|
|||
}
|
||||
}
|
||||
|
||||
private parseLogsWithLevel(
|
||||
logs: string[]
|
||||
): Array<{ message: string; level: number }> {
|
||||
return logs.map((log) => ({
|
||||
message: log,
|
||||
level: this.getLogLevel(log),
|
||||
}))
|
||||
}
|
||||
|
||||
scrollToBottom(): void {
|
||||
const viewport = this.logContainer?.nativeElement
|
||||
if (!viewport) {
|
||||
return
|
||||
}
|
||||
this.changedetectorRef.detectChanges()
|
||||
this.logContainer?.nativeElement.scroll({
|
||||
top: this.logContainer.nativeElement.scrollHeight,
|
||||
left: 0,
|
||||
behavior: 'auto',
|
||||
})
|
||||
viewport.scrollTop = viewport.scrollHeight
|
||||
this.showJumpToBottom = false
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
@for (user of users; track user) {
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<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" [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">{{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">
|
||||
|
|
|
|||
|
|
@ -68,13 +68,15 @@
|
|||
<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"
|
||||
[ngbCollapse]="isMenuCollapsed">
|
||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
|
||||
@if (slimSidebarEnabled) {
|
||||
<i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
|
||||
} @else {
|
||||
<i-bs width="0.9em" height="0.9em" name="chevron-double-left"></i-bs>
|
||||
}
|
||||
</button>
|
||||
@if (canSaveSettings) {
|
||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
|
||||
@if (slimSidebarEnabled) {
|
||||
<i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
|
||||
} @else {
|
||||
<i-bs width="0.9em" height="0.9em" name="chevron-double-left"></i-bs>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item app-link">
|
||||
|
|
|
|||
|
|
@ -152,6 +152,19 @@ export class AppFrameComponent
|
|||
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 {
|
||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -411,6 +411,9 @@ export class GlobalSearchComponent implements OnInit {
|
|||
const ruleType = this.useAdvancedForFullSearch
|
||||
? FILTER_FULLTEXT_QUERY
|
||||
: FILTER_TITLE_CONTENT
|
||||
this.documentService.searchQuery = this.useAdvancedForFullSearch
|
||||
? this.query
|
||||
: ''
|
||||
this.documentListViewService.quickFilter([
|
||||
{ rule_type: ruleType, value: this.query },
|
||||
])
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@
|
|||
bindValue="id"
|
||||
[(ngModel)]="atom.value"
|
||||
[disabled]="disabled"
|
||||
[virtualScroll]="getSelectOptionsForField(atom.field)?.length > 100"
|
||||
(mousedown)="$event.stopImmediatePropagation()"
|
||||
></ng-select>
|
||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) {
|
||||
|
|
|
|||
|
|
@ -354,5 +354,13 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||
model.removeElement(atom)
|
||||
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'
|
||||
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { first, Subject, takeUntil } from 'rxjs'
|
||||
import { first, Subject, Subscription, takeUntil } from 'rxjs'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import {
|
||||
CUSTOM_FIELD_QUERY_MAX_ATOMS,
|
||||
|
|
@ -41,10 +41,27 @@ import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.comp
|
|||
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
||||
|
||||
export class CustomFieldQueriesModel {
|
||||
public queries: CustomFieldQueryElement[] = []
|
||||
private _queries: CustomFieldQueryElement[] = []
|
||||
private rootSubscriptions: Subscription[] = []
|
||||
|
||||
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) {
|
||||
this.queries = []
|
||||
if (fireEvent) {
|
||||
|
|
@ -107,14 +124,14 @@ export class CustomFieldQueriesModel {
|
|||
public addExpression(
|
||||
expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
|
||||
) {
|
||||
if (this.queries.length > 0) {
|
||||
;(
|
||||
(this.queries[0] as CustomFieldQueryExpression)
|
||||
.value as CustomFieldQueryElement[]
|
||||
).push(expression)
|
||||
} else {
|
||||
this.queries.push(expression)
|
||||
if (this.queries.length === 0) {
|
||||
this.queries = [expression]
|
||||
return
|
||||
}
|
||||
;(
|
||||
(this.queries[0] as CustomFieldQueryExpression)
|
||||
.value as CustomFieldQueryElement[]
|
||||
).push(expression)
|
||||
expression.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
|
|
@ -166,6 +183,13 @@ export class CustomFieldQueriesModel {
|
|||
this.changed.next(this)
|
||||
}
|
||||
}
|
||||
|
||||
private teardownRootSubscriptions() {
|
||||
for (const subscription of this.rootSubscriptions) {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
this.rootSubscriptions = []
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
i18n-placeholder
|
||||
(change)="onSetCreatedRelativeDate($event)">
|
||||
<ng-template ng-option-tmp let-item="item">
|
||||
<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-container [ngTemplateOutlet]="relativeDateOptionTemplate" [ngTemplateOutletContext]="{ $implicit: item }"></ng-container>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
</div>
|
||||
|
|
@ -102,7 +102,7 @@
|
|||
i18n-placeholder
|
||||
(change)="onSetAddedRelativeDate($event)">
|
||||
<ng-template ng-option-tmp let-item="item">
|
||||
<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-container [ngTemplateOutlet]="relativeDateOptionTemplate" [ngTemplateOutletContext]="{ $implicit: item }"></ng-container>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
</div>
|
||||
|
|
@ -158,3 +158,16 @@
|
|||
</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 } from '@angular/common'
|
||||
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
|
|
@ -42,6 +42,10 @@ export enum RelativeDate {
|
|||
THIS_MONTH = 6,
|
||||
TODAY = 7,
|
||||
YESTERDAY = 8,
|
||||
PREVIOUS_WEEK = 9,
|
||||
PREVIOUS_MONTH = 10,
|
||||
PREVIOUS_QUARTER = 11,
|
||||
PREVIOUS_YEAR = 12,
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
|
@ -59,6 +63,7 @@ export enum RelativeDate {
|
|||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
],
|
||||
})
|
||||
export class DatesDropdownComponent implements OnInit, OnDestroy {
|
||||
|
|
@ -111,6 +116,46 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
|
|||
name: $localize`Yesterday`,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { GroupService } from 'src/app/services/rest/group.service'
|
|||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.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 { SelectComponent } from '../../input/select/select.component'
|
||||
import { TextComponent } from '../../input/text/text.component'
|
||||
|
|
@ -28,6 +29,7 @@ import { PermissionsSelectComponent } from '../../permissions-select/permissions
|
|||
SelectComponent,
|
||||
TextComponent,
|
||||
PasswordComponent,
|
||||
ConfirmButtonComponent,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -77,9 +77,11 @@
|
|||
</div>
|
||||
<div ngbAccordion [closeOthers]="true" cdkDropList (cdkDropListDropped)="onActionDrop($event)">
|
||||
@for (action of object?.actions; track action; let i = $index){
|
||||
<div ngbAccordionItem cdkDrag [formGroup]="actionFields.controls[i]">
|
||||
<div ngbAccordionHeader>
|
||||
<button ngbAccordionButton>{{i + 1}}. {{getActionTypeOptionName(actionFields.controls[i].value.type)}}
|
||||
<div ngbAccordionItem [formGroup]="actionFields.controls[i]">
|
||||
<div ngbAccordionHeader cdkDrag>
|
||||
<button ngbAccordionButton>
|
||||
<i-bs name="grip-vertical" class="ms-n3 pe-1"></i-bs>
|
||||
{{i + 1}}. {{getActionTypeOptionName(actionFields.controls[i].value.type)}}
|
||||
@if(action.id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{action.id}}</span>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,3 +11,7 @@
|
|||
:host ::ng-deep .filters .paperless-input-select.mb-3 {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ms-n3 {
|
||||
margin-left: -1rem !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -564,6 +564,208 @@ 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(() => {
|
||||
component.selectionModel.items = items
|
||||
component.icon = 'tag-fill'
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@ export interface ChangedItems {
|
|||
itemsToRemove: MatchingModel[]
|
||||
}
|
||||
|
||||
type BranchSummary = {
|
||||
items: MatchingModel[]
|
||||
firstIndex: number
|
||||
special: boolean
|
||||
selected: boolean
|
||||
hasDocs: boolean
|
||||
}
|
||||
|
||||
export enum LogicalOperator {
|
||||
And = 'and',
|
||||
Or = 'or',
|
||||
|
|
@ -53,8 +61,13 @@ export class FilterableDropdownSelectionModel {
|
|||
temporaryIntersection: Intersection = this._intersection
|
||||
|
||||
private _documentCounts: SelectionDataItem[] = []
|
||||
public documentCountSortingEnabled = false
|
||||
|
||||
public set documentCounts(counts: SelectionDataItem[]) {
|
||||
this._documentCounts = counts
|
||||
if (this.documentCountSortingEnabled) {
|
||||
this.sortItems()
|
||||
}
|
||||
}
|
||||
|
||||
private _items: MatchingModel[] = []
|
||||
|
|
@ -114,6 +127,13 @@ export class FilterableDropdownSelectionModel {
|
|||
b.id == NEGATIVE_NULL_FILTER_VALUE)
|
||||
) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Preserve hierarchical order when provided (e.g., Tags)
|
||||
const ao = (a as any)['orderIndex']
|
||||
const bo = (b as any)['orderIndex']
|
||||
if (ao !== undefined && bo !== undefined) {
|
||||
return ao - bo
|
||||
} else if (
|
||||
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
|
||||
this.getNonTemporary(b.id) != ToggleableItemState.NotSelected
|
||||
|
|
@ -136,17 +156,14 @@ export class FilterableDropdownSelectionModel {
|
|||
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
|
||||
) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Preserve hierarchical order when provided (e.g., Tags)
|
||||
const ao = (a as any)['orderIndex']
|
||||
const bo = (b as any)['orderIndex']
|
||||
if (ao !== undefined && bo !== undefined) {
|
||||
return ao - bo
|
||||
} else {
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
})
|
||||
|
||||
if (this._documentCounts.length) {
|
||||
this.promoteBranchesWithDocumentCounts()
|
||||
}
|
||||
}
|
||||
|
||||
private selectionStates = new Map<number, ToggleableItemState>()
|
||||
|
|
@ -380,6 +397,180 @@ export class FilterableDropdownSelectionModel {
|
|||
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>) {
|
||||
this.temporarySelectionStates = map
|
||||
this.apply()
|
||||
|
|
@ -465,8 +656,9 @@ export class FilterableDropdownComponent
|
|||
this.selectionModel.changed.complete()
|
||||
model.items = this.selectionModel.items
|
||||
model.manyToOne = this.selectionModel.manyToOne
|
||||
model.singleSelect = this.editing && !this.selectionModel.manyToOne
|
||||
model.singleSelect = this._editing && !model.manyToOne
|
||||
}
|
||||
model.documentCountSortingEnabled = this._editing
|
||||
model.changed.subscribe((updatedModel) => {
|
||||
this.selectionModelChange.next(updatedModel)
|
||||
})
|
||||
|
|
@ -496,8 +688,21 @@ export class FilterableDropdownComponent
|
|||
@Input()
|
||||
allowSelectNone: boolean = false
|
||||
|
||||
private _editing = false
|
||||
|
||||
@Input()
|
||||
editing = false
|
||||
set editing(value: boolean) {
|
||||
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()
|
||||
applyOnClose = false
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
[multiple]="multiple"
|
||||
[bindLabel]="bindLabel"
|
||||
bindValue="id"
|
||||
[virtualScroll]="items?.length > 100"
|
||||
(change)="onChange(value)"
|
||||
(search)="onSearch($event)"
|
||||
(focus)="clearLastSearchTerm()"
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ describe('TagsComponent', () => {
|
|||
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
settingsService.currentUser = { id: 1 }
|
||||
fixture = TestBed.createComponent(TagsComponent)
|
||||
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
||||
component = fixture.componentInstance
|
||||
|
|
@ -138,7 +139,7 @@ describe('TagsComponent', () => {
|
|||
settingsService.currentUser = { id: 1 }
|
||||
let activeInstances: NgbModalRef[]
|
||||
modalService.activeInstances.subscribe((v) => (activeInstances = v))
|
||||
component.select.searchTerm = 'foobar'
|
||||
component.select.filter('foobar')
|
||||
component.createTag()
|
||||
expect(modalService.hasOpenModals()).toBeTruthy()
|
||||
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 }
|
||||
else if (this.select.searchTerm)
|
||||
modal.componentInstance.object = { name: this.select.searchTerm }
|
||||
this.select.searchTerm = null
|
||||
this.select.filter(null)
|
||||
this.select.detectChanges()
|
||||
return firstValueFrom(
|
||||
(modal.componentInstance as TagEditDialogComponent).succeeded.pipe(
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@ export class ProfileEditDialogComponent
|
|||
this.newPassword && this.currentPassword !== this.newPassword
|
||||
const profile = Object.assign({}, this.form.value)
|
||||
delete profile.totp_code
|
||||
this.error = null
|
||||
this.networkActive = true
|
||||
this.profileService
|
||||
.update(profile)
|
||||
|
|
@ -204,6 +205,7 @@ export class ProfileEditDialogComponent
|
|||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError($localize`Error saving profile`, error)
|
||||
this.error = error?.error
|
||||
this.networkActive = false
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1489,6 +1489,8 @@ describe('DocumentDetailComponent', () => {
|
|||
mockContentWindow.onafterprint(new Event('afterprint'))
|
||||
}
|
||||
|
||||
tick(500)
|
||||
|
||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
|
||||
|
|
@ -1512,65 +1514,97 @@ describe('DocumentDetailComponent', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('should show error toast if printing throws inside iframe', fakeAsync(() => {
|
||||
initNormally()
|
||||
const iframePrintErrorCases: Array<{
|
||||
description: string
|
||||
thrownError: Error
|
||||
expectToast: boolean
|
||||
}> = [
|
||||
{
|
||||
description: 'should show error toast if printing throws inside iframe',
|
||||
thrownError: new Error('focus failed'),
|
||||
expectToast: true,
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should suppress toast if cross-origin afterprint error occurs',
|
||||
thrownError: new DOMException(
|
||||
'Accessing onafterprint triggered a cross-origin violation',
|
||||
'SecurityError'
|
||||
),
|
||||
expectToast: false,
|
||||
},
|
||||
]
|
||||
|
||||
const appendChildSpy = jest
|
||||
.spyOn(document.body, 'appendChild')
|
||||
.mockImplementation((node: Node) => node)
|
||||
const removeChildSpy = jest
|
||||
.spyOn(document.body, 'removeChild')
|
||||
.mockImplementation((node: Node) => node)
|
||||
const createObjectURLSpy = jest
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue('blob:mock-url')
|
||||
const revokeObjectURLSpy = jest
|
||||
.spyOn(URL, 'revokeObjectURL')
|
||||
.mockImplementation(() => {})
|
||||
iframePrintErrorCases.forEach(({ description, thrownError, expectToast }) => {
|
||||
it(
|
||||
description,
|
||||
fakeAsync(() => {
|
||||
initNormally()
|
||||
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
const appendChildSpy = jest
|
||||
.spyOn(document.body, 'appendChild')
|
||||
.mockImplementation((node: Node) => node)
|
||||
const removeChildSpy = jest
|
||||
.spyOn(document.body, 'removeChild')
|
||||
.mockImplementation((node: Node) => node)
|
||||
const createObjectURLSpy = jest
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue('blob:mock-url')
|
||||
const revokeObjectURLSpy = jest
|
||||
.spyOn(URL, 'revokeObjectURL')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const mockContentWindow = {
|
||||
focus: jest.fn().mockImplementation(() => {
|
||||
throw new Error('focus failed')
|
||||
}),
|
||||
print: jest.fn(),
|
||||
onafterprint: null,
|
||||
}
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
const mockIframe: any = {
|
||||
style: {},
|
||||
src: '',
|
||||
onload: null,
|
||||
contentWindow: mockContentWindow,
|
||||
}
|
||||
const mockContentWindow = {
|
||||
focus: jest.fn().mockImplementation(() => {
|
||||
throw thrownError
|
||||
}),
|
||||
print: jest.fn(),
|
||||
onafterprint: null,
|
||||
}
|
||||
|
||||
const createElementSpy = jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockReturnValue(mockIframe as any)
|
||||
const mockIframe: any = {
|
||||
style: {},
|
||||
src: '',
|
||||
onload: null,
|
||||
contentWindow: mockContentWindow,
|
||||
}
|
||||
|
||||
const blob = new Blob(['test'], { type: 'application/pdf' })
|
||||
component.printDocument()
|
||||
const createElementSpy = jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockReturnValue(mockIframe as any)
|
||||
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/${doc.id}/download/`
|
||||
const blob = new Blob(['test'], { type: 'application/pdf' })
|
||||
component.printDocument()
|
||||
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/${doc.id}/download/`
|
||||
)
|
||||
req.flush(blob)
|
||||
|
||||
tick()
|
||||
|
||||
if (mockIframe.onload) {
|
||||
mockIframe.onload(new Event('load'))
|
||||
}
|
||||
|
||||
tick(200)
|
||||
|
||||
if (expectToast) {
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
} else {
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
}
|
||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
|
||||
createElementSpy.mockRestore()
|
||||
appendChildSpy.mockRestore()
|
||||
removeChildSpy.mockRestore()
|
||||
createObjectURLSpy.mockRestore()
|
||||
revokeObjectURLSpy.mockRestore()
|
||||
})
|
||||
)
|
||||
req.flush(blob)
|
||||
|
||||
tick()
|
||||
|
||||
if (mockIframe.onload) {
|
||||
mockIframe.onload(new Event('load'))
|
||||
}
|
||||
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
|
||||
createElementSpy.mockRestore()
|
||||
appendChildSpy.mockRestore()
|
||||
removeChildSpy.mockRestore()
|
||||
createObjectURLSpy.mockRestore()
|
||||
revokeObjectURLSpy.mockRestore()
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
|
|||
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { DeviceDetectorService } from 'ngx-device-detector'
|
||||
import { BehaviorSubject, Observable, of, Subject } from 'rxjs'
|
||||
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
|
||||
import {
|
||||
catchError,
|
||||
debounceTime,
|
||||
|
|
@ -1452,9 +1452,18 @@ export class DocumentDetailComponent
|
|||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
this.toastService.showError($localize`Print failed.`, err)
|
||||
document.body.removeChild(iframe)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
// FF throws cross-origin error on onafterprint
|
||||
const isCrossOriginAfterPrintError =
|
||||
err instanceof DOMException &&
|
||||
err.message.includes('onafterprint')
|
||||
if (!isCrossOriginAfterPrintError) {
|
||||
this.toastService.showError($localize`Print failed.`, err)
|
||||
}
|
||||
timer(100).subscribe(() => {
|
||||
// delay to avoid FF print failure
|
||||
document.body.removeChild(iframe)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
</div>
|
||||
<div class="d-none d-sm-flex flex-fill me-3">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text border-0">Select:</span>
|
||||
<span class="input-group-text border-0" i18n>Select:</span>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm flex-nowrap">
|
||||
@if (list.selected.size > 0) {
|
||||
|
|
|
|||
|
|
@ -173,6 +173,22 @@ const RELATIVE_DATE_QUERYSTRINGS = [
|
|||
relativeDate: RelativeDate.YESTERDAY,
|
||||
dateQuery: 'yesterday',
|
||||
},
|
||||
{
|
||||
relativeDate: RelativeDate.PREVIOUS_WEEK,
|
||||
dateQuery: 'previous week',
|
||||
},
|
||||
{
|
||||
relativeDate: RelativeDate.PREVIOUS_MONTH,
|
||||
dateQuery: 'previous month',
|
||||
},
|
||||
{
|
||||
relativeDate: RelativeDate.PREVIOUS_QUARTER,
|
||||
dateQuery: 'previous quarter',
|
||||
},
|
||||
{
|
||||
relativeDate: RelativeDate.PREVIOUS_YEAR,
|
||||
dateQuery: 'previous year',
|
||||
},
|
||||
]
|
||||
|
||||
const DEFAULT_TEXT_FILTER_TARGET_OPTIONS = [
|
||||
|
|
@ -400,6 +416,9 @@ export class FilterEditorComponent
|
|||
|
||||
@Input()
|
||||
set filterRules(value: FilterRule[]) {
|
||||
if (value === this._filterRules) {
|
||||
return
|
||||
}
|
||||
this._filterRules = value
|
||||
|
||||
this.documentTypeSelectionModel.clear(false)
|
||||
|
|
@ -747,7 +766,7 @@ export class FilterEditorComponent
|
|||
) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_TITLE_CONTENT,
|
||||
value: this._textFilter,
|
||||
value: this._textFilter.trim(),
|
||||
})
|
||||
}
|
||||
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) {
|
||||
|
|
@ -805,7 +824,7 @@ export class FilterEditorComponent
|
|||
) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: this._textFilter,
|
||||
value: this._textFilter.trim(),
|
||||
})
|
||||
}
|
||||
if (
|
||||
|
|
@ -1098,7 +1117,13 @@ export class FilterEditorComponent
|
|||
rulesModified: boolean = false
|
||||
|
||||
updateRules() {
|
||||
this.filterRulesChange.next(this.filterRules)
|
||||
const updatedRules = this.filterRules
|
||||
this._filterRules = updatedRules
|
||||
this.rulesModified = filterRulesDiffer(
|
||||
this._unmodifiedFilterRules,
|
||||
updatedRules
|
||||
)
|
||||
this.filterRulesChange.next(updatedRules)
|
||||
}
|
||||
|
||||
get textFilter() {
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@
|
|||
</td>
|
||||
<td>
|
||||
<ng-template #errorPopover>
|
||||
<pre class="small text-light">
|
||||
<pre class="small">
|
||||
{{ mail.error }}
|
||||
</pre>
|
||||
</ng-template>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
::ng-deep .popover {
|
||||
max-width: 350px;
|
||||
max-height: 600px;
|
||||
overflow: hidden;
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
|
|
|
|||
|
|
@ -361,4 +361,11 @@ describe('ManagementListComponent', () => {
|
|||
const original = component.getOriginalObject({ id: 4 } as Tag)
|
||||
expect(original).toEqual(childTag)
|
||||
})
|
||||
|
||||
it('getSelectableIDs should return flat ids when not overridden', () => {
|
||||
const ids = (
|
||||
ManagementListComponent.prototype as any
|
||||
).getSelectableIDs.call({}, [{ id: 1 }, { id: 5 }] as any)
|
||||
expect(ids).toEqual([1, 5])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -297,13 +297,19 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||
}
|
||||
|
||||
toggleAll(event: PointerEvent) {
|
||||
if ((event.target as HTMLInputElement).checked) {
|
||||
this.selectedObjects = new Set(this.data.map((o) => o.id))
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
this.togggleAll = checked
|
||||
if (checked) {
|
||||
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
|
||||
} else {
|
||||
this.clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
protected getSelectableIDs(objects: T[]): number[] {
|
||||
return objects.map((o) => o.id)
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.togggleAll = false
|
||||
this.selectedObjects.clear()
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ describe('TagListComponent', () => {
|
|||
let component: TagListComponent
|
||||
let fixture: ComponentFixture<TagListComponent>
|
||||
let tagService: TagService
|
||||
let listFilteredSpy: jest.SpyInstance
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
|
|
@ -39,7 +40,7 @@ describe('TagListComponent', () => {
|
|||
}).compileComponents()
|
||||
|
||||
tagService = TestBed.inject(TagService)
|
||||
jest.spyOn(tagService, 'listFiltered').mockReturnValue(
|
||||
listFilteredSpy = jest.spyOn(tagService, 'listFiltered').mockReturnValue(
|
||||
of({
|
||||
count: 3,
|
||||
all: [1, 2, 3],
|
||||
|
|
@ -72,9 +73,14 @@ describe('TagListComponent', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('should filter out child tags if name filter is empty, otherwise show all', () => {
|
||||
it('should omit matching children from top level when their parent is present', () => {
|
||||
const tags = [
|
||||
{ id: 1, name: 'Tag1', parent: null },
|
||||
{
|
||||
id: 1,
|
||||
name: 'Tag1',
|
||||
parent: null,
|
||||
children: [{ id: 2, name: 'Tag2', parent: 1 }],
|
||||
},
|
||||
{ id: 2, name: 'Tag2', parent: 1 },
|
||||
{ id: 3, name: 'Tag3', parent: null },
|
||||
]
|
||||
|
|
@ -85,6 +91,65 @@ describe('TagListComponent', () => {
|
|||
|
||||
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
|
||||
const filteredWithName = component.filterData(tags as any)
|
||||
expect(filteredWithName.length).toBe(3)
|
||||
expect(filteredWithName.length).toBe(2)
|
||||
expect(filteredWithName.find((t) => t.id === 2)).toBeUndefined()
|
||||
expect(
|
||||
filteredWithName
|
||||
.find((t) => t.id === 1)
|
||||
?.children?.some((c) => c.id === 2)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should request only parent tags when no name filter is applied', () => {
|
||||
expect(tagService.listFiltered).toHaveBeenCalledWith(
|
||||
1,
|
||||
null,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
{ is_root: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('should include child tags when a name filter is applied', () => {
|
||||
listFilteredSpy.mockClear()
|
||||
component['_nameFilter'] = 'Tag'
|
||||
component.reloadData()
|
||||
expect(tagService.listFiltered).toHaveBeenCalledWith(
|
||||
1,
|
||||
null,
|
||||
undefined,
|
||||
undefined,
|
||||
'Tag',
|
||||
true,
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('should include child tags when selecting all', () => {
|
||||
const parent = {
|
||||
id: 10,
|
||||
name: 'Parent',
|
||||
children: [
|
||||
{
|
||||
id: 11,
|
||||
name: 'Child',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
component.data = [parent as any]
|
||||
const selectEvent = { target: { checked: true } } as unknown as PointerEvent
|
||||
component.toggleAll(selectEvent)
|
||||
|
||||
expect(component.selectedObjects.has(10)).toBe(true)
|
||||
expect(component.selectedObjects.has(11)).toBe(true)
|
||||
|
||||
const deselectEvent = {
|
||||
target: { checked: false },
|
||||
} as unknown as PointerEvent
|
||||
component.toggleAll(deselectEvent)
|
||||
expect(component.selectedObjects.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -61,9 +61,33 @@ export class TagListComponent extends ManagementListComponent<Tag> {
|
|||
return $localize`Do you really want to delete the tag "${object.name}"?`
|
||||
}
|
||||
|
||||
override reloadData(extraParams: { [key: string]: any } = null) {
|
||||
const params = this.nameFilter?.length
|
||||
? extraParams
|
||||
: { ...extraParams, is_root: true }
|
||||
super.reloadData(params)
|
||||
}
|
||||
|
||||
filterData(data: Tag[]) {
|
||||
return this.nameFilter?.length
|
||||
? [...data]
|
||||
: data.filter((tag) => !tag.parent)
|
||||
if (!this.nameFilter?.length) {
|
||||
return data.filter((tag) => !tag.parent)
|
||||
}
|
||||
|
||||
// When filtering by name, exclude children if their parent is also present
|
||||
const availableIds = new Set(data.map((tag) => tag.id))
|
||||
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
|
||||
}
|
||||
|
||||
protected override getSelectableIDs(tags: Tag[]): number[] {
|
||||
const ids: number[] = []
|
||||
for (const tag of tags.filter(Boolean)) {
|
||||
if (tag.id != null) {
|
||||
ids.push(tag.id)
|
||||
}
|
||||
if (Array.isArray(tag.children) && tag.children.length) {
|
||||
ids.push(...this.getSelectableIDs(tag.children))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,4 +49,14 @@ describe('LogService', () => {
|
|||
)
|
||||
expect(req.request.method).toEqual('GET')
|
||||
})
|
||||
|
||||
it('should pass limit param on logs get when provided', () => {
|
||||
const id: string = 'mail'
|
||||
const limit: number = 100
|
||||
subscription = service.get(id, limit).subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/${id}/?limit=${limit}`
|
||||
)
|
||||
expect(req.request.method).toEqual('GET')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { HttpClient } from '@angular/common/http'
|
||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||
import { Injectable, inject } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { environment } from 'src/environments/environment'
|
||||
|
|
@ -13,7 +13,13 @@ export class LogService {
|
|||
return this.http.get<string[]>(`${environment.apiBaseUrl}logs/`)
|
||||
}
|
||||
|
||||
get(id: string): Observable<string[]> {
|
||||
return this.http.get<string[]>(`${environment.apiBaseUrl}logs/${id}/`)
|
||||
get(id: string, limit?: number): Observable<string[]> {
|
||||
let params = new HttpParams()
|
||||
if (limit !== undefined) {
|
||||
params = params.set('limit', limit.toString())
|
||||
}
|
||||
return this.http.get<string[]>(`${environment.apiBaseUrl}logs/${id}/`, {
|
||||
params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { fakeAsync, tick } from '@angular/core/testing'
|
||||
import {
|
||||
CustomFieldQueryElementType,
|
||||
CustomFieldQueryLogicalOperator,
|
||||
|
|
@ -111,13 +110,38 @@ describe('CustomFieldQueryAtom', () => {
|
|||
expect(atom.serialize()).toEqual([1, 'operator', 'value'])
|
||||
})
|
||||
|
||||
it('should emit changed on value change after debounce', fakeAsync(() => {
|
||||
it('should emit changed on value change immediately', () => {
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
const changeSpy = jest.spyOn(atom.changed, 'next')
|
||||
atom.value = 'new value'
|
||||
tick(1000)
|
||||
expect(changeSpy).toHaveBeenCalled()
|
||||
}))
|
||||
})
|
||||
|
||||
it('should ignore duplicate array emissions', () => {
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
atom.operator = CustomFieldQueryOperator.In
|
||||
const changeSpy = jest.fn()
|
||||
atom.changed.subscribe(changeSpy)
|
||||
|
||||
atom.value = [1, 2]
|
||||
expect(changeSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
changeSpy.mockClear()
|
||||
atom.value = [1, 2]
|
||||
expect(changeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should emit when array values differ while length matches', () => {
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
atom.operator = CustomFieldQueryOperator.In
|
||||
const changeSpy = jest.fn()
|
||||
atom.changed.subscribe(changeSpy)
|
||||
|
||||
atom.value = [1, 2]
|
||||
changeSpy.mockClear()
|
||||
atom.value = [1, 3]
|
||||
expect(changeSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CustomFieldQueryExpression', () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'
|
||||
import { Subject, distinctUntilChanged } from 'rxjs'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import {
|
||||
CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR,
|
||||
|
|
@ -110,7 +110,22 @@ export class CustomFieldQueryAtom extends CustomFieldQueryElement {
|
|||
|
||||
protected override connectValueModelChanged(): void {
|
||||
this.valueModelChanged
|
||||
.pipe(debounceTime(1000), distinctUntilChanged())
|
||||
.pipe(
|
||||
distinctUntilChanged((previous, current) => {
|
||||
if (Array.isArray(previous) && Array.isArray(current)) {
|
||||
if (previous.length !== current.length) {
|
||||
return false
|
||||
}
|
||||
for (let i = 0; i < previous.length; i++) {
|
||||
if (previous[i] !== current[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return previous === current
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export const environment = {
|
|||
apiVersion: '9', // match src/paperless/settings.py
|
||||
appTitle: 'Paperless-ngx',
|
||||
tag: 'prod',
|
||||
version: '2.19.2',
|
||||
version: '2.20.1',
|
||||
webSocketHost: window.location.host,
|
||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
11480
src-ui/src/locale/messages.mk_MK.xlf
Normal file
11480
src-ui/src/locale/messages.mk_MK.xlf
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue