mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-09 23:49:29 -06:00
Compare commits
53 Commits
feature-re
...
chore/swit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59a25f8601 | ||
|
|
5950f22e87 | ||
|
|
40ff58ad39 | ||
|
|
914362224c | ||
|
|
e0b45539a6 | ||
|
|
c4ed4e7f36 | ||
|
|
0b89e2847e | ||
|
|
21623e4455 | ||
|
|
9d7231d2dc | ||
|
|
4208d9255a | ||
|
|
9e9e55758f | ||
|
|
6a87c3f4dd | ||
|
|
d7c64760ed | ||
|
|
750c77736b | ||
|
|
30b1d3c6d7 | ||
|
|
d3ff856202 | ||
|
|
3bc4631a0f | ||
|
|
ab328e0212 | ||
|
|
5c3d02e6d4 | ||
|
|
1d89ec402b | ||
|
|
6192915be7 | ||
|
|
b9b90ec9f7 | ||
|
|
0dc58cf686 | ||
|
|
505ff31748 | ||
|
|
3c51b3f9cd | ||
|
|
dfbac35f9c | ||
|
|
8f917555b1 | ||
|
|
734b5b9a45 | ||
|
|
0f1cae03ec | ||
|
|
71663fdbe2 | ||
|
|
1188a89369 | ||
|
|
b8e3b6590e | ||
|
|
4a5116adf8 | ||
|
|
bbf2e63f10 | ||
|
|
33cbe2ad54 | ||
|
|
261e10ebeb | ||
|
|
585c28b460 | ||
|
|
e77ab3357c | ||
|
|
05ab091ea4 | ||
|
|
fb7abf7a6e | ||
|
|
6ad2fc0356 | ||
|
|
2ec8ec96c8 | ||
|
|
276dc13e3f | ||
|
|
d0c02e7a8d | ||
|
|
e45fca475a | ||
|
|
63c0e2f72b | ||
|
|
00ef0837d2 | ||
|
|
231561ad55 | ||
|
|
4fa38708a1 | ||
|
|
5c2366fb24 | ||
|
|
e5edfd0f7f | ||
|
|
470c824684 | ||
|
|
3b5ffbf9fa |
@@ -91,12 +91,12 @@ Additional tasks are available for common maintenance operations:
|
||||
|
||||
## Committing from the Host Machine
|
||||
|
||||
The DevContainer automatically installs pre-commit hooks during setup. However, these hooks are configured for use inside the container.
|
||||
The DevContainer automatically installs Git pre-commit hooks during setup. However, these hooks are configured for use inside the container.
|
||||
|
||||
If you want to commit changes from your host machine (outside the DevContainer), you need to set up pre-commit on your host. This installs it as a standalone tool.
|
||||
If you want to commit changes from your host machine (outside the DevContainer), you need to set up prek on your host. This installs it as a standalone tool.
|
||||
|
||||
```bash
|
||||
uv tool install pre-commit && pre-commit install
|
||||
uv tool install prek && prek install
|
||||
```
|
||||
|
||||
After this, you can commit either from inside the DevContainer or from your host machine.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"containerEnv": {
|
||||
"UV_CACHE_DIR": "/usr/src/paperless/paperless-ngx/.uv-cache"
|
||||
},
|
||||
"postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run pre-commit install'",
|
||||
"postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run prek install'",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
|
||||
@@ -116,9 +116,9 @@
|
||||
},
|
||||
{
|
||||
"label": "Maintenance: Build Documentation",
|
||||
"description": "Build the documentation with MkDocs",
|
||||
"description": "Build the documentation with Zensical",
|
||||
"type": "shell",
|
||||
"command": "uv run mkdocs build --config-file mkdocs.yml && uv run mkdocs serve",
|
||||
"command": "uv run zensical build && uv run zensical serve",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
|
||||
@@ -28,3 +28,4 @@
|
||||
./resources
|
||||
# Other stuff
|
||||
**/*.drawio.png
|
||||
.mypy_baseline
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -37,6 +37,6 @@ NOTE: PRs that do not address the following will not be merged, please do not sk
|
||||
- [ ] If applicable, I have included testing coverage for new code in this PR, for [backend](https://docs.paperless-ngx.com/development/#testing) and / or [front-end](https://docs.paperless-ngx.com/development/#testing-and-code-style) changes.
|
||||
- [ ] If applicable, I have tested my code for breaking changes & regressions on both mobile & desktop devices, using the latest version of major browsers.
|
||||
- [ ] If applicable, I have checked that all tests pass, see [documentation](https://docs.paperless-ngx.com/development/#back-end-development).
|
||||
- [ ] I have run all `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks).
|
||||
- [ ] I have run all Git `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.
|
||||
- [ ] In the description of the PR above I have disclosed the use of AI tools in the coding of this PR.
|
||||
|
||||
4
.github/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
@@ -46,8 +46,8 @@ updates:
|
||||
patterns:
|
||||
- "*pytest*"
|
||||
- "ruff"
|
||||
- "mkdocs-material"
|
||||
- "pre-commit*"
|
||||
- "zensical"
|
||||
- "prek*"
|
||||
# Django & DRF Ecosystem
|
||||
django-ecosystem:
|
||||
patterns:
|
||||
|
||||
51
.github/workflows/ci-backend.yml
vendored
51
.github/workflows/ci-backend.yml
vendored
@@ -23,7 +23,7 @@ concurrency:
|
||||
group: backend-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.9.x"
|
||||
DEFAULT_UV_VERSION: "0.10.x"
|
||||
NLTK_DATA: "/usr/share/nltk_data"
|
||||
jobs:
|
||||
test:
|
||||
@@ -99,3 +99,52 @@ jobs:
|
||||
run: |
|
||||
docker compose --file docker/compose/docker-compose.ci-test.yml logs
|
||||
docker compose --file docker/compose/docker-compose.ci-test.yml down
|
||||
typing:
|
||||
name: Check project typing
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
DEFAULT_PYTHON: "3.12"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.1
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v6.2.0
|
||||
with:
|
||||
python-version: "${{ env.DEFAULT_PYTHON }}"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7.2.1
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
python-version: ${{ steps.setup-python.outputs.python-version }}
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
uv sync \
|
||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||
--group testing \
|
||||
--group typing \
|
||||
--frozen
|
||||
- name: List installed Python dependencies
|
||||
run: |
|
||||
uv pip list
|
||||
- name: Check typing (pyrefly)
|
||||
run: |
|
||||
uv run pyrefly \
|
||||
check \
|
||||
src/
|
||||
- name: Cache Mypy
|
||||
uses: actions/cache@v5.0.3
|
||||
with:
|
||||
path: .mypy_cache
|
||||
# Keyed by OS, Python version, and dependency hashes
|
||||
key: ${{ runner.os }}-mypy-py${{ env.DEFAULT_PYTHON }}-${{ hashFiles('pyproject.toml', 'uv.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-mypy-py${{ env.DEFAULT_PYTHON }}-
|
||||
${{ runner.os }}-mypy-
|
||||
- name: Check typing (mypy)
|
||||
run: |
|
||||
uv run mypy \
|
||||
--show-error-codes \
|
||||
--warn-unused-configs \
|
||||
src/ | uv run mypy-baseline filter
|
||||
|
||||
8
.github/workflows/ci-docker.yml
vendored
8
.github/workflows/ci-docker.yml
vendored
@@ -106,7 +106,7 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.12.0
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3.6.0
|
||||
uses: docker/login-action@v3.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -180,20 +180,20 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.12.0
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3.6.0
|
||||
uses: docker/login-action@v3.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Login to Docker Hub
|
||||
if: needs.build-arch.outputs.push-external == 'true'
|
||||
uses: docker/login-action@v3.6.0
|
||||
uses: docker/login-action@v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to Quay.io
|
||||
if: needs.build-arch.outputs.push-external == 'true'
|
||||
uses: docker/login-action@v3.6.0
|
||||
uses: docker/login-action@v3.7.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
|
||||
56
.github/workflows/ci-docs.yml
vendored
56
.github/workflows/ci-docs.yml
vendored
@@ -6,17 +6,25 @@ on:
|
||||
- dev
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- 'zensical.toml'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- '.github/workflows/ci-docs.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- 'zensical.toml'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- '.github/workflows/ci-docs.yml'
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
group: docs-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.9.x"
|
||||
DEFAULT_PYTHON_VERSION: "3.11"
|
||||
@@ -25,6 +33,7 @@ jobs:
|
||||
name: Build Documentation
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/configure-pages@v5
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
@@ -47,42 +56,23 @@ jobs:
|
||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||
--dev \
|
||||
--frozen \
|
||||
mkdocs build --config-file ./mkdocs.yml
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
zensical build --clean
|
||||
- name: Upload GitHub Pages artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
name: documentation
|
||||
path: site/
|
||||
retention-days: 7
|
||||
path: site
|
||||
name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
deploy:
|
||||
name: Deploy Documentation
|
||||
needs: build
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v6
|
||||
- name: Deploy GitHub Pages
|
||||
uses: actions/deploy-pages@v4
|
||||
id: deployment
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
|
||||
- name: Deploy documentation
|
||||
run: |
|
||||
echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME"
|
||||
git config --global user.name "${{ github.actor }}"
|
||||
git config --global user.email "${{ github.actor }}@users.noreply.github.com"
|
||||
uv run \
|
||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||
--dev \
|
||||
--frozen \
|
||||
mkdocs gh-deploy --force --no-history
|
||||
artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
2
.github/workflows/ci-frontend.yml
vendored
2
.github/workflows/ci-frontend.yml
vendored
@@ -121,7 +121,7 @@ jobs:
|
||||
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
||||
needs: install-dependencies
|
||||
runs-on: ubuntu-24.04
|
||||
container: mcr.microsoft.com/playwright:v1.58.1-noble
|
||||
container: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||
|
||||
16
.github/workflows/ci-lint.yml
vendored
16
.github/workflows/ci-lint.yml
vendored
@@ -10,15 +10,15 @@ concurrency:
|
||||
group: lint-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
pre-commit:
|
||||
name: Pre-commit Checks
|
||||
runs-on: ubuntu-24.04
|
||||
lint:
|
||||
name: Linting via prek
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v6.0.2
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Run pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
python-version: "3.14"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@v1.1.1
|
||||
|
||||
6
.github/workflows/ci-release.yml
vendored
6
.github/workflows/ci-release.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Wait for Docker build
|
||||
uses: lewagon/wait-on-check-action@v1.4.1
|
||||
uses: lewagon/wait-on-check-action@v1.5.0
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
check-name: 'Build Docker Image'
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||
--dev \
|
||||
--frozen \
|
||||
mkdocs build --config-file ./mkdocs.yml
|
||||
zensical build --clean
|
||||
# ---- Prepare Release ----
|
||||
- name: Generate requirements file
|
||||
run: |
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
uv run \
|
||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||
--dev \
|
||||
pre-commit run --files changelog.md || true
|
||||
prek run --files changelog.md || true
|
||||
|
||||
git config --global user.name "github-actions"
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -54,7 +54,7 @@ junit.xml
|
||||
# Django stuff:
|
||||
*.log
|
||||
|
||||
# MkDocs documentation
|
||||
# Zensical documentation
|
||||
site/
|
||||
|
||||
# PyBuilder
|
||||
|
||||
2468
.mypy-baseline.txt
Normal file
2468
.mypy-baseline.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
# This file configures pre-commit hooks.
|
||||
# See https://pre-commit.com/ for general information
|
||||
# See https://pre-commit.com/hooks.html for a listing of possible hooks
|
||||
# We actually run via https://github.com/j178/prek which is compatible
|
||||
repos:
|
||||
# General hooks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
@@ -49,12 +50,12 @@ repos:
|
||||
- 'prettier-plugin-organize-imports@4.1.0'
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.14
|
||||
rev: v0.15.0
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: "v2.11.1"
|
||||
rev: "v2.12.1"
|
||||
hooks:
|
||||
- id: pyproject-fmt
|
||||
# Dockerfile hooks
|
||||
|
||||
17368
.pyrefly-baseline.json
Normal file
17368
.pyrefly-baseline.json
Normal file
File diff suppressed because one or more lines are too long
@@ -30,7 +30,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.26-python3.12-trixie-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.10.0-python3.12-trixie-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# correct networking for the tests
|
||||
services:
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.25
|
||||
image: docker.io/gotenberg/gotenberg:8.26
|
||||
hostname: gotenberg
|
||||
container_name: gotenberg
|
||||
network_mode: host
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
- "3143:3143" # IMAP
|
||||
restart: unless-stopped
|
||||
nginx:
|
||||
image: docker.io/nginx:1.29-alpine
|
||||
image: docker.io/nginx:1.29.5-alpine
|
||||
hostname: nginx
|
||||
container_name: nginx
|
||||
ports:
|
||||
|
||||
@@ -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.25
|
||||
image: docker.io/gotenberg/gotenberg:8.26
|
||||
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.25
|
||||
image: docker.io/gotenberg/gotenberg:8.26
|
||||
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.25
|
||||
image: docker.io/gotenberg/gotenberg:8.26
|
||||
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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# The REST API
|
||||
# REST API
|
||||
|
||||
Paperless-ngx now ships with a fully-documented REST API and a browsable
|
||||
web interface to explore it. The API browsable interface is available at
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
:root>* {
|
||||
--md-primary-fg-color: #17541f;
|
||||
--md-primary-fg-color--dark: #17541f;
|
||||
--md-primary-fg-color--light: #17541f;
|
||||
--md-accent-fg-color: #2b8a38;
|
||||
--paperless-green: #17541f;
|
||||
--paperless-green-accent: #2b8a38;
|
||||
--md-primary-fg-color: var(--paperless-green);
|
||||
--md-primary-fg-color--dark: var(--paperless-green);
|
||||
--md-primary-fg-color--light: var(--paperless-green-accent);
|
||||
--md-accent-fg-color: var(--paperless-green-accent);
|
||||
--md-typeset-a-color: #21652a;
|
||||
}
|
||||
|
||||
.md-header,
|
||||
.md-tabs {
|
||||
background-color: var(--paperless-green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.md-tabs__link {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.md-tabs__link:hover,
|
||||
.md-tabs__link--active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] {
|
||||
--md-hue: 222;
|
||||
--md-default-bg-color: hsla(var(--md-hue), 15%, 10%, 1);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -69,8 +87,8 @@ h4 code {
|
||||
}
|
||||
|
||||
/* Hide config vars from sidebar, toc and move the border on mobile case their hidden */
|
||||
.md-nav.md-nav--secondary .md-nav__item .md-nav__link[href*="PAPERLESS_"],
|
||||
.md-nav.md-nav--secondary .md-nav__item .md-nav__link[href*="USERMAP_"] {
|
||||
.md-nav.md-nav--secondary .md-nav__item:has(> .md-nav__link[href*="PAPERLESS_"]),
|
||||
.md-nav.md-nav--secondary .md-nav__item:has(> .md-nav__link[href*="USERMAP_"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -83,18 +101,3 @@ h4 code {
|
||||
border-top: .05rem solid var(--md-default-fg-color--lightest);
|
||||
}
|
||||
}
|
||||
|
||||
/* Show search shortcut key */
|
||||
[data-md-toggle="search"]:not(:checked) ~ .md-header .md-search__form::after {
|
||||
position: absolute;
|
||||
top: .3rem;
|
||||
right: .3rem;
|
||||
display: block;
|
||||
padding: .1rem .4rem;
|
||||
color: var(--md-default-fg-color--lighter);
|
||||
font-weight: bold;
|
||||
font-size: .8rem;
|
||||
border: .05rem solid var(--md-default-fg-color--lighter);
|
||||
border-radius: .1rem;
|
||||
content: "/";
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ first-time setup.
|
||||
5. Install pre-commit hooks:
|
||||
|
||||
```bash
|
||||
$ uv run pre-commit install
|
||||
$ uv run prek install
|
||||
```
|
||||
|
||||
6. Apply migrations and create a superuser (also can be done via the web UI) for your development instance:
|
||||
@@ -217,7 +217,7 @@ commit. See [above](#code-formatting-with-pre-commit-hooks) for installation ins
|
||||
command such as
|
||||
|
||||
```bash
|
||||
$ git ls-files -- '*.ts' | xargs pre-commit run prettier --files
|
||||
$ git ls-files -- '*.ts' | xargs prek run prettier --files
|
||||
```
|
||||
|
||||
Front end testing uses Jest and Playwright. Unit tests and e2e tests,
|
||||
@@ -338,13 +338,13 @@ LANGUAGES = [
|
||||
|
||||
## Building the documentation
|
||||
|
||||
The documentation is built using material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/).
|
||||
The documentation is built using Zensical, see their [documentation](https://zensical.org/docs/).
|
||||
If you want to build the documentation locally, this is how you do it:
|
||||
|
||||
1. Build the documentation
|
||||
|
||||
```bash
|
||||
$ uv run mkdocs build --config-file mkdocs.yml
|
||||
$ uv run zensical build
|
||||
```
|
||||
|
||||
_alternatively..._
|
||||
@@ -355,7 +355,7 @@ If you want to build the documentation locally, this is how you do it:
|
||||
something.
|
||||
|
||||
```bash
|
||||
$ uv run mkdocs serve
|
||||
$ uv run zensical serve
|
||||
```
|
||||
|
||||
## Building the Docker image
|
||||
@@ -481,3 +481,147 @@ To get started:
|
||||
|
||||
5. The project is ready for debugging, start either run the fullstack debug or individual debug
|
||||
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
|
||||
|
||||
## Developing Date Parser Plugins
|
||||
|
||||
Paperless-ngx uses a plugin system for date parsing, allowing you to extend or replace the default date parsing behavior. Plugins are discovered using [Python entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
|
||||
|
||||
### Creating a Date Parser Plugin
|
||||
|
||||
To create a custom date parser plugin, you need to:
|
||||
|
||||
1. Create a class that inherits from `DateParserPluginBase`
|
||||
2. Implement the required abstract method
|
||||
3. Register your plugin via an entry point
|
||||
|
||||
#### 1. Implementing the Parser Class
|
||||
|
||||
Your parser must extend `documents.plugins.date_parsing.DateParserPluginBase` and implement the `parse` method:
|
||||
|
||||
```python
|
||||
from collections.abc import Iterator
|
||||
import datetime
|
||||
|
||||
from documents.plugins.date_parsing import DateParserPluginBase
|
||||
|
||||
|
||||
class MyDateParserPlugin(DateParserPluginBase):
|
||||
"""
|
||||
Custom date parser implementation.
|
||||
"""
|
||||
|
||||
def parse(self, filename: str, content: str) -> Iterator[datetime.datetime]:
|
||||
"""
|
||||
Parse dates from the document's filename and content.
|
||||
|
||||
Args:
|
||||
filename: The original filename of the document
|
||||
content: The extracted text content of the document
|
||||
|
||||
Yields:
|
||||
datetime.datetime: Valid datetime objects found in the document
|
||||
"""
|
||||
# Your parsing logic here
|
||||
# Use self.config to access configuration settings
|
||||
|
||||
# Example: parse dates from filename first
|
||||
if self.config.filename_date_order:
|
||||
# Your filename parsing logic
|
||||
yield some_datetime
|
||||
|
||||
# Then parse dates from content
|
||||
# Your content parsing logic
|
||||
yield another_datetime
|
||||
```
|
||||
|
||||
#### 2. Configuration and Helper Methods
|
||||
|
||||
Your parser instance is initialized with a `DateParserConfig` object accessible via `self.config`. This provides:
|
||||
|
||||
- `languages: list[str]` - List of language codes for date parsing
|
||||
- `timezone_str: str` - Timezone string for date localization
|
||||
- `ignore_dates: set[datetime.date]` - Dates that should be filtered out
|
||||
- `reference_time: datetime.datetime` - Current time for filtering future dates
|
||||
- `filename_date_order: str | None` - Date order preference for filenames (e.g., "DMY", "MDY")
|
||||
- `content_date_order: str` - Date order preference for content
|
||||
|
||||
The base class provides two helper methods you can use:
|
||||
|
||||
```python
|
||||
def _parse_string(
|
||||
self,
|
||||
date_string: str,
|
||||
date_order: str,
|
||||
) -> datetime.datetime | None:
|
||||
"""
|
||||
Parse a single date string using dateparser with configured settings.
|
||||
"""
|
||||
|
||||
def _filter_date(
|
||||
self,
|
||||
date: datetime.datetime | None,
|
||||
) -> datetime.datetime | None:
|
||||
"""
|
||||
Validate a parsed datetime against configured rules.
|
||||
Filters out dates before 1900, future dates, and ignored dates.
|
||||
"""
|
||||
```
|
||||
|
||||
#### 3. Resource Management (Optional)
|
||||
|
||||
If your plugin needs to acquire or release resources (database connections, API clients, etc.), override the context manager methods. Paperless-ngx will always use plugins as context managers, ensuring resources can be released even in the event of errors.
|
||||
|
||||
#### 4. Registering Your Plugin
|
||||
|
||||
Register your plugin using a setuptools entry point in your package's `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[project.entry-points."paperless_ngx.date_parsers"]
|
||||
my_parser = "my_package.parsers:MyDateParserPlugin"
|
||||
```
|
||||
|
||||
The entry point name (e.g., `"my_parser"`) is used for sorting when multiple plugins are found. Paperless-ngx will use the first plugin alphabetically by name if multiple plugins are discovered.
|
||||
|
||||
### Plugin Discovery
|
||||
|
||||
Paperless-ngx automatically discovers and loads date parser plugins at runtime. The discovery process:
|
||||
|
||||
1. Queries the `paperless_ngx.date_parsers` entry point group
|
||||
2. Validates that each plugin is a subclass of `DateParserPluginBase`
|
||||
3. Sorts valid plugins alphabetically by entry point name
|
||||
4. Uses the first valid plugin, or falls back to the default `RegexDateParserPlugin` if none are found
|
||||
|
||||
If multiple plugins are installed, a warning is logged indicating which plugin was selected.
|
||||
|
||||
### Example: Simple Date Parser
|
||||
|
||||
Here's a minimal example that only looks for ISO 8601 dates:
|
||||
|
||||
```python
|
||||
import datetime
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
|
||||
from documents.plugins.date_parsing.base import DateParserPluginBase
|
||||
|
||||
|
||||
class ISODateParserPlugin(DateParserPluginBase):
|
||||
"""
|
||||
Parser that only matches ISO 8601 formatted dates (YYYY-MM-DD).
|
||||
"""
|
||||
|
||||
ISO_REGEX = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b")
|
||||
|
||||
def parse(self, filename: str, content: str) -> Iterator[datetime.datetime]:
|
||||
# Combine filename and content for searching
|
||||
text = f"{filename} {content}"
|
||||
|
||||
for match in self.ISO_REGEX.finditer(text):
|
||||
date_string = match.group(1)
|
||||
# Use helper method to parse with configured timezone
|
||||
date = self._parse_string(date_string, "YMD")
|
||||
# Use helper method to validate the date
|
||||
filtered_date = self._filter_date(date)
|
||||
if filtered_date is not None:
|
||||
yield filtered_date
|
||||
```
|
||||
|
||||
10
docs/faq.md
10
docs/faq.md
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: FAQs
|
||||
---
|
||||
|
||||
# Frequently Asked Questions
|
||||
|
||||
## _What's the general plan for Paperless-ngx?_
|
||||
@@ -63,8 +67,10 @@ elsewhere. Here are a couple notes about that.
|
||||
Paperless also supports various Office documents (.docx, .doc, odt,
|
||||
.ppt, .pptx, .odp, .xls, .xlsx, .ods).
|
||||
|
||||
Paperless-ngx determines the type of a file by inspecting its content.
|
||||
The file extensions do not matter.
|
||||
Paperless-ngx determines the type of a file by inspecting its content
|
||||
rather than its file extensions. However, files processed via the
|
||||
consumption directory will be rejected if they have a file extension that
|
||||
not supported by any of the available parsers.
|
||||
|
||||
## _Will paperless-ngx run on Raspberry Pi?_
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
title: Home
|
||||
---
|
||||
|
||||
<div class="grid-left" markdown>
|
||||
{.index-logo}
|
||||
{.index-logo}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
## Installation
|
||||
---
|
||||
title: Setup
|
||||
---
|
||||
|
||||
# Installation
|
||||
|
||||
You can go multiple routes to setup and run Paperless:
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# Usage Overview
|
||||
---
|
||||
title: Basic Usage
|
||||
---
|
||||
|
||||
# Usage
|
||||
|
||||
Paperless-ngx is an application that manages your personal documents. With
|
||||
the (optional) help of a document scanner (see [the scanners wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Scanner-&-Software-Recommendations)), Paperless-ngx transforms your unwieldy
|
||||
@@ -562,8 +566,8 @@ you may want to adjust these settings to prevent abuse.
|
||||
|
||||
#### Workflow placeholders
|
||||
|
||||
Titles can be assigned by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||
This allows for complex logic to be used to generate the title, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
|
||||
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||
This allows for complex logic to be used, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
|
||||
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
|
||||
The template is provided as a string.
|
||||
|
||||
@@ -586,7 +590,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
|
||||
- `{{doc_title}}`: current document title (cannot be used in title assignment)
|
||||
|
||||
The following placeholders are only available for "added" or "updated" triggers
|
||||
|
||||
|
||||
87
mkdocs.yml
87
mkdocs.yml
@@ -1,87 +0,0 @@
|
||||
site_name: Paperless-ngx
|
||||
theme:
|
||||
name: material
|
||||
logo: assets/logo.svg
|
||||
font:
|
||||
text: Roboto
|
||||
code: Roboto Mono
|
||||
palette:
|
||||
# Palette toggle for automatic mode
|
||||
- media: "(prefers-color-scheme)"
|
||||
toggle:
|
||||
icon: material/brightness-auto
|
||||
name: Switch to light mode
|
||||
# Palette toggle for light mode
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
toggle:
|
||||
icon: material/brightness-7
|
||||
name: Switch to dark mode
|
||||
# Palette toggle for dark mode
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
scheme: slate
|
||||
toggle:
|
||||
icon: material/brightness-4
|
||||
name: Switch to system preference
|
||||
features:
|
||||
- navigation.tabs
|
||||
- navigation.top
|
||||
- toc.integrate
|
||||
- content.code.annotate
|
||||
icon:
|
||||
repo: fontawesome/brands/github
|
||||
favicon: assets/favicon.png
|
||||
repo_url: https://github.com/paperless-ngx/paperless-ngx
|
||||
repo_name: paperless-ngx/paperless-ngx
|
||||
edit_uri: blob/main/docs/
|
||||
extra_css:
|
||||
- assets/extra.css
|
||||
markdown_extensions:
|
||||
- attr_list
|
||||
- md_in_html
|
||||
- def_list
|
||||
- admonition
|
||||
- tables
|
||||
- pymdownx.highlight:
|
||||
anchor_linenums: true
|
||||
- pymdownx.superfences
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.snippets
|
||||
- pymdownx.tilde
|
||||
- footnotes
|
||||
- pymdownx.superfences:
|
||||
custom_fences:
|
||||
- name: mermaid
|
||||
class: mermaid
|
||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||
strict: true
|
||||
nav:
|
||||
- index.md
|
||||
- setup.md
|
||||
- 'Basic Usage': usage.md
|
||||
- configuration.md
|
||||
- administration.md
|
||||
- advanced_usage.md
|
||||
- 'REST API': api.md
|
||||
- development.md
|
||||
- 'FAQs': faq.md
|
||||
- troubleshooting.md
|
||||
- 'Migration to v3': migration.md
|
||||
- changelog.md
|
||||
copyright: Copyright © 2016 - 2026 Daniel Quinn, Jonas Winkler, and the Paperless-ngx team
|
||||
extra:
|
||||
social:
|
||||
- icon: fontawesome/brands/github
|
||||
link: https://github.com/paperless-ngx/paperless-ngx
|
||||
- icon: fontawesome/brands/docker
|
||||
link: https://hub.docker.com/r/paperlessngx/paperless-ngx
|
||||
- icon: material/chat
|
||||
link: https://matrix.to/#/#paperless:matrix.org
|
||||
plugins:
|
||||
- search
|
||||
- glightbox:
|
||||
skip_classes:
|
||||
- no-lightbox
|
||||
@@ -27,9 +27,9 @@ dependencies = [
|
||||
# WARNING: django does not use semver.
|
||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||
"django~=5.2.10",
|
||||
"django-allauth[mfa,socialaccount]~=65.13.1",
|
||||
"django-allauth[mfa,socialaccount]~=65.14.0",
|
||||
"django-auditlog~=3.4.1",
|
||||
"django-cachalot~=2.8.0",
|
||||
"django-cachalot~=2.9.0",
|
||||
"django-celery-results~=2.6.0",
|
||||
"django-compression-middleware~=0.5.0",
|
||||
"django-cors-headers~=4.9.0",
|
||||
@@ -42,7 +42,7 @@ dependencies = [
|
||||
"djangorestframework~=3.16",
|
||||
"djangorestframework-guardian~=0.4.0",
|
||||
"drf-spectacular~=0.28",
|
||||
"drf-spectacular-sidecar~=2025.10.1",
|
||||
"drf-spectacular-sidecar~=2026.1.1",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"faiss-cpu>=1.10",
|
||||
"filelock~=3.20.0",
|
||||
@@ -76,8 +76,7 @@ dependencies = [
|
||||
"sentence-transformers>=4.1",
|
||||
"setproctitle~=1.3.4",
|
||||
"tika-client~=0.10.0",
|
||||
"torch~=2.9.1",
|
||||
"tqdm~=4.67.1",
|
||||
"torch~=2.10.0",
|
||||
"watchfiles>=1.1.1",
|
||||
"whitenoise~=6.11",
|
||||
"whoosh-reloaded>=2.7.5",
|
||||
@@ -94,7 +93,7 @@ optional-dependencies.postgres = [
|
||||
"psycopg-pool==3.3",
|
||||
]
|
||||
optional-dependencies.webserver = [
|
||||
"granian[uvloop]~=2.6.0",
|
||||
"granian[uvloop]~=2.7.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -106,8 +105,7 @@ dev = [
|
||||
]
|
||||
|
||||
docs = [
|
||||
"mkdocs-glightbox~=0.5.1",
|
||||
"mkdocs-material~=9.7.0",
|
||||
"zensical>=0.0.21",
|
||||
]
|
||||
|
||||
testing = [
|
||||
@@ -127,9 +125,8 @@ testing = [
|
||||
]
|
||||
|
||||
lint = [
|
||||
"pre-commit~=4.5.1",
|
||||
"pre-commit-uv~=4.2.0",
|
||||
"ruff~=0.14.0",
|
||||
"prek~=0.3.0",
|
||||
"ruff~=0.15.0",
|
||||
]
|
||||
|
||||
typing = [
|
||||
@@ -138,8 +135,12 @@ typing = [
|
||||
"django-stubs[compatible-mypy]",
|
||||
"djangorestframework-stubs[compatible-mypy]",
|
||||
"lxml-stubs",
|
||||
"microsoft-python-type-stubs @ git+https://github.com/microsoft/python-type-stubs.git",
|
||||
"mypy",
|
||||
"mypy-baseline",
|
||||
"pyrefly",
|
||||
"types-bleach",
|
||||
"types-channels",
|
||||
"types-colorama",
|
||||
"types-dateparser",
|
||||
"types-markdown",
|
||||
@@ -148,7 +149,6 @@ typing = [
|
||||
"types-pytz",
|
||||
"types-redis",
|
||||
"types-setuptools",
|
||||
"types-tqdm",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
@@ -159,6 +159,11 @@ environments = [
|
||||
"sys_platform == 'linux'",
|
||||
]
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-cpu"
|
||||
url = "https://download.pytorch.org/whl/cpu"
|
||||
explicit = true
|
||||
|
||||
[tool.uv.sources]
|
||||
# Markers are chosen to select these almost exclusively when building the Docker image
|
||||
psycopg-c = [
|
||||
@@ -174,11 +179,6 @@ torch = [
|
||||
{ index = "pytorch-cpu" },
|
||||
]
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-cpu"
|
||||
url = "https://download.pytorch.org/whl/cpu"
|
||||
explicit = true
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
line-length = 88
|
||||
@@ -306,12 +306,20 @@ markers = [
|
||||
"gotenberg: Tests requiring Gotenberg service",
|
||||
"tika: Tests requiring Tika service",
|
||||
"greenmail: Tests requiring Greenmail service",
|
||||
"date_parsing: Tests which cover date parsing from content or filename",
|
||||
]
|
||||
|
||||
[tool.pytest_env]
|
||||
PAPERLESS_DISABLE_DBHANDLER = "true"
|
||||
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_also = [
|
||||
"if settings.AUDIT_LOG_ENABLED:",
|
||||
"if AUDIT_LOG_ENABLED:",
|
||||
"if TYPE_CHECKING:",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = [
|
||||
"src/",
|
||||
@@ -323,13 +331,6 @@ omit = [
|
||||
"paperless/auth.py",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_also = [
|
||||
"if settings.AUDIT_LOG_ENABLED:",
|
||||
"if AUDIT_LOG_ENABLED:",
|
||||
"if TYPE_CHECKING:",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
mypy_path = "src"
|
||||
plugins = [
|
||||
@@ -343,5 +344,15 @@ disallow_untyped_defs = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
|
||||
[tool.pyrefly]
|
||||
search-path = [ "src" ]
|
||||
baseline = ".pyrefly-baseline.json"
|
||||
python-platform = "linux"
|
||||
|
||||
[tool.django-stubs]
|
||||
django_settings_module = "paperless.settings"
|
||||
|
||||
[tool.mypy-baseline]
|
||||
baseline_path = ".mypy-baseline.txt"
|
||||
sort_baseline = true
|
||||
ignore_categories = [ "note" ]
|
||||
|
||||
@@ -86,7 +86,6 @@
|
||||
],
|
||||
"scripts": [],
|
||||
"allowedCommonJsDependencies": [
|
||||
"ng2-pdf-viewer",
|
||||
"file-saver",
|
||||
"utif"
|
||||
],
|
||||
|
||||
@@ -72,7 +72,7 @@ test('should show a mobile preview', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 400, height: 1000 })
|
||||
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
|
||||
await page.getByRole('tab', { name: 'Preview' }).click()
|
||||
await page.waitForSelector('pdf-viewer')
|
||||
await page.waitForSelector('pngx-pdf-viewer')
|
||||
})
|
||||
|
||||
test('should show a list of notes', async ({ page }) => {
|
||||
|
||||
@@ -31,6 +31,10 @@ module.exports = {
|
||||
moduleNameMapper: {
|
||||
...esmPreset.moduleNameMapper,
|
||||
'^src/(.*)': '<rootDir>/src/$1',
|
||||
'^pdfjs-dist/legacy/build/pdf\\.mjs$':
|
||||
'<rootDir>/src/test/mocks/pdfjs-legacy-build-pdf.ts',
|
||||
'^pdfjs-dist/web/pdf_viewer\\.mjs$':
|
||||
'<rootDir>/src/test/mocks/pdfjs-web-pdf_viewer.ts',
|
||||
},
|
||||
workerIdleMemoryLimit: '512MB',
|
||||
reporters: [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,15 +11,15 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^21.1.2",
|
||||
"@angular/common": "~21.1.2",
|
||||
"@angular/compiler": "~21.1.2",
|
||||
"@angular/core": "~21.1.2",
|
||||
"@angular/forms": "~21.1.2",
|
||||
"@angular/localize": "~21.1.2",
|
||||
"@angular/platform-browser": "~21.1.2",
|
||||
"@angular/platform-browser-dynamic": "~21.1.2",
|
||||
"@angular/router": "~21.1.2",
|
||||
"@angular/cdk": "^21.1.3",
|
||||
"@angular/common": "~21.1.3",
|
||||
"@angular/compiler": "~21.1.3",
|
||||
"@angular/core": "~21.1.3",
|
||||
"@angular/forms": "~21.1.3",
|
||||
"@angular/localize": "~21.1.3",
|
||||
"@angular/platform-browser": "~21.1.3",
|
||||
"@angular/platform-browser-dynamic": "~21.1.3",
|
||||
"@angular/router": "~21.1.3",
|
||||
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
||||
"@ng-select/ng-select": "^21.2.0",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
@@ -27,12 +27,12 @@
|
||||
"bootstrap": "^5.3.8",
|
||||
"file-saver": "^2.0.5",
|
||||
"mime-names": "^1.0.0",
|
||||
"ng2-pdf-viewer": "^10.4.0",
|
||||
"ngx-bootstrap-icons": "^1.9.3",
|
||||
"ngx-color": "^10.1.0",
|
||||
"ngx-cookie-service": "^21.1.0",
|
||||
"ngx-device-detector": "^11.0.0",
|
||||
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
|
||||
"ngx-ui-tour-ng-bootstrap": "^18.0.0",
|
||||
"pdfjs-dist": "^5.4.624",
|
||||
"rxjs": "^7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
"utif": "^3.1.0",
|
||||
@@ -42,20 +42,20 @@
|
||||
"devDependencies": {
|
||||
"@angular-builders/custom-webpack": "^21.0.3",
|
||||
"@angular-builders/jest": "^21.0.3",
|
||||
"@angular-devkit/core": "^21.1.2",
|
||||
"@angular-devkit/schematics": "^21.1.2",
|
||||
"@angular-devkit/core": "^21.1.3",
|
||||
"@angular-devkit/schematics": "^21.1.3",
|
||||
"@angular-eslint/builder": "21.2.0",
|
||||
"@angular-eslint/eslint-plugin": "21.2.0",
|
||||
"@angular-eslint/eslint-plugin-template": "21.2.0",
|
||||
"@angular-eslint/schematics": "21.2.0",
|
||||
"@angular-eslint/template-parser": "21.2.0",
|
||||
"@angular/build": "^21.1.2",
|
||||
"@angular/cli": "~21.1.2",
|
||||
"@angular/compiler-cli": "~21.1.2",
|
||||
"@angular/build": "^21.1.3",
|
||||
"@angular/cli": "~21.1.3",
|
||||
"@angular/compiler-cli": "~21.1.3",
|
||||
"@codecov/webpack-plugin": "^1.9.1",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/node": "^25.2.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"@typescript-eslint/utils": "^8.54.0",
|
||||
@@ -68,7 +68,7 @@
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"ts-node": "~10.9.1",
|
||||
"typescript": "^5.9.3",
|
||||
"webpack": "^5.103.0"
|
||||
"webpack": "^5.105.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"pnpm": {
|
||||
|
||||
1332
src-ui/pnpm-lock.yaml
generated
1332
src-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -100,10 +100,10 @@ const mock = () => {
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'open', { value: jest.fn() })
|
||||
Object.defineProperty(window, 'localStorage', { value: mock() })
|
||||
Object.defineProperty(window, 'sessionStorage', { value: mock() })
|
||||
Object.defineProperty(window, 'getComputedStyle', {
|
||||
Object.defineProperty(globalThis, 'open', { value: jest.fn() })
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: mock() })
|
||||
Object.defineProperty(globalThis, 'sessionStorage', { value: mock() })
|
||||
Object.defineProperty(globalThis, 'getComputedStyle', {
|
||||
value: () => ['-webkit-appearance'],
|
||||
})
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
@@ -115,13 +115,33 @@ Object.defineProperty(navigator, 'canShare', { value: () => true })
|
||||
if (!navigator.share) {
|
||||
Object.defineProperty(navigator, 'share', { value: jest.fn() })
|
||||
}
|
||||
if (!URL.createObjectURL) {
|
||||
Object.defineProperty(window.URL, 'createObjectURL', { value: jest.fn() })
|
||||
if (!globalThis.URL.createObjectURL) {
|
||||
Object.defineProperty(globalThis.URL, 'createObjectURL', { value: jest.fn() })
|
||||
}
|
||||
if (!URL.revokeObjectURL) {
|
||||
Object.defineProperty(window.URL, 'revokeObjectURL', { value: jest.fn() })
|
||||
if (!globalThis.URL.revokeObjectURL) {
|
||||
Object.defineProperty(globalThis.URL, 'revokeObjectURL', { value: jest.fn() })
|
||||
}
|
||||
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
|
||||
class MockResizeObserver {
|
||||
private readonly callback: ResizeObserverCallback
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback
|
||||
}
|
||||
|
||||
observe = jest.fn()
|
||||
unobserve = jest.fn()
|
||||
disconnect = jest.fn()
|
||||
|
||||
trigger = (entries: ResizeObserverEntry[] = []) => {
|
||||
this.callback(entries, this)
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, 'ResizeObserver', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: MockResizeObserver,
|
||||
})
|
||||
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
class MockIntersectionObserver {
|
||||
@@ -136,7 +156,7 @@ if (typeof IntersectionObserver === 'undefined') {
|
||||
takeRecords = jest.fn()
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
Object.defineProperty(globalThis, 'IntersectionObserver', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: MockIntersectionObserver,
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
import { Router, RouterModule } from '@angular/router'
|
||||
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import {
|
||||
provideUiTour,
|
||||
TourNgBootstrap,
|
||||
TourService,
|
||||
} from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { Subject } from 'rxjs'
|
||||
import { routes } from './app-routing.module'
|
||||
import { AppComponent } from './app.component'
|
||||
@@ -40,12 +44,12 @@ describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
TourNgBootstrapModule,
|
||||
RouterModule.forRoot(routes),
|
||||
NgbModalModule,
|
||||
AppComponent,
|
||||
ToastsComponent,
|
||||
FileDropComponent,
|
||||
TourNgBootstrap,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
providers: [
|
||||
@@ -53,6 +57,7 @@ describe('AppComponent', () => {
|
||||
DirtySavedViewGuard,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideUiTour(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject, OnDestroy, OnInit, Renderer2 } from '@angular/core'
|
||||
import { Router, RouterOutlet } from '@angular/router'
|
||||
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { TourNgBootstrap, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { first, Subscription } from 'rxjs'
|
||||
import { ToastsComponent } from './components/common/toasts/toasts.component'
|
||||
import { FileDropComponent } from './components/file-drop/file-drop.component'
|
||||
@@ -21,12 +21,7 @@ import { WebsocketStatusService } from './services/websocket-status.service'
|
||||
selector: 'pngx-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss'],
|
||||
imports: [
|
||||
FileDropComponent,
|
||||
ToastsComponent,
|
||||
TourNgBootstrapModule,
|
||||
RouterOutlet,
|
||||
],
|
||||
imports: [FileDropComponent, ToastsComponent, TourNgBootstrap, RouterOutlet],
|
||||
})
|
||||
export class AppComponent implements OnInit, OnDestroy {
|
||||
private settings = inject(SettingsService)
|
||||
@@ -167,12 +162,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
})
|
||||
}
|
||||
|
||||
const prevBtnTitle = $localize`Prev`
|
||||
const nextBtnTitle = $localize`Next`
|
||||
const endBtnTitle = $localize`End`
|
||||
|
||||
this.tourService.initialize(
|
||||
[
|
||||
this.tourService.initialize([
|
||||
{
|
||||
anchorId: 'tour.dashboard',
|
||||
content: $localize`The dashboard can be used to show saved views, such as an 'Inbox'. Views are found under Manage > Saved Views once you have created some.`,
|
||||
@@ -256,19 +246,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
enableBackdrop: true,
|
||||
backdropConfig: {
|
||||
offset: 10,
|
||||
},
|
||||
prevBtnTitle,
|
||||
nextBtnTitle,
|
||||
endBtnTitle,
|
||||
isOptional: true,
|
||||
useLegacyTitle: true,
|
||||
}
|
||||
)
|
||||
])
|
||||
|
||||
this.tourService.start$.subscribe(() => {
|
||||
this.renderer.addClass(document.body, 'tour-active')
|
||||
|
||||
@@ -222,8 +222,8 @@
|
||||
</div>
|
||||
<div class="col">
|
||||
<select class="form-select" formControlName="pdfViewerDefaultZoom">
|
||||
<option [ngValue]="ZoomSetting.PageWidth" i18n>Fit width</option>
|
||||
<option [ngValue]="ZoomSetting.PageFit" i18n>Fit page</option>
|
||||
<option [ngValue]="PdfZoomScale.PageWidth" i18n>Fit width</option>
|
||||
<option [ngValue]="PdfZoomScale.PageFit" i18n>Fit page</option>
|
||||
</select>
|
||||
<p class="small text-muted mt-1" i18n>Only applies to the Paperless-ngx PDF viewer.</p>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import {
|
||||
@@ -147,6 +148,7 @@ describe('SettingsComponent', () => {
|
||||
PermissionsGuard,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideUiTour(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ import { PermissionsUserComponent } from '../../common/input/permissions/permiss
|
||||
import { SelectComponent } from '../../common/input/select/select.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { PdfEditorEditMode } from '../../common/pdf-editor/pdf-editor-edit-mode'
|
||||
import { PdfZoomScale } from '../../common/pdf-viewer/pdf-viewer.types'
|
||||
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
|
||||
import { ZoomSetting } from '../../document-detail/zoom-setting'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
|
||||
enum SettingsNavIDs {
|
||||
@@ -196,7 +196,7 @@ export class SettingsComponent
|
||||
|
||||
public readonly GlobalSearchType = GlobalSearchType
|
||||
|
||||
public readonly ZoomSetting = ZoomSetting
|
||||
public readonly PdfZoomScale = PdfZoomScale
|
||||
|
||||
public readonly PdfEditorEditMode = PdfEditorEditMode
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
@@ -157,6 +158,7 @@ describe('AppFrameComponent', () => {
|
||||
PermissionsGuard,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideUiTour(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
NgbPopoverModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { Observable } from 'rxjs'
|
||||
import { first } from 'rxjs/operators'
|
||||
import { Document } from 'src/app/data/document'
|
||||
@@ -69,7 +69,7 @@ import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.compo
|
||||
NgbNavModule,
|
||||
NgxBootstrapIconsModule,
|
||||
DragDropModule,
|
||||
TourNgBootstrapModule,
|
||||
TourNgBootstrap,
|
||||
],
|
||||
})
|
||||
export class AppFrameComponent
|
||||
|
||||
@@ -430,6 +430,24 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case (WorkflowActionType.PasswordRemoval) {
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<p class="small" i18n>
|
||||
One password per line. The workflow will try them in order until one succeeds.
|
||||
</p>
|
||||
<pngx-input-textarea
|
||||
i18n-title
|
||||
title="Passwords"
|
||||
formControlName="passwords"
|
||||
rows="4"
|
||||
[error]="error?.actions?.[i]?.passwords"
|
||||
hint="Passwords are stored in plain text. Use with caution."
|
||||
i18n-hint
|
||||
></pngx-input-textarea>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import {
|
||||
FormArray,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormsModule,
|
||||
@@ -994,4 +995,32 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
component.removeSelectedCustomField(3, formGroup)
|
||||
expect(formGroup.get('assign_custom_fields').value).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle parsing of passwords from array to string and back on save', () => {
|
||||
const passwordAction: WorkflowAction = {
|
||||
id: 1,
|
||||
type: WorkflowActionType.PasswordRemoval,
|
||||
passwords: ['pass1', 'pass2'],
|
||||
}
|
||||
component.object = {
|
||||
name: 'Workflow with Passwords',
|
||||
id: 1,
|
||||
order: 1,
|
||||
enabled: true,
|
||||
triggers: [],
|
||||
actions: [passwordAction],
|
||||
}
|
||||
component.ngOnInit()
|
||||
|
||||
const formActions = component.objectForm.get('actions') as FormArray
|
||||
expect(formActions.value[0].passwords).toBe('pass1\npass2')
|
||||
formActions.at(0).get('passwords').setValue('pass1\npass2\npass3')
|
||||
component.save()
|
||||
|
||||
expect(component.objectForm.get('actions').value[0].passwords).toEqual([
|
||||
'pass1',
|
||||
'pass2',
|
||||
'pass3',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -139,6 +139,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
||||
id: WorkflowActionType.Webhook,
|
||||
name: $localize`Webhook`,
|
||||
},
|
||||
{
|
||||
id: WorkflowActionType.PasswordRemoval,
|
||||
name: $localize`Password removal`,
|
||||
},
|
||||
]
|
||||
|
||||
export enum TriggerFilterType {
|
||||
@@ -1202,11 +1206,25 @@ export class WorkflowEditDialogComponent
|
||||
headers: new FormControl(action.webhook?.headers),
|
||||
include_document: new FormControl(!!action.webhook?.include_document),
|
||||
}),
|
||||
passwords: new FormControl(
|
||||
this.formatPasswords(action.passwords ?? [])
|
||||
),
|
||||
}),
|
||||
{ emitEvent }
|
||||
)
|
||||
}
|
||||
|
||||
private formatPasswords(passwords: string[] = []): string {
|
||||
return passwords.join('\n')
|
||||
}
|
||||
|
||||
private parsePasswords(value: string = ''): string[] {
|
||||
return value
|
||||
.split(/[\n,]+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
}
|
||||
|
||||
private updateAllTriggerActionFields(emitEvent: boolean = false) {
|
||||
this.triggerFields.clear({ emitEvent: false })
|
||||
this.object?.triggers.forEach((trigger) => {
|
||||
@@ -1331,6 +1349,7 @@ export class WorkflowEditDialogComponent
|
||||
headers: null,
|
||||
include_document: false,
|
||||
},
|
||||
passwords: [],
|
||||
}
|
||||
this.object.actions.push(action)
|
||||
this.createActionField(action)
|
||||
@@ -1367,6 +1386,7 @@ export class WorkflowEditDialogComponent
|
||||
if (action.type !== WorkflowActionType.Email) {
|
||||
action.email = null
|
||||
}
|
||||
action.passwords = this.parsePasswords(action.passwords as any)
|
||||
})
|
||||
super.save()
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
|
||||
}
|
||||
@if (info) {
|
||||
<button class="btn btn-sm btn-link text-muted me-auto p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
||||
<button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
||||
<i-bs name="question-circle"></i-bs>
|
||||
</button>
|
||||
<ng-template #infoPopover>
|
||||
@@ -26,6 +26,9 @@
|
||||
}
|
||||
</ng-template>
|
||||
}
|
||||
@if (loading) {
|
||||
<output class="spinner-border spinner-border-sm fs-6 fw-normal" aria-hidden="true"><span class="visually-hidden" i18n>Loading...</span></output>
|
||||
}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="btn-toolbar col col-md-auto gap-2">
|
||||
|
||||
@@ -3,14 +3,14 @@ import { Component, Input, inject } from '@angular/core'
|
||||
import { Title } from '@angular/platform-browser'
|
||||
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { environment } from 'src/environments/environment'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-page-header',
|
||||
templateUrl: './page-header.component.html',
|
||||
styleUrls: ['./page-header.component.scss'],
|
||||
imports: [NgbPopoverModule, NgxBootstrapIconsModule, TourNgBootstrapModule],
|
||||
imports: [NgbPopoverModule, NgxBootstrapIconsModule, TourNgBootstrap],
|
||||
})
|
||||
export class PageHeaderComponent {
|
||||
private titleService = inject(Title)
|
||||
@@ -42,6 +42,9 @@ export class PageHeaderComponent {
|
||||
@Input()
|
||||
infoLink: string
|
||||
|
||||
@Input()
|
||||
loading: boolean = false
|
||||
|
||||
public copyID() {
|
||||
this.copied = this.clipboard.copy(this.id.toString())
|
||||
clearTimeout(this.copyTimeout)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer>
|
||||
<pngx-pdf-viewer class="visually-hidden" [src]="pdfSrc" [renderMode]="PdfRenderMode.Single" [page]="1" [selectable]="false" (afterLoadComplete)="pdfLoaded($event)"></pngx-pdf-viewer>
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{ title }}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
||||
@@ -59,7 +59,7 @@
|
||||
<span class="placeholder w-100 h-100"></span>
|
||||
</div>
|
||||
}
|
||||
<pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [original-size]="false" [show-all]="false" [render-text]="false" (page-rendered)="p.loaded = true"></pdf-viewer>
|
||||
<pngx-pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [renderMode]="PdfRenderMode.Single" (rendered)="p.loaded = true"></pngx-pdf-viewer>
|
||||
} @placeholder {
|
||||
<div class="placeholder-glow w-100 h-100 z-10">
|
||||
<span class="placeholder w-100 h-100"></span>
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
background-color: gray;
|
||||
height: 240px;
|
||||
|
||||
pdf-viewer {
|
||||
pngx-pdf-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .ng2-pdf-viewer-container {
|
||||
::ng-deep .pngx-pdf-viewer-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,16 @@ import {
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
|
||||
import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
|
||||
import {
|
||||
PdfRenderMode,
|
||||
PngxPdfDocumentProxy,
|
||||
} from '../pdf-viewer/pdf-viewer.types'
|
||||
import { PdfEditorEditMode } from './pdf-editor-edit-mode'
|
||||
|
||||
interface PageOperation {
|
||||
@@ -29,11 +33,12 @@ interface PageOperation {
|
||||
imports: [
|
||||
DragDropModule,
|
||||
FormsModule,
|
||||
PdfViewerModule,
|
||||
NgxBootstrapIconsModule,
|
||||
PngxPdfViewerComponent,
|
||||
],
|
||||
})
|
||||
export class PDFEditorComponent extends ConfirmDialogComponent {
|
||||
PdfRenderMode = PdfRenderMode
|
||||
public PdfEditorEditMode = PdfEditorEditMode
|
||||
|
||||
private documentService = inject(DocumentService)
|
||||
@@ -53,7 +58,7 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
|
||||
return this.documentService.getPreviewUrl(this.documentID)
|
||||
}
|
||||
|
||||
pdfLoaded(pdf: PDFDocumentProxy) {
|
||||
pdfLoaded(pdf: PngxPdfDocumentProxy) {
|
||||
this.totalPages = pdf.numPages
|
||||
this.pages = Array.from({ length: this.totalPages }, (_, i) => ({
|
||||
page: i + 1,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<div #container class="pngx-pdf-viewer-container">
|
||||
<div #viewer class="pdfViewer"></div>
|
||||
</div>
|
||||
@@ -0,0 +1,153 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:host ::ng-deep .pngx-pdf-viewer-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:host ::ng-deep .pdfViewer {
|
||||
--scale-factor: 1;
|
||||
--page-bg-color: unset;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .pdfViewer .page {
|
||||
--user-unit: 1;
|
||||
--total-scale-factor: calc(var(--scale-factor) * var(--user-unit));
|
||||
--scale-round-x: 1px;
|
||||
--scale-round-y: 1px;
|
||||
direction: ltr;
|
||||
margin: 0 auto 10px;
|
||||
border: 0;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
background-clip: content-box;
|
||||
background-color: var(--page-bg-color, rgb(255 255 255));
|
||||
}
|
||||
|
||||
:host ::ng-deep .pdfViewer > .page:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .pdfViewer.singlePageView {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
:host ::ng-deep .pdfViewer.singlePageView .page {
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:host ::ng-deep .pdfViewer .canvasWrapper {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:host ::ng-deep .pdfViewer .canvasWrapper canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
contain: content;
|
||||
}
|
||||
|
||||
:host ::ng-deep .textLayer {
|
||||
position: absolute;
|
||||
text-align: initial;
|
||||
inset: 0;
|
||||
overflow: clip;
|
||||
opacity: 1;
|
||||
line-height: 1;
|
||||
text-size-adjust: none;
|
||||
transform-origin: 0 0;
|
||||
caret-color: CanvasText;
|
||||
z-index: 0;
|
||||
user-select: text;
|
||||
--min-font-size: 1;
|
||||
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
|
||||
--min-font-size-inv: calc(1 / var(--min-font-size));
|
||||
}
|
||||
|
||||
:host ::ng-deep .textLayer.highlighting {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
:host ::ng-deep .textLayer :is(span, br) {
|
||||
position: absolute;
|
||||
white-space: pre;
|
||||
color: transparent;
|
||||
cursor: text;
|
||||
transform-origin: 0% 0%;
|
||||
}
|
||||
|
||||
:host ::ng-deep .textLayer > :not(.markedContent),
|
||||
:host ::ng-deep .textLayer .markedContent span:not(.markedContent) {
|
||||
z-index: 1;
|
||||
--font-height: 0;
|
||||
font-size: calc(var(--text-scale-factor) * var(--font-height));
|
||||
--scale-x: 1;
|
||||
--rotate: 0deg;
|
||||
transform: rotate(var(--rotate)) scaleX(var(--scale-x))
|
||||
scale(var(--min-font-size-inv));
|
||||
}
|
||||
|
||||
:host ::ng-deep .textLayer .markedContent {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
:host ::ng-deep .textLayer span[role='img'] {
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:host ::ng-deep .textLayer .highlight {
|
||||
--highlight-bg-color: rgb(180 0 170 / 0.25);
|
||||
--highlight-selected-bg-color: rgb(0 100 0 / 0.25);
|
||||
--highlight-backdrop-filter: none;
|
||||
--highlight-selected-backdrop-filter: none;
|
||||
margin: -1px;
|
||||
padding: 1px;
|
||||
background-color: var(--highlight-bg-color);
|
||||
backdrop-filter: var(--highlight-backdrop-filter);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .appended:is(.textLayer .highlight) {
|
||||
position: initial;
|
||||
}
|
||||
|
||||
:host ::ng-deep .begin:is(.textLayer .highlight) {
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .end:is(.textLayer .highlight) {
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .middle:is(.textLayer .highlight) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:host ::ng-deep .selected:is(.textLayer .highlight) {
|
||||
background-color: var(--highlight-selected-bg-color);
|
||||
}
|
||||
|
||||
:host ::ng-deep .textLayer ::selection {
|
||||
background: rgba(30, 100, 255, 0.35);
|
||||
}
|
||||
|
||||
:host ::ng-deep .annotationLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { SimpleChange } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||
import { PDFSinglePageViewer, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'
|
||||
import { PngxPdfViewerComponent } from './pdf-viewer.component'
|
||||
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
|
||||
|
||||
describe('PngxPdfViewerComponent', () => {
|
||||
let fixture: ComponentFixture<PngxPdfViewerComponent>
|
||||
let component: PngxPdfViewerComponent
|
||||
|
||||
const initComponent = async (src = 'test.pdf') => {
|
||||
component.src = src
|
||||
fixture.detectChanges()
|
||||
await fixture.whenStable()
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PngxPdfViewerComponent],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(PngxPdfViewerComponent)
|
||||
component = fixture.componentInstance
|
||||
})
|
||||
|
||||
it('loads a document and emits events', async () => {
|
||||
const loadSpy = jest.fn()
|
||||
const renderedSpy = jest.fn()
|
||||
component.afterLoadComplete.subscribe(loadSpy)
|
||||
component.rendered.subscribe(renderedSpy)
|
||||
|
||||
await initComponent()
|
||||
|
||||
expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe(
|
||||
'/assets/js/pdf.worker.min.mjs'
|
||||
)
|
||||
const isVisible = (component as any).findController.onIsPageVisible as
|
||||
| (() => boolean)
|
||||
| undefined
|
||||
expect(isVisible?.()).toBe(true)
|
||||
expect(loadSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ numPages: 1 })
|
||||
)
|
||||
expect(renderedSpy).toHaveBeenCalled()
|
||||
expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer)
|
||||
})
|
||||
|
||||
it('initializes single-page viewer and disables text layer', async () => {
|
||||
component.renderMode = PdfRenderMode.Single
|
||||
component.selectable = false
|
||||
|
||||
await initComponent()
|
||||
|
||||
const viewer = (component as any).pdfViewer as PDFSinglePageViewer & {
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
expect(viewer).toBeInstanceOf(PDFSinglePageViewer)
|
||||
expect(viewer.options.textLayerMode).toBe(0)
|
||||
})
|
||||
|
||||
it('applies zoom, rotation, and page changes', async () => {
|
||||
await initComponent()
|
||||
|
||||
const pageSpy = jest.fn()
|
||||
component.pageChange.subscribe(pageSpy)
|
||||
|
||||
component.zoomScale = PdfZoomScale.PageFit
|
||||
component.zoom = PdfZoomLevel.Two
|
||||
component.rotation = 90
|
||||
component.page = 2
|
||||
|
||||
component.ngOnChanges({
|
||||
zoomScale: new SimpleChange(
|
||||
PdfZoomScale.PageWidth,
|
||||
PdfZoomScale.PageFit,
|
||||
false
|
||||
),
|
||||
zoom: new SimpleChange(PdfZoomLevel.One, PdfZoomLevel.Two, false),
|
||||
rotation: new SimpleChange(undefined, 90, false),
|
||||
page: new SimpleChange(undefined, 2, false),
|
||||
})
|
||||
|
||||
const viewer = (component as any).pdfViewer as PDFViewer
|
||||
expect(viewer.pagesRotation).toBe(90)
|
||||
expect(viewer.currentPageNumber).toBe(2)
|
||||
expect(pageSpy).toHaveBeenCalledWith(2)
|
||||
|
||||
viewer.currentScale = 1
|
||||
;(component as any).applyScale()
|
||||
expect(viewer.currentScaleValue).toBe(PdfZoomScale.PageFit)
|
||||
expect(viewer.currentScale).toBe(2)
|
||||
|
||||
const applyScaleSpy = jest.spyOn(component as any, 'applyScale')
|
||||
component.page = 2
|
||||
;(component as any).lastViewerPage = 2
|
||||
;(component as any).applyViewerState()
|
||||
expect((component as any).lastViewerPage).toBeUndefined()
|
||||
expect(applyScaleSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispatches find when search query changes after render', async () => {
|
||||
await initComponent()
|
||||
|
||||
const eventBus = (component as any).eventBus as { dispatch: jest.Mock }
|
||||
const dispatchSpy = jest.spyOn(eventBus, 'dispatch')
|
||||
|
||||
;(component as any).hasRenderedPage = true
|
||||
component.searchQuery = 'needle'
|
||||
component.ngOnChanges({
|
||||
searchQuery: new SimpleChange('', 'needle', false),
|
||||
})
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith('find', {
|
||||
query: 'needle',
|
||||
caseSensitive: false,
|
||||
highlightAll: true,
|
||||
phraseSearch: true,
|
||||
})
|
||||
|
||||
component.ngOnChanges({
|
||||
searchQuery: new SimpleChange('needle', 'needle', false),
|
||||
})
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('emits error when document load fails', async () => {
|
||||
const errorSpy = jest.fn()
|
||||
component.loadError.subscribe(errorSpy)
|
||||
|
||||
jest.spyOn(pdfjs, 'getDocument').mockImplementationOnce(() => {
|
||||
return {
|
||||
promise: Promise.reject(new Error('boom')),
|
||||
destroy: jest.fn(),
|
||||
} as any
|
||||
})
|
||||
|
||||
await initComponent('bad.pdf')
|
||||
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up resources on destroy', async () => {
|
||||
await initComponent()
|
||||
|
||||
const viewer = (component as any).pdfViewer as { cleanup: jest.Mock }
|
||||
const loadingTask = (component as any).loadingTask as unknown as {
|
||||
destroy: jest.Mock
|
||||
}
|
||||
const resizeObserver = (component as any).resizeObserver as unknown as {
|
||||
disconnect: jest.Mock
|
||||
}
|
||||
const eventBus = (component as any).eventBus as { off: jest.Mock }
|
||||
|
||||
jest.spyOn(viewer, 'cleanup')
|
||||
jest.spyOn(loadingTask, 'destroy')
|
||||
jest.spyOn(resizeObserver, 'disconnect')
|
||||
jest.spyOn(eventBus, 'off')
|
||||
|
||||
component.ngOnDestroy()
|
||||
|
||||
expect(eventBus.off).toHaveBeenCalledWith(
|
||||
'pagerendered',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(eventBus.off).toHaveBeenCalledWith('pagesinit', expect.any(Function))
|
||||
expect(eventBus.off).toHaveBeenCalledWith(
|
||||
'pagechanging',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(resizeObserver.disconnect).toHaveBeenCalled()
|
||||
expect(loadingTask.destroy).toHaveBeenCalled()
|
||||
expect(viewer.cleanup).toHaveBeenCalled()
|
||||
expect((component as any).pdfViewer).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips work when viewer is missing or has no pages', () => {
|
||||
const eventBus = (component as any).eventBus as { dispatch: jest.Mock }
|
||||
const dispatchSpy = jest.spyOn(eventBus, 'dispatch')
|
||||
;(component as any).dispatchFindIfReady()
|
||||
expect(dispatchSpy).not.toHaveBeenCalled()
|
||||
;(component as any).applyViewerState()
|
||||
;(component as any).applyScale()
|
||||
|
||||
const viewer = new PDFViewer({ eventBus: undefined })
|
||||
viewer.pagesCount = 0
|
||||
;(component as any).pdfViewer = viewer
|
||||
viewer.currentScale = 5
|
||||
;(component as any).applyScale()
|
||||
expect(viewer.currentScale).toBe(5)
|
||||
})
|
||||
|
||||
it('returns early on src change in ngOnChanges', () => {
|
||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||
const initSpy = jest.spyOn(component as any, 'initViewer')
|
||||
const scaleSpy = jest.spyOn(component as any, 'applyViewerState')
|
||||
const resizeSpy = jest.spyOn(component as any, 'setupResizeObserver')
|
||||
|
||||
component.ngOnChanges({
|
||||
src: new SimpleChange(undefined, 'test.pdf', true),
|
||||
zoomScale: new SimpleChange(
|
||||
PdfZoomScale.PageWidth,
|
||||
PdfZoomScale.PageFit,
|
||||
false
|
||||
),
|
||||
})
|
||||
|
||||
expect(loadSpy).toHaveBeenCalled()
|
||||
expect(resizeSpy).not.toHaveBeenCalled()
|
||||
expect(initSpy).not.toHaveBeenCalled()
|
||||
expect(scaleSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies viewer state after view init when already loaded', () => {
|
||||
const applySpy = jest.spyOn(component as any, 'applyViewerState')
|
||||
;(component as any).hasLoaded = true
|
||||
;(component as any).pdf = { numPages: 1 }
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(applySpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips viewer state after view init when no pdf is available', () => {
|
||||
const applySpy = jest.spyOn(component as any, 'applyViewerState')
|
||||
;(component as any).hasLoaded = true
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(applySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reload when already loaded', async () => {
|
||||
await initComponent()
|
||||
|
||||
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
|
||||
const callCount = getDocumentSpy.mock.calls.length
|
||||
await (component as any).loadDocument()
|
||||
|
||||
expect(getDocumentSpy).toHaveBeenCalledTimes(callCount)
|
||||
})
|
||||
|
||||
it('runs applyScale on resize observer notifications', async () => {
|
||||
await initComponent()
|
||||
|
||||
const applySpy = jest.spyOn(component as any, 'applyScale')
|
||||
const resizeObserver = (component as any).resizeObserver as {
|
||||
trigger: () => void
|
||||
}
|
||||
resizeObserver.trigger()
|
||||
|
||||
expect(applySpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips page work when no pages are available', async () => {
|
||||
await initComponent()
|
||||
|
||||
const viewer = (component as any).pdfViewer as PDFViewer
|
||||
viewer.pagesCount = 0
|
||||
const applyScaleSpy = jest.spyOn(component as any, 'applyScale')
|
||||
|
||||
component.page = undefined
|
||||
;(component as any).lastViewerPage = 1
|
||||
;(component as any).applyViewerState()
|
||||
|
||||
expect(applyScaleSpy).not.toHaveBeenCalled()
|
||||
expect((component as any).lastViewerPage).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to a default zoom when input is invalid', async () => {
|
||||
await initComponent()
|
||||
|
||||
const viewer = (component as any).pdfViewer as PDFViewer
|
||||
viewer.currentScale = 3
|
||||
component.zoom = 'not-a-number' as PdfZoomLevel
|
||||
;(component as any).applyScale()
|
||||
|
||||
expect(viewer.currentScale).toBe(3)
|
||||
})
|
||||
|
||||
it('re-initializes viewer on selectable or render mode changes', async () => {
|
||||
await initComponent()
|
||||
|
||||
const initSpy = jest.spyOn(component as any, 'initViewer')
|
||||
component.selectable = false
|
||||
component.renderMode = PdfRenderMode.Single
|
||||
|
||||
component.ngOnChanges({
|
||||
selectable: new SimpleChange(true, false, false),
|
||||
renderMode: new SimpleChange(
|
||||
PdfRenderMode.All,
|
||||
PdfRenderMode.Single,
|
||||
false
|
||||
),
|
||||
})
|
||||
|
||||
expect(initSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
getDocument,
|
||||
GlobalWorkerOptions,
|
||||
PDFDocumentLoadingTask,
|
||||
PDFDocumentProxy,
|
||||
} from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||
import {
|
||||
EventBus,
|
||||
PDFFindController,
|
||||
PDFLinkService,
|
||||
PDFSinglePageViewer,
|
||||
PDFViewer,
|
||||
} from 'pdfjs-dist/web/pdf_viewer.mjs'
|
||||
import {
|
||||
PdfRenderMode,
|
||||
PdfSource,
|
||||
PdfZoomLevel,
|
||||
PdfZoomScale,
|
||||
PngxPdfDocumentProxy,
|
||||
} from './pdf-viewer.types'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-pdf-viewer',
|
||||
templateUrl: './pdf-viewer.component.html',
|
||||
styleUrl: './pdf-viewer.component.scss',
|
||||
})
|
||||
export class PngxPdfViewerComponent
|
||||
implements AfterViewInit, OnChanges, OnDestroy
|
||||
{
|
||||
@Input() src!: PdfSource
|
||||
@Input() page?: number
|
||||
@Output() pageChange = new EventEmitter<number>()
|
||||
@Input() rotation?: number
|
||||
@Input() renderMode: PdfRenderMode = PdfRenderMode.All
|
||||
@Input() selectable = true
|
||||
@Input() searchQuery = ''
|
||||
@Input() zoom: PdfZoomLevel = PdfZoomLevel.One
|
||||
@Input() zoomScale: PdfZoomScale = PdfZoomScale.PageWidth
|
||||
|
||||
@Output() afterLoadComplete = new EventEmitter<PngxPdfDocumentProxy>()
|
||||
@Output() rendered = new EventEmitter<void>()
|
||||
@Output() loadError = new EventEmitter<unknown>()
|
||||
|
||||
@ViewChild('container', { static: true })
|
||||
private readonly container!: ElementRef<HTMLDivElement>
|
||||
|
||||
@ViewChild('viewer', { static: true })
|
||||
private readonly viewer!: ElementRef<HTMLDivElement>
|
||||
|
||||
private hasLoaded = false
|
||||
private loadingTask?: PDFDocumentLoadingTask
|
||||
private resizeObserver?: ResizeObserver
|
||||
private pdf?: PDFDocumentProxy
|
||||
private pdfViewer?: PDFViewer | PDFSinglePageViewer
|
||||
private hasRenderedPage = false
|
||||
private lastFindQuery = ''
|
||||
private lastViewerPage?: number
|
||||
|
||||
private readonly eventBus = new EventBus()
|
||||
private readonly linkService = new PDFLinkService({ eventBus: this.eventBus })
|
||||
private readonly findController = new PDFFindController({
|
||||
eventBus: this.eventBus,
|
||||
linkService: this.linkService,
|
||||
updateMatchesCountOnProgress: false,
|
||||
})
|
||||
|
||||
private readonly onPageRendered = () => {
|
||||
this.hasRenderedPage = true
|
||||
this.dispatchFindIfReady()
|
||||
this.rendered.emit()
|
||||
}
|
||||
private readonly onPagesInit = () => this.applyScale()
|
||||
private readonly onPageChanging = (evt: { pageNumber: number }) => {
|
||||
// Avoid [(page)] two-way binding re-triggers navigation
|
||||
this.lastViewerPage = evt.pageNumber
|
||||
this.pageChange.emit(evt.pageNumber)
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['src']) {
|
||||
this.hasLoaded = false
|
||||
this.loadDocument()
|
||||
return
|
||||
}
|
||||
|
||||
if (changes['zoomScale']) {
|
||||
this.setupResizeObserver()
|
||||
}
|
||||
|
||||
if (changes['selectable'] || changes['renderMode']) {
|
||||
this.initViewer()
|
||||
}
|
||||
|
||||
if (
|
||||
changes['page'] ||
|
||||
changes['zoom'] ||
|
||||
changes['zoomScale'] ||
|
||||
changes['rotation']
|
||||
) {
|
||||
this.applyViewerState()
|
||||
}
|
||||
|
||||
if (changes['searchQuery']) {
|
||||
this.dispatchFindIfReady()
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.setupResizeObserver()
|
||||
this.initViewer()
|
||||
if (!this.hasLoaded) {
|
||||
this.loadDocument()
|
||||
return
|
||||
}
|
||||
if (this.pdf) {
|
||||
this.applyViewerState()
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.eventBus.off('pagerendered', this.onPageRendered)
|
||||
this.eventBus.off('pagesinit', this.onPagesInit)
|
||||
this.eventBus.off('pagechanging', this.onPageChanging)
|
||||
this.resizeObserver?.disconnect()
|
||||
this.loadingTask?.destroy()
|
||||
this.pdfViewer?.cleanup()
|
||||
this.pdfViewer = undefined
|
||||
}
|
||||
|
||||
private async loadDocument(): Promise<void> {
|
||||
if (this.hasLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
this.hasLoaded = true
|
||||
this.hasRenderedPage = false
|
||||
this.lastFindQuery = ''
|
||||
this.loadingTask?.destroy()
|
||||
|
||||
GlobalWorkerOptions.workerSrc = '/assets/js/pdf.worker.min.mjs'
|
||||
this.loadingTask = getDocument(this.src)
|
||||
|
||||
try {
|
||||
const pdf = await this.loadingTask.promise
|
||||
this.pdf = pdf
|
||||
this.linkService.setDocument(pdf)
|
||||
this.findController.onIsPageVisible = () => true
|
||||
this.pdfViewer?.setDocument(pdf)
|
||||
this.applyViewerState()
|
||||
this.afterLoadComplete.emit(pdf)
|
||||
} catch (err) {
|
||||
this.loadError.emit(err)
|
||||
}
|
||||
}
|
||||
|
||||
private setupResizeObserver(): void {
|
||||
this.resizeObserver?.disconnect()
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.applyScale()
|
||||
})
|
||||
this.resizeObserver.observe(this.container.nativeElement)
|
||||
}
|
||||
|
||||
private initViewer(): void {
|
||||
this.viewer.nativeElement.innerHTML = ''
|
||||
this.pdfViewer?.cleanup()
|
||||
this.hasRenderedPage = false
|
||||
this.lastFindQuery = ''
|
||||
|
||||
const textLayerMode = this.selectable === false ? 0 : 1
|
||||
const options = {
|
||||
container: this.container.nativeElement,
|
||||
viewer: this.viewer.nativeElement,
|
||||
eventBus: this.eventBus,
|
||||
linkService: this.linkService,
|
||||
findController: this.findController,
|
||||
textLayerMode,
|
||||
removePageBorders: true,
|
||||
}
|
||||
|
||||
this.pdfViewer =
|
||||
this.renderMode === PdfRenderMode.Single
|
||||
? new PDFSinglePageViewer(options)
|
||||
: new PDFViewer(options)
|
||||
this.linkService.setViewer(this.pdfViewer)
|
||||
|
||||
this.eventBus.off('pagerendered', this.onPageRendered)
|
||||
this.eventBus.off('pagesinit', this.onPagesInit)
|
||||
this.eventBus.off('pagechanging', this.onPageChanging)
|
||||
this.eventBus.on('pagerendered', this.onPageRendered)
|
||||
this.eventBus.on('pagesinit', this.onPagesInit)
|
||||
this.eventBus.on('pagechanging', this.onPageChanging)
|
||||
|
||||
if (this.pdf) {
|
||||
this.pdfViewer.setDocument(this.pdf)
|
||||
this.applyViewerState()
|
||||
}
|
||||
}
|
||||
|
||||
private applyViewerState(): void {
|
||||
if (!this.pdfViewer) {
|
||||
return
|
||||
}
|
||||
const hasPages = this.pdfViewer.pagesCount > 0
|
||||
if (typeof this.rotation === 'number' && hasPages) {
|
||||
this.pdfViewer.pagesRotation = this.rotation
|
||||
}
|
||||
if (
|
||||
typeof this.page === 'number' &&
|
||||
hasPages &&
|
||||
this.page !== this.lastViewerPage
|
||||
) {
|
||||
this.pdfViewer.currentPageNumber = this.page
|
||||
}
|
||||
if (this.page === this.lastViewerPage) {
|
||||
this.lastViewerPage = undefined
|
||||
}
|
||||
if (hasPages) {
|
||||
this.applyScale()
|
||||
}
|
||||
this.dispatchFindIfReady()
|
||||
}
|
||||
|
||||
private applyScale(): void {
|
||||
if (!this.pdfViewer) {
|
||||
return
|
||||
}
|
||||
if (this.pdfViewer.pagesCount === 0) {
|
||||
return
|
||||
}
|
||||
const zoomFactor = Number(this.zoom) || 1
|
||||
this.pdfViewer.currentScaleValue = this.zoomScale
|
||||
if (zoomFactor !== 1) {
|
||||
this.pdfViewer.currentScale = this.pdfViewer.currentScale * zoomFactor
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchFindIfReady(): void {
|
||||
if (!this.hasRenderedPage) {
|
||||
return
|
||||
}
|
||||
const query = this.searchQuery.trim()
|
||||
if (query === this.lastFindQuery) {
|
||||
return
|
||||
}
|
||||
this.lastFindQuery = query
|
||||
this.eventBus.dispatch('find', {
|
||||
query,
|
||||
caseSensitive: false,
|
||||
highlightAll: query.length > 0,
|
||||
phraseSearch: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type PngxPdfDocumentProxy = {
|
||||
numPages: number
|
||||
}
|
||||
|
||||
export type PdfSource = string | { url: string; password?: string }
|
||||
|
||||
export enum PdfRenderMode {
|
||||
Single = 'single',
|
||||
All = 'all',
|
||||
}
|
||||
|
||||
export enum PdfZoomScale {
|
||||
PageFit = 'page-fit',
|
||||
PageWidth = 'page-width',
|
||||
}
|
||||
|
||||
export enum PdfZoomLevel {
|
||||
Quarter = '.25',
|
||||
Half = '.5',
|
||||
ThreeQuarters = '.75',
|
||||
One = '1',
|
||||
OneAndHalf = '1.5',
|
||||
Two = '2',
|
||||
Three = '3',
|
||||
}
|
||||
@@ -23,14 +23,12 @@
|
||||
</div>
|
||||
}
|
||||
@if (!requiresPassword) {
|
||||
<pdf-viewer
|
||||
<pngx-pdf-viewer
|
||||
[src]="previewUrl"
|
||||
[original-size]="false"
|
||||
[show-borders]="false"
|
||||
[show-all]="true"
|
||||
(text-layer-rendered)="onPageRendered()"
|
||||
(error)="onError($event)" #pdfViewer>
|
||||
</pdf-viewer>
|
||||
[renderMode]="PdfRenderMode.All"
|
||||
[searchQuery]="documentService.searchQuery"
|
||||
(loadError)="onError($event)">
|
||||
</pngx-pdf-viewer>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { of, throwError } from 'rxjs'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
|
||||
import { PreviewPopupComponent } from './preview-popup.component'
|
||||
|
||||
const doc = {
|
||||
@@ -78,7 +79,7 @@ describe('PreviewPopupComponent', () => {
|
||||
component.popover.open()
|
||||
fixture.detectChanges()
|
||||
expect(fixture.debugElement.query(By.css('object'))).toBeNull()
|
||||
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
|
||||
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should show lock icon on password error', () => {
|
||||
@@ -159,23 +160,15 @@ describe('PreviewPopupComponent', () => {
|
||||
expect(component.popover.isOpen()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should dispatch find event on viewer loaded if searchQuery set', () => {
|
||||
it('should pass searchQuery to viewer', () => {
|
||||
documentService.searchQuery = 'test'
|
||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
||||
component.popover.open()
|
||||
jest.advanceTimersByTime(1000)
|
||||
fixture.detectChanges()
|
||||
// normally setup by pdf-viewer
|
||||
jest.replaceProperty(component.pdfViewer, 'eventBus', {
|
||||
dispatch: jest.fn(),
|
||||
} as any)
|
||||
const dispatchSpy = jest.spyOn(component.pdfViewer.eventBus, 'dispatch')
|
||||
component.onPageRendered()
|
||||
expect(dispatchSpy).toHaveBeenCalledWith('find', {
|
||||
query: 'test',
|
||||
caseSensitive: false,
|
||||
highlightAll: true,
|
||||
phraseSearch: true,
|
||||
})
|
||||
const viewer = fixture.debugElement.query(
|
||||
By.directive(PngxPdfViewerComponent)
|
||||
)
|
||||
expect(viewer).not.toBeNull()
|
||||
expect(viewer.componentInstance.searchQuery).toBe('test')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
import { Component, inject, Input, OnDestroy, ViewChild } from '@angular/core'
|
||||
import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { PdfViewerComponent, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { first, Subject, takeUntil } from 'rxjs'
|
||||
import { Document } from 'src/app/data/document'
|
||||
@@ -10,6 +9,8 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
|
||||
import { PdfRenderMode } from '../pdf-viewer/pdf-viewer.types'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-preview-popup',
|
||||
@@ -18,14 +19,15 @@ import { SettingsService } from 'src/app/services/settings.service'
|
||||
imports: [
|
||||
NgbPopoverModule,
|
||||
DocumentTitlePipe,
|
||||
PdfViewerModule,
|
||||
PngxPdfViewerComponent,
|
||||
SafeUrlPipe,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class PreviewPopupComponent implements OnDestroy {
|
||||
PdfRenderMode = PdfRenderMode
|
||||
private settingsService = inject(SettingsService)
|
||||
private documentService = inject(DocumentService)
|
||||
public readonly documentService = inject(DocumentService)
|
||||
private http = inject(HttpClient)
|
||||
|
||||
private _document: Document
|
||||
@@ -61,8 +63,6 @@ export class PreviewPopupComponent implements OnDestroy {
|
||||
|
||||
@ViewChild('popover') popover: NgbPopover
|
||||
|
||||
@ViewChild('pdfViewer') pdfViewer: PdfViewerComponent
|
||||
|
||||
mouseOnPreview: boolean = false
|
||||
|
||||
popoverClass: string = 'shadow popover-preview'
|
||||
@@ -114,18 +114,6 @@ export class PreviewPopupComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
onPageRendered() {
|
||||
// Only triggered by the pngx pdf viewer
|
||||
if (this.documentService.searchQuery) {
|
||||
this.pdfViewer.eventBus.dispatch('find', {
|
||||
query: this.documentService.searchQuery,
|
||||
caseSensitive: false,
|
||||
highlightAll: true,
|
||||
phraseSearch: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
mouseEnterPreview() {
|
||||
this.mouseOnPreview = true
|
||||
if (!this.popover.isOpen()) {
|
||||
|
||||
@@ -5,8 +5,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import {
|
||||
provideUiTour,
|
||||
TourNgBootstrap,
|
||||
TourService,
|
||||
} from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
@@ -75,7 +79,7 @@ describe('DashboardComponent', () => {
|
||||
imports: [
|
||||
NgbAlertModule,
|
||||
RouterTestingModule,
|
||||
TourNgBootstrapModule,
|
||||
TourNgBootstrap,
|
||||
DragDropModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
DashboardComponent,
|
||||
@@ -111,6 +115,7 @@ describe('DashboardComponent', () => {
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideUiTour(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { TourNgBootstrap, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
@@ -36,7 +36,7 @@ import { WelcomeWidgetComponent } from './widgets/welcome-widget/welcome-widget.
|
||||
WelcomeWidgetComponent,
|
||||
IfPermissionsDirective,
|
||||
DragDropModule,
|
||||
TourNgBootstrapModule,
|
||||
TourNgBootstrap,
|
||||
NgxBootstrapIconsModule,
|
||||
RouterModule,
|
||||
],
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
@@ -61,6 +62,7 @@ describe('UploadFileWidgetComponent', () => {
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideUiTour(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
NgbProgressbarModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
@@ -33,7 +33,7 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
|
||||
NgbAlertModule,
|
||||
NgbProgressbarModule,
|
||||
NgxBootstrapIconsModule,
|
||||
TourNgBootstrapModule,
|
||||
TourNgBootstrap,
|
||||
],
|
||||
})
|
||||
export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { NgbAlert, NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
|
||||
import { WelcomeWidgetComponent } from './welcome-widget.component'
|
||||
@@ -11,7 +12,7 @@ describe('WelcomeWidgetComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [PermissionsGuard],
|
||||
providers: [PermissionsGuard, provideUiTour()],
|
||||
imports: [NgbAlertModule, WelcomeWidgetComponent, WidgetFrameComponent],
|
||||
}).compileComponents()
|
||||
|
||||
|
||||
@@ -456,17 +456,15 @@
|
||||
@case (ContentRenderType.PDF) {
|
||||
@if (!useNativePdfViewer) {
|
||||
<div class="preview-sticky pdf-viewer-container">
|
||||
<pdf-viewer
|
||||
<pngx-pdf-viewer
|
||||
[src]="{ url: previewUrl, password: password }"
|
||||
[original-size]="false"
|
||||
[show-borders]="true"
|
||||
[show-all]="true"
|
||||
[renderMode]="PdfRenderMode.All"
|
||||
[(page)]="previewCurrentPage"
|
||||
[zoom-scale]="previewZoomScale"
|
||||
[zoomScale]="previewZoomScale"
|
||||
[zoom]="previewZoomSetting"
|
||||
(error)="onError($event)"
|
||||
(after-load-complete)="pdfPreviewLoaded($event)">
|
||||
</pdf-viewer>
|
||||
(loadError)="onError($event)"
|
||||
(afterLoadComplete)="pdfPreviewLoaded($event)">
|
||||
</pngx-pdf-viewer>
|
||||
</div>
|
||||
} @else {
|
||||
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
|
||||
|
||||
@@ -5,20 +5,15 @@
|
||||
}
|
||||
|
||||
.pdf-viewer-container {
|
||||
padding-top: 10px;
|
||||
padding: 8px;
|
||||
background-color: gray;
|
||||
|
||||
pdf-viewer {
|
||||
pngx-pdf-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .ng2-pdf-viewer-container .page {
|
||||
--page-margin: 0 auto 10px;
|
||||
--page-border: 0;
|
||||
}
|
||||
|
||||
.btn-group .dropdown-toggle-split {
|
||||
border-top-right-radius: inherit;
|
||||
border-bottom-right-radius: inherit;
|
||||
|
||||
@@ -69,8 +69,11 @@ import { environment } from 'src/environments/environment'
|
||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||
import {
|
||||
PdfZoomLevel,
|
||||
PdfZoomScale,
|
||||
} from '../common/pdf-viewer/pdf-viewer.types'
|
||||
import { DocumentDetailComponent } from './document-detail.component'
|
||||
import { ZoomSetting } from './zoom-setting'
|
||||
|
||||
const doc: Document = {
|
||||
id: 3,
|
||||
@@ -860,7 +863,7 @@ describe('DocumentDetailComponent', () => {
|
||||
|
||||
it('should support zoom controls', () => {
|
||||
initNormally()
|
||||
component.setZoom(ZoomSetting.One) // from select
|
||||
component.setZoom(PdfZoomLevel.One) // from select
|
||||
expect(component.previewZoomSetting).toEqual('1')
|
||||
component.increaseZoom()
|
||||
expect(component.previewZoomSetting).toEqual('1.5')
|
||||
@@ -868,18 +871,18 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(component.previewZoomSetting).toEqual('2')
|
||||
component.decreaseZoom()
|
||||
expect(component.previewZoomSetting).toEqual('1.5')
|
||||
component.setZoom(ZoomSetting.One) // from select
|
||||
component.setZoom(PdfZoomLevel.One) // from select
|
||||
component.decreaseZoom()
|
||||
expect(component.previewZoomSetting).toEqual('.75')
|
||||
|
||||
component.setZoom(ZoomSetting.PageFit) // from select
|
||||
component.setZoom(PdfZoomScale.PageFit) // from select
|
||||
expect(component.previewZoomScale).toEqual('page-fit')
|
||||
expect(component.previewZoomSetting).toEqual('1')
|
||||
component.increaseZoom()
|
||||
expect(component.previewZoomSetting).toEqual('1.5')
|
||||
expect(component.previewZoomScale).toEqual('page-width')
|
||||
|
||||
component.setZoom(ZoomSetting.PageFit) // from select
|
||||
component.setZoom(PdfZoomScale.PageFit) // from select
|
||||
expect(component.previewZoomScale).toEqual('page-fit')
|
||||
expect(component.previewZoomSetting).toEqual('1')
|
||||
component.decreaseZoom()
|
||||
@@ -889,10 +892,10 @@ describe('DocumentDetailComponent', () => {
|
||||
|
||||
it('should select correct zoom setting in dropdown', () => {
|
||||
initNormally()
|
||||
component.setZoom(ZoomSetting.PageFit)
|
||||
expect(component.currentZoom).toEqual(ZoomSetting.PageFit)
|
||||
component.setZoom(ZoomSetting.Quarter)
|
||||
expect(component.currentZoom).toEqual(ZoomSetting.Quarter)
|
||||
component.setZoom(PdfZoomScale.PageFit)
|
||||
expect(component.currentZoom).toEqual(PdfZoomScale.PageFit)
|
||||
component.setZoom(PdfZoomLevel.Quarter)
|
||||
expect(component.currentZoom).toEqual(PdfZoomLevel.Quarter)
|
||||
})
|
||||
|
||||
it('should support updating notes dynamically', () => {
|
||||
@@ -1017,7 +1020,7 @@ describe('DocumentDetailComponent', () => {
|
||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
||||
expect(component.useNativePdfViewer).toBeFalsy()
|
||||
fixture.detectChanges()
|
||||
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
|
||||
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should display native pdf viewer if enabled', () => {
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
NgbNavModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
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, timer } from 'rxjs'
|
||||
@@ -108,13 +107,19 @@ import { UrlComponent } from '../common/input/url/url.component'
|
||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
||||
import { PdfEditorEditMode } from '../common/pdf-editor/pdf-editor-edit-mode'
|
||||
import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component'
|
||||
import { PngxPdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
|
||||
import {
|
||||
PdfRenderMode,
|
||||
PdfZoomLevel,
|
||||
PdfZoomScale,
|
||||
PngxPdfDocumentProxy,
|
||||
} from '../common/pdf-viewer/pdf-viewer.types'
|
||||
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
|
||||
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
|
||||
import { DocumentHistoryComponent } from '../document-history/document-history.component'
|
||||
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
|
||||
import { ZoomSetting } from './zoom-setting'
|
||||
|
||||
enum DocumentDetailNavIDs {
|
||||
Details = 1,
|
||||
@@ -168,16 +173,17 @@ enum ContentRenderType {
|
||||
NgbNavModule,
|
||||
NgbDropdownModule,
|
||||
NgxBootstrapIconsModule,
|
||||
PdfViewerModule,
|
||||
TextAreaComponent,
|
||||
RouterModule,
|
||||
PngxPdfViewerComponent,
|
||||
],
|
||||
})
|
||||
export class DocumentDetailComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, OnDestroy, DirtyComponent
|
||||
{
|
||||
private documentsService = inject(DocumentService)
|
||||
PdfRenderMode = PdfRenderMode
|
||||
documentsService = inject(DocumentService)
|
||||
private route = inject(ActivatedRoute)
|
||||
private tagService = inject(TagService)
|
||||
private correspondentService = inject(CorrespondentService)
|
||||
@@ -246,8 +252,8 @@ export class DocumentDetailComponent
|
||||
|
||||
previewCurrentPage: number = 1
|
||||
previewNumPages: number
|
||||
previewZoomSetting: ZoomSetting = ZoomSetting.One
|
||||
previewZoomScale: ZoomSetting = ZoomSetting.PageWidth
|
||||
previewZoomSetting: PdfZoomLevel = PdfZoomLevel.One
|
||||
previewZoomScale: PdfZoomScale = PdfZoomScale.PageWidth
|
||||
|
||||
store: BehaviorSubject<any>
|
||||
isDirty$: Observable<boolean>
|
||||
@@ -503,7 +509,9 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING))
|
||||
this.setZoom(
|
||||
this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale
|
||||
)
|
||||
this.documentForm.valueChanges
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((values) => {
|
||||
@@ -1204,7 +1212,7 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
|
||||
pdfPreviewLoaded(pdf: PDFDocumentProxy) {
|
||||
pdfPreviewLoaded(pdf: PngxPdfDocumentProxy) {
|
||||
this.previewNumPages = pdf.numPages
|
||||
if (this.password) this.requiresPassword = false
|
||||
setTimeout(() => {
|
||||
@@ -1225,31 +1233,33 @@ export class DocumentDetailComponent
|
||||
}
|
||||
}
|
||||
|
||||
setZoom(setting: ZoomSetting) {
|
||||
if (ZoomSetting.PageFit === setting || ZoomSetting.PageWidth === setting) {
|
||||
setZoom(setting: PdfZoomScale | PdfZoomLevel) {
|
||||
if (
|
||||
setting === PdfZoomScale.PageFit ||
|
||||
setting === PdfZoomScale.PageWidth
|
||||
) {
|
||||
this.previewZoomScale = setting
|
||||
this.previewZoomSetting = ZoomSetting.One
|
||||
} else {
|
||||
this.previewZoomSetting = setting
|
||||
this.previewZoomScale = ZoomSetting.PageWidth
|
||||
this.previewZoomSetting = PdfZoomLevel.One
|
||||
return
|
||||
}
|
||||
this.previewZoomSetting = setting
|
||||
this.previewZoomScale = PdfZoomScale.PageWidth
|
||||
}
|
||||
|
||||
get zoomSettings() {
|
||||
return Object.values(ZoomSetting).filter(
|
||||
(setting) => setting !== ZoomSetting.PageWidth
|
||||
)
|
||||
return [PdfZoomScale.PageFit, ...Object.values(PdfZoomLevel)]
|
||||
}
|
||||
|
||||
get currentZoom() {
|
||||
if (this.previewZoomScale === ZoomSetting.PageFit) {
|
||||
return ZoomSetting.PageFit
|
||||
} else return this.previewZoomSetting
|
||||
if (this.previewZoomScale === PdfZoomScale.PageFit) {
|
||||
return PdfZoomScale.PageFit
|
||||
}
|
||||
return this.previewZoomSetting
|
||||
}
|
||||
|
||||
getZoomSettingTitle(setting: ZoomSetting): string {
|
||||
getZoomSettingTitle(setting: PdfZoomScale | PdfZoomLevel): string {
|
||||
switch (setting) {
|
||||
case ZoomSetting.PageFit:
|
||||
case PdfZoomScale.PageFit:
|
||||
return $localize`Page Fit`
|
||||
default:
|
||||
return `${parseFloat(setting) * 100}%`
|
||||
@@ -1257,25 +1267,24 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
increaseZoom(): void {
|
||||
let currentIndex = Object.values(ZoomSetting).indexOf(
|
||||
this.previewZoomSetting
|
||||
)
|
||||
if (this.previewZoomScale === ZoomSetting.PageFit) currentIndex = 5
|
||||
this.previewZoomScale = ZoomSetting.PageWidth
|
||||
const zoomLevels = Object.values(PdfZoomLevel)
|
||||
let currentIndex = zoomLevels.indexOf(this.previewZoomSetting)
|
||||
if (this.previewZoomScale === PdfZoomScale.PageFit) {
|
||||
currentIndex = zoomLevels.indexOf(PdfZoomLevel.One)
|
||||
}
|
||||
this.previewZoomScale = PdfZoomScale.PageWidth
|
||||
this.previewZoomSetting =
|
||||
Object.values(ZoomSetting)[
|
||||
Math.min(Object.values(ZoomSetting).length - 1, currentIndex + 1)
|
||||
]
|
||||
zoomLevels[Math.min(zoomLevels.length - 1, currentIndex + 1)]
|
||||
}
|
||||
|
||||
decreaseZoom(): void {
|
||||
let currentIndex = Object.values(ZoomSetting).indexOf(
|
||||
this.previewZoomSetting
|
||||
)
|
||||
if (this.previewZoomScale === ZoomSetting.PageFit) currentIndex = 4
|
||||
this.previewZoomScale = ZoomSetting.PageWidth
|
||||
this.previewZoomSetting =
|
||||
Object.values(ZoomSetting)[Math.max(2, currentIndex - 1)]
|
||||
const zoomLevels = Object.values(PdfZoomLevel)
|
||||
let currentIndex = zoomLevels.indexOf(this.previewZoomSetting)
|
||||
if (this.previewZoomScale === PdfZoomScale.PageFit) {
|
||||
currentIndex = zoomLevels.indexOf(PdfZoomLevel.ThreeQuarters)
|
||||
}
|
||||
this.previewZoomScale = PdfZoomScale.PageWidth
|
||||
this.previewZoomSetting = zoomLevels[Math.max(0, currentIndex - 1)]
|
||||
}
|
||||
|
||||
get showPermissions(): boolean {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export enum ZoomSetting {
|
||||
PageFit = 'page-fit',
|
||||
PageWidth = 'page-width',
|
||||
Quarter = '.25',
|
||||
Half = '.5',
|
||||
ThreeQuarters = '.75',
|
||||
One = '1',
|
||||
OneAndHalf = '1.5',
|
||||
Two = '2',
|
||||
Three = '3',
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
NgbModalRef,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { Subject, of, throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import {
|
||||
@@ -105,6 +106,7 @@ describe('DocumentListComponent', () => {
|
||||
PermissionsGuard,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideUiTour(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
NgbPaginationModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { filter, first, map, Subject, switchMap, takeUntil } from 'rxjs'
|
||||
import {
|
||||
DEFAULT_DISPLAY_FIELDS,
|
||||
@@ -99,7 +99,7 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi
|
||||
NgbPaginationModule,
|
||||
NgClass,
|
||||
RouterModule,
|
||||
TourNgBootstrapModule,
|
||||
TourNgBootstrap,
|
||||
],
|
||||
})
|
||||
export class DocumentListComponent
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
@@ -251,6 +252,7 @@ describe('FilterEditorComponent', () => {
|
||||
SettingsService,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideUiTour(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
NgbTypeaheadModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { Observable, Subject, from } from 'rxjs'
|
||||
import {
|
||||
catchError,
|
||||
@@ -251,7 +251,7 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
|
||||
NgbTypeaheadModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
TourNgBootstrapModule,
|
||||
TourNgBootstrap,
|
||||
],
|
||||
})
|
||||
export class FilterEditorComponent
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
<div class="row fade" [class.show]="showRules">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule) || !userCanEdit(rule)">{{rule.name}}</button></div>
|
||||
<div class="col-1 d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
|
||||
<div class="col-2 d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
|
||||
<div class="col-2 d-flex align-items-center">{{ mailAccountsById.get(rule.account)?.name }}</div>
|
||||
<div class="col-2 d-flex align-items-center d-none d-sm-flex">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input #inputField type="checkbox" class="form-check-input cursor-pointer" [id]="rule.id+'_enable'" [(ngModel)]="rule.enabled" (change)="onMailRuleEnableToggled(rule)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
@@ -37,7 +36,6 @@ import { ProcessedMailDialogComponent } from './processed-mail-dialog/processed-
|
||||
PageHeaderComponent,
|
||||
IfPermissionsDirective,
|
||||
IfOwnerDirective,
|
||||
AsyncPipe,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgbDropdownModule,
|
||||
@@ -48,8 +46,8 @@ export class MailComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
mailAccountService = inject(MailAccountService)
|
||||
mailRuleService = inject(MailRuleService)
|
||||
private readonly mailAccountService = inject(MailAccountService)
|
||||
private readonly mailRuleService = inject(MailRuleService)
|
||||
private toastService = inject(ToastService)
|
||||
private modalService = inject(NgbModal)
|
||||
permissionsService = inject(PermissionsService)
|
||||
@@ -58,8 +56,19 @@ export class MailComponent
|
||||
|
||||
public MailAccountType = MailAccountType
|
||||
|
||||
mailAccounts: MailAccount[] = []
|
||||
mailRules: MailRule[] = []
|
||||
private _mailAccounts: MailAccount[] = []
|
||||
|
||||
public get mailAccounts() {
|
||||
return this._mailAccounts
|
||||
}
|
||||
private set mailAccounts(accounts: MailAccount[]) {
|
||||
this._mailAccounts = accounts
|
||||
this.mailAccountsById = new Map(
|
||||
accounts.map((account) => [account.id, account])
|
||||
)
|
||||
}
|
||||
public mailAccountsById: Map<number, MailAccount> = new Map()
|
||||
public mailRules: MailRule[] = []
|
||||
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
oAuthAccountId: number
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions">
|
||||
<pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions" [loading]="loading">
|
||||
|
||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
||||
@@ -46,14 +46,30 @@
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md mb-2 mb-xl-0">
|
||||
<div class="col mb-2 mb-xl-0">
|
||||
<div class="form-inline d-flex align-items-center">
|
||||
<label class="text-muted me-2 mb-0" i18n>Filter by:</label>
|
||||
<input class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
<div class="col-auto mb-2 mb-xl-0">
|
||||
<div class="form-inline d-flex align-items-center">
|
||||
<div class="input-group input-group-sm w-auto d-none d-md-flex">
|
||||
<span class="input-group-text border-0" i18n>Show:</span>
|
||||
</div>
|
||||
<div class="input-group input-group-sm w-auto me-3">
|
||||
<select class="form-select form-select-sm small" [(ngModel)]="pageSize">
|
||||
<option [ngValue]="25">25</option>
|
||||
<option [ngValue]="50">50</option>
|
||||
<option [ngValue]="100">100</option>
|
||||
</select>
|
||||
<span class="input-group-text text-muted d-none d-md-flex" i18n>per page</span>
|
||||
</div>
|
||||
<ngb-pagination [pageSize]="pageSize" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card border table-responsive mb-3">
|
||||
@@ -76,7 +92,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (loading) {
|
||||
@if (loading && data.length === 0) {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
@@ -91,7 +107,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (!loading) {
|
||||
@if (!loading || data.length > 0) {
|
||||
<div class="d-flex mb-2">
|
||||
@if (collectionSize > 0) {
|
||||
<div>
|
||||
@@ -102,7 +118,7 @@
|
||||
</div>
|
||||
}
|
||||
@if (collectionSize > 20) {
|
||||
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
<ngb-pagination class="ms-auto" [pageSize]="pageSize" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -24,3 +24,7 @@ td.name-cell {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
select.small {
|
||||
font-size: 0.875rem !important; // 14px
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
MATCH_NONE,
|
||||
} from 'src/app/data/matching-model'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-filter-service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { EditDialogComponent } from '../../common/edit-dialog/edit-dialog.component'
|
||||
@@ -79,6 +81,7 @@ describe('ManagementListComponent', () => {
|
||||
let toastService: ToastService
|
||||
let documentListViewService: DocumentListViewService
|
||||
let permissionsService: PermissionsService
|
||||
let settingsService: SettingsService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -130,6 +133,7 @@ describe('ManagementListComponent', () => {
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
fixture = TestBed.createComponent(TagListComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
@@ -447,4 +451,66 @@ describe('ManagementListComponent', () => {
|
||||
).getSelectableIDs.call({}, [{ id: 1 }, { id: 5 }] as any)
|
||||
expect(ids).toEqual([1, 5])
|
||||
})
|
||||
|
||||
it('pageSize getter should return stored page size or default to 25', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue({ tags: 50 })
|
||||
component.typeNamePlural = 'tags'
|
||||
|
||||
expect(component.pageSize).toBe(50)
|
||||
})
|
||||
|
||||
it('pageSize getter should return 25 when no size is stored', () => {
|
||||
const settingsService = TestBed.inject(SettingsService)
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue({})
|
||||
component.typeNamePlural = 'tags'
|
||||
|
||||
expect(component.pageSize).toBe(25)
|
||||
})
|
||||
|
||||
it('pageSize setter should update settings, reset page and reload data on success', fakeAsync(() => {
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue({ tags: 25 })
|
||||
jest.spyOn(settingsService, 'set').mockImplementation(() => {})
|
||||
jest
|
||||
.spyOn(settingsService, 'storeSettings')
|
||||
.mockReturnValue(of({ success: true }))
|
||||
|
||||
component.typeNamePlural = 'tags'
|
||||
component.page = 2
|
||||
component.pageSize = 100
|
||||
|
||||
tick()
|
||||
|
||||
expect(settingsService.set).toHaveBeenCalledWith(
|
||||
SETTINGS_KEYS.OBJECT_LIST_SIZES,
|
||||
{ tags: 100 }
|
||||
)
|
||||
expect(component.page).toBe(1)
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
expect(toastErrorSpy).not.toHaveBeenCalled()
|
||||
}))
|
||||
|
||||
it('pageSize setter should show error toast on settings store failure', fakeAsync(() => {
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue({ tags: 25 })
|
||||
jest.spyOn(settingsService, 'set').mockImplementation(() => {})
|
||||
jest
|
||||
.spyOn(settingsService, 'storeSettings')
|
||||
.mockReturnValue(throwError(() => new Error('error storing settings')))
|
||||
|
||||
component.typeNamePlural = 'tags'
|
||||
component.pageSize = 50
|
||||
|
||||
tick()
|
||||
|
||||
expect(toastErrorSpy).toHaveBeenCalledWith(
|
||||
'Error saving settings',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
MatchingModel,
|
||||
} from 'src/app/data/matching-model'
|
||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import {
|
||||
SortableDirective,
|
||||
SortEvent,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
AbstractNameFilterService,
|
||||
BulkEditObjectOperation,
|
||||
} from 'src/app/services/rest/abstract-name-filter-service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
@@ -80,6 +82,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
public permissionType: PermissionType
|
||||
public extraColumns: ManagementListColumn[]
|
||||
|
||||
private readonly settingsService = inject(SettingsService)
|
||||
|
||||
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
|
||||
|
||||
public data: T[] = []
|
||||
@@ -160,7 +164,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
this.service
|
||||
.listFiltered(
|
||||
this.page,
|
||||
null,
|
||||
this.pageSize,
|
||||
this.sortField,
|
||||
this.sortReverse,
|
||||
this._nameFilter,
|
||||
@@ -280,6 +284,30 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
if (event.code == 'Escape') this.nameFilterDebounce.next(null)
|
||||
}
|
||||
|
||||
public get pageSize(): number {
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)[
|
||||
this.typeNamePlural
|
||||
] || 25
|
||||
)
|
||||
}
|
||||
|
||||
public set pageSize(newPageSize: number) {
|
||||
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
|
||||
[this.typeNamePlural]: newPageSize,
|
||||
})
|
||||
this.settingsService.storeSettings().subscribe({
|
||||
next: () => {
|
||||
this.page = 1
|
||||
this.reloadData()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError($localize`Error saving settings`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
userCanDelete(object: ObjectWithPermissions): boolean {
|
||||
return this.permissionsService.currentUserOwnsObject(object)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('TagListComponent', () => {
|
||||
it('should request only parent tags when no name filter is applied', () => {
|
||||
expect(tagService.listFiltered).toHaveBeenCalledWith(
|
||||
1,
|
||||
null,
|
||||
25,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -116,7 +116,7 @@ describe('TagListComponent', () => {
|
||||
component.reloadData()
|
||||
expect(tagService.listFiltered).toHaveBeenCalledWith(
|
||||
1,
|
||||
null,
|
||||
25,
|
||||
undefined,
|
||||
undefined,
|
||||
'Tag',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode'
|
||||
import { ZoomSetting } from '../components/document-detail/zoom-setting'
|
||||
import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types'
|
||||
import { User } from './user'
|
||||
|
||||
export interface UiSettings {
|
||||
@@ -63,6 +63,7 @@ export const SETTINGS_KEYS = {
|
||||
SIDEBAR_VIEWS_SHOW_COUNT:
|
||||
'general-settings:saved-views:sidebar-views-show-count',
|
||||
TOUR_COMPLETE: 'general-settings:tour-complete',
|
||||
OBJECT_LIST_SIZES: 'general-settings:object-list-sizes',
|
||||
DEFAULT_PERMS_OWNER: 'general-settings:permissions:default-owner',
|
||||
DEFAULT_PERMS_VIEW_USERS: 'general-settings:permissions:default-view-users',
|
||||
DEFAULT_PERMS_VIEW_GROUPS: 'general-settings:permissions:default-view-groups',
|
||||
@@ -201,6 +202,16 @@ export const SETTINGS: UiSetting[] = [
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.OBJECT_LIST_SIZES,
|
||||
type: 'object',
|
||||
default: {
|
||||
correspondents: 25,
|
||||
document_types: 25,
|
||||
tags: 25,
|
||||
storage_paths: 25,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DEFAULT_PERMS_OWNER,
|
||||
type: 'number',
|
||||
@@ -299,7 +310,7 @@ export const SETTINGS: UiSetting[] = [
|
||||
{
|
||||
key: SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
|
||||
type: 'string',
|
||||
default: ZoomSetting.PageWidth,
|
||||
default: PdfZoomScale.PageWidth,
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.AI_ENABLED,
|
||||
|
||||
@@ -5,6 +5,7 @@ export enum WorkflowActionType {
|
||||
Removal = 2,
|
||||
Email = 3,
|
||||
Webhook = 4,
|
||||
PasswordRemoval = 5,
|
||||
}
|
||||
|
||||
export interface WorkflowActionEmail extends ObjectWithId {
|
||||
@@ -97,4 +98,6 @@ export interface WorkflowAction extends ObjectWithId {
|
||||
email?: WorkflowActionEmail
|
||||
|
||||
webhook?: WorkflowActionWebhook
|
||||
|
||||
passwords?: string[]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TestBed } from '@angular/core/testing'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { routes } from '../app-routing.module'
|
||||
import { ConfirmDialogComponent } from '../components/common/confirm-dialog/confirm-dialog.component'
|
||||
import { DocumentListComponent } from '../components/document-list/document-list.component'
|
||||
@@ -30,6 +31,7 @@ describe('DirtySavedViewGuard', () => {
|
||||
DocumentListComponent,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
provideUiTour(),
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { ActivatedRoute, RouterState } from '@angular/router'
|
||||
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { provideUiTour, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from '../services/permissions.service'
|
||||
import { ToastService } from '../services/toast.service'
|
||||
import { PermissionsGuard } from './permissions.guard'
|
||||
@@ -45,6 +45,7 @@ describe('PermissionsGuard', () => {
|
||||
},
|
||||
TourService,
|
||||
ToastService,
|
||||
provideUiTour(),
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { map, publishReplay, refCount, tap } from 'rxjs/operators'
|
||||
import { map, shareReplay, tap } from 'rxjs/operators'
|
||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||
import { Results } from 'src/app/data/results'
|
||||
import { environment } from 'src/environments/environment'
|
||||
@@ -90,7 +90,7 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
|
||||
sortField,
|
||||
sortReverse,
|
||||
extraParams
|
||||
).pipe(publishReplay(1), refCount())
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: true }))
|
||||
}
|
||||
return this._listAll
|
||||
}
|
||||
|
||||
@@ -70,4 +70,26 @@ describe('LocalizedDateParserFormatter', () => {
|
||||
dateStr = dateParserFormatter.format(dateStruct)
|
||||
expect(dateStr).toEqual('04.05.2023')
|
||||
})
|
||||
|
||||
it('should handle years when current year % 100 < 50', () => {
|
||||
jest.useFakeTimers()
|
||||
jest.setSystemTime(new Date(2026, 5, 15))
|
||||
let val = dateParserFormatter.parse('5/4/26')
|
||||
expect(val).toEqual({ day: 4, month: 5, year: 2026 })
|
||||
|
||||
val = dateParserFormatter.parse('5/4/75')
|
||||
expect(val).toEqual({ day: 4, month: 5, year: 2075 })
|
||||
|
||||
val = dateParserFormatter.parse('5/4/99')
|
||||
expect(val).toEqual({ day: 4, month: 5, year: 1999 })
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should handle years when current year % 100 >= 50', () => {
|
||||
jest.useFakeTimers()
|
||||
jest.setSystemTime(new Date(2076, 5, 15))
|
||||
const val = dateParserFormatter.parse('5/4/00')
|
||||
expect(val).toEqual({ day: 4, month: 5, year: 2100 })
|
||||
jest.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -106,15 +106,25 @@ export class LocalizedDateParserFormatter extends NgbDateParserFormatter {
|
||||
value = this.preformatDateInput(value)
|
||||
let match = this.getDateParseRegex().exec(value)
|
||||
if (match) {
|
||||
const currentYear = new Date().getFullYear()
|
||||
const currentCentury = currentYear - (currentYear % 100)
|
||||
|
||||
let year = +match.groups.year
|
||||
if (year < 100) {
|
||||
let fourDigitYear = currentCentury + year
|
||||
// Mimic python-dateutil: keep result within -50/+49 years of current year
|
||||
if (fourDigitYear > currentYear + 49) {
|
||||
fourDigitYear -= 100
|
||||
} else if (fourDigitYear <= currentYear - 50) {
|
||||
fourDigitYear += 100
|
||||
}
|
||||
year = fourDigitYear
|
||||
}
|
||||
|
||||
let dateStruct = {
|
||||
day: +match.groups.day,
|
||||
month: +match.groups.month,
|
||||
year: +match.groups.year,
|
||||
}
|
||||
if (dateStruct.year <= new Date().getFullYear() - 2000) {
|
||||
dateStruct.year += 2000
|
||||
} else if (dateStruct.year < 100) {
|
||||
dateStruct.year += 1900
|
||||
year,
|
||||
}
|
||||
return dateStruct
|
||||
} else {
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
NgbModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { PdfViewerModule } from 'ng2-pdf-viewer'
|
||||
import {
|
||||
NgxBootstrapIconsModule,
|
||||
airplane,
|
||||
@@ -145,7 +144,6 @@ import {
|
||||
} from 'ngx-bootstrap-icons'
|
||||
import { ColorSliderModule } from 'ngx-color/slider'
|
||||
import { CookieService } from 'ngx-cookie-service'
|
||||
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { AppRoutingModule } from './app/app-routing.module'
|
||||
import { AppComponent } from './app/app.component'
|
||||
import { DirtyDocGuard } from './app/guards/dirty-doc.guard'
|
||||
@@ -195,6 +193,7 @@ import localeUk from '@angular/common/locales/uk'
|
||||
import localeVi from '@angular/common/locales/vi'
|
||||
import localeZh from '@angular/common/locales/zh'
|
||||
import localeZhHant from '@angular/common/locales/zh-Hant'
|
||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { CorrespondentNamePipe } from './app/pipes/correspondent-name.pipe'
|
||||
import { DocumentTypeNamePipe } from './app/pipes/document-type-name.pipe'
|
||||
import { StoragePathNamePipe } from './app/pipes/storage-path-name.pipe'
|
||||
@@ -371,10 +370,8 @@ bootstrapApplication(AppComponent, {
|
||||
NgbModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
PdfViewerModule,
|
||||
NgSelectModule,
|
||||
ColorSliderModule,
|
||||
TourNgBootstrapModule,
|
||||
DragDropModule,
|
||||
NgxBootstrapIconsModule.pick(icons)
|
||||
),
|
||||
@@ -397,5 +394,16 @@ bootstrapApplication(AppComponent, {
|
||||
withInterceptors([withCsrfInterceptor, withApiVersionInterceptor]),
|
||||
withFetch()
|
||||
),
|
||||
provideUiTour({
|
||||
enableBackdrop: true,
|
||||
backdropConfig: {
|
||||
offset: 10,
|
||||
},
|
||||
prevBtnTitle: $localize`Prev`,
|
||||
nextBtnTitle: $localize`Next`,
|
||||
endBtnTitle: $localize`End`,
|
||||
isOptional: true,
|
||||
useLegacyTitle: true,
|
||||
}),
|
||||
],
|
||||
}).catch((err) => console.error(err))
|
||||
|
||||
24
src-ui/src/test/mocks/pdfjs-legacy-build-pdf.ts
Normal file
24
src-ui/src/test/mocks/pdfjs-legacy-build-pdf.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export class PDFDocumentProxy {
|
||||
numPages = 1
|
||||
}
|
||||
|
||||
export class PDFDocumentLoadingTask {
|
||||
promise: Promise<PDFDocumentProxy>
|
||||
destroyed = false
|
||||
|
||||
constructor(promise: Promise<PDFDocumentProxy>) {
|
||||
this.promise = promise
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true
|
||||
}
|
||||
}
|
||||
|
||||
export const GlobalWorkerOptions = {
|
||||
workerSrc: '',
|
||||
}
|
||||
|
||||
export const getDocument = (_src: unknown): PDFDocumentLoadingTask => {
|
||||
return new PDFDocumentLoadingTask(Promise.resolve(new PDFDocumentProxy()))
|
||||
}
|
||||
79
src-ui/src/test/mocks/pdfjs-web-pdf_viewer.ts
Normal file
79
src-ui/src/test/mocks/pdfjs-web-pdf_viewer.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
type EventHandler = (event?: unknown) => void
|
||||
|
||||
export class EventBus {
|
||||
private readonly listeners = new Map<string, Set<EventHandler>>()
|
||||
|
||||
on(eventName: string, listener: EventHandler): void {
|
||||
let listeners = this.listeners.get(eventName)
|
||||
if (!listeners) {
|
||||
listeners = new Set()
|
||||
this.listeners.set(eventName, listeners)
|
||||
}
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
off(eventName: string, listener: EventHandler): void {
|
||||
this.listeners.get(eventName)?.delete(listener)
|
||||
}
|
||||
|
||||
dispatch(eventName: string, event?: unknown): void {
|
||||
this.listeners.get(eventName)?.forEach((listener) => listener(event))
|
||||
}
|
||||
}
|
||||
|
||||
export class PDFFindController {
|
||||
onIsPageVisible?: () => boolean
|
||||
}
|
||||
|
||||
export class PDFLinkService {
|
||||
private document?: unknown
|
||||
private viewer?: unknown
|
||||
|
||||
setDocument(document: unknown): void {
|
||||
this.document = document
|
||||
}
|
||||
|
||||
setViewer(viewer: unknown): void {
|
||||
this.viewer = viewer
|
||||
}
|
||||
}
|
||||
|
||||
class BaseViewer {
|
||||
pagesCount = 0
|
||||
currentScale = 1
|
||||
currentScaleValue: string | number = 1
|
||||
pagesRotation = 0
|
||||
readonly options: Record<string, unknown>
|
||||
|
||||
private readonly eventBus?: EventBus
|
||||
private _currentPageNumber = 1
|
||||
|
||||
constructor(options: { eventBus?: EventBus }) {
|
||||
this.options = options
|
||||
this.eventBus = options.eventBus
|
||||
}
|
||||
|
||||
setDocument(document: { numPages?: number } | null | undefined): void {
|
||||
this.pagesCount = document?.numPages ?? 1
|
||||
this.eventBus?.dispatch('pagesinit', {})
|
||||
this.eventBus?.dispatch('pagerendered', {
|
||||
pageNumber: this._currentPageNumber,
|
||||
})
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.pagesCount = 0
|
||||
}
|
||||
|
||||
get currentPageNumber(): number {
|
||||
return this._currentPageNumber
|
||||
}
|
||||
|
||||
set currentPageNumber(value: number) {
|
||||
this._currentPageNumber = value
|
||||
this.eventBus?.dispatch('pagechanging', { pageNumber: value })
|
||||
}
|
||||
}
|
||||
|
||||
export class PDFViewer extends BaseViewer {}
|
||||
export class PDFSinglePageViewer extends BaseViewer {}
|
||||
@@ -17,6 +17,7 @@
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
"src/**/*.d.ts",
|
||||
"src/test/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ class DocumentsConfig(AppConfig):
|
||||
|
||||
verbose_name = _("Documents")
|
||||
|
||||
def ready(self):
|
||||
def ready(self) -> None:
|
||||
from documents.signals import document_consumption_finished
|
||||
from documents.signals import document_updated
|
||||
from documents.signals.handlers import add_inbox_tags
|
||||
|
||||
@@ -16,6 +16,7 @@ from pikepdf import Pdf
|
||||
from documents.converters import convert_from_tiff_to_pdf
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.models import Document
|
||||
from documents.models import Tag
|
||||
from documents.plugins.base import ConsumeTaskPlugin
|
||||
from documents.plugins.base import StopConsumeTaskError
|
||||
@@ -129,6 +130,24 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
||||
self._tiff_conversion_done = False
|
||||
self.barcodes: list[Barcode] = []
|
||||
|
||||
def _apply_detected_asn(self, detected_asn: int) -> None:
|
||||
"""
|
||||
Apply a detected ASN to metadata if allowed.
|
||||
"""
|
||||
if (
|
||||
self.metadata.skip_asn_if_exists
|
||||
and Document.global_objects.filter(
|
||||
archive_serial_number=detected_asn,
|
||||
).exists()
|
||||
):
|
||||
logger.info(
|
||||
f"Found ASN in barcode {detected_asn} but skipping because it already exists.",
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"Found ASN in barcode: {detected_asn}")
|
||||
self.metadata.asn = detected_asn
|
||||
|
||||
def run(self) -> None:
|
||||
# Some operations may use PIL, override pixel setting if needed
|
||||
maybe_override_pixel_limit()
|
||||
@@ -206,13 +225,8 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
||||
|
||||
# Update/overwrite an ASN if possible
|
||||
# After splitting, as otherwise each split document gets the same ASN
|
||||
if (
|
||||
self.settings.barcode_enable_asn
|
||||
and not self.metadata.skip_asn
|
||||
and (located_asn := self.asn) is not None
|
||||
):
|
||||
logger.info(f"Found ASN in barcode: {located_asn}")
|
||||
self.metadata.asn = located_asn
|
||||
if self.settings.barcode_enable_asn and (located_asn := self.asn) is not None:
|
||||
self._apply_detected_asn(located_asn)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
@@ -7,7 +7,6 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Literal
|
||||
|
||||
from celery import chain
|
||||
from celery import chord
|
||||
from celery import group
|
||||
from celery import shared_task
|
||||
@@ -38,6 +37,42 @@ if TYPE_CHECKING:
|
||||
logger: logging.Logger = logging.getLogger("paperless.bulk_edit")
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def restore_archive_serial_numbers_task(
|
||||
self,
|
||||
backup: dict[int, int | None],
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
restore_archive_serial_numbers(backup)
|
||||
|
||||
|
||||
def release_archive_serial_numbers(doc_ids: list[int]) -> dict[int, int | None]:
|
||||
"""
|
||||
Clears ASNs on documents that are about to be replaced so new documents
|
||||
can be assigned ASNs without uniqueness collisions. Returns a backup map
|
||||
of doc_id -> previous ASN for potential restoration.
|
||||
"""
|
||||
qs = Document.objects.filter(
|
||||
id__in=doc_ids,
|
||||
archive_serial_number__isnull=False,
|
||||
).only("pk", "archive_serial_number")
|
||||
backup = dict(qs.values_list("pk", "archive_serial_number"))
|
||||
qs.update(archive_serial_number=None)
|
||||
logger.info(f"Released archive serial numbers for documents {list(backup.keys())}")
|
||||
return backup
|
||||
|
||||
|
||||
def restore_archive_serial_numbers(backup: dict[int, int | None]) -> None:
|
||||
"""
|
||||
Restores ASNs using the provided backup map, intended for
|
||||
rollback when replacement consumption fails.
|
||||
"""
|
||||
for doc_id, asn in backup.items():
|
||||
Document.objects.filter(pk=doc_id).update(archive_serial_number=asn)
|
||||
logger.info(f"Restored archive serial numbers for documents {list(backup.keys())}")
|
||||
|
||||
|
||||
def set_correspondent(
|
||||
doc_ids: list[int],
|
||||
correspondent: Correspondent,
|
||||
@@ -305,10 +340,10 @@ def reprocess(doc_ids: list[int]) -> Literal["OK"]:
|
||||
|
||||
def set_permissions(
|
||||
doc_ids: list[int],
|
||||
set_permissions,
|
||||
set_permissions: dict,
|
||||
*,
|
||||
owner=None,
|
||||
merge=False,
|
||||
owner: User | None = None,
|
||||
merge: bool = False,
|
||||
) -> Literal["OK"]:
|
||||
qs = Document.objects.filter(id__in=doc_ids).select_related("owner")
|
||||
|
||||
@@ -386,6 +421,7 @@ def merge(
|
||||
|
||||
merged_pdf = pikepdf.new()
|
||||
version: str = merged_pdf.pdf_version
|
||||
handoff_asn: int | None = None
|
||||
# use doc_ids to preserve order
|
||||
for doc_id in doc_ids:
|
||||
doc = qs.get(id=doc_id)
|
||||
@@ -401,6 +437,8 @@ def merge(
|
||||
version = max(version, pdf.pdf_version)
|
||||
merged_pdf.pages.extend(pdf.pages)
|
||||
affected_docs.append(doc.id)
|
||||
if handoff_asn is None and doc.archive_serial_number is not None:
|
||||
handoff_asn = doc.archive_serial_number
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
|
||||
@@ -426,6 +464,8 @@ def merge(
|
||||
DocumentMetadataOverrides.from_document(metadata_document)
|
||||
)
|
||||
overrides.title = metadata_document.title + " (merged)"
|
||||
if metadata_document.archive_serial_number is not None:
|
||||
handoff_asn = metadata_document.archive_serial_number
|
||||
else:
|
||||
overrides = DocumentMetadataOverrides()
|
||||
else:
|
||||
@@ -433,8 +473,11 @@ def merge(
|
||||
|
||||
if user is not None:
|
||||
overrides.owner_id = user.id
|
||||
# Avoid copying or detecting ASN from merged PDFs to prevent collision
|
||||
overrides.skip_asn = True
|
||||
if not delete_originals:
|
||||
overrides.skip_asn_if_exists = True
|
||||
|
||||
if delete_originals and handoff_asn is not None:
|
||||
overrides.asn = handoff_asn
|
||||
|
||||
logger.info("Adding merged document to the task queue.")
|
||||
|
||||
@@ -447,10 +490,18 @@ def merge(
|
||||
)
|
||||
|
||||
if delete_originals:
|
||||
backup = release_archive_serial_numbers(affected_docs)
|
||||
logger.info(
|
||||
"Queueing removal of original documents after consumption of merged document",
|
||||
)
|
||||
chain(consume_task, delete.si(affected_docs)).delay()
|
||||
try:
|
||||
consume_task.apply_async(
|
||||
link=[delete.si(affected_docs)],
|
||||
link_error=[restore_archive_serial_numbers_task.s(backup)],
|
||||
)
|
||||
except Exception:
|
||||
restore_archive_serial_numbers(backup)
|
||||
raise
|
||||
else:
|
||||
consume_task.delay()
|
||||
|
||||
@@ -494,6 +545,8 @@ def split(
|
||||
overrides.title = f"{doc.title} (split {idx + 1})"
|
||||
if user is not None:
|
||||
overrides.owner_id = user.id
|
||||
if not delete_originals:
|
||||
overrides.skip_asn_if_exists = True
|
||||
logger.info(
|
||||
f"Adding split document with pages {split_doc} to the task queue.",
|
||||
)
|
||||
@@ -508,10 +561,20 @@ def split(
|
||||
)
|
||||
|
||||
if delete_originals:
|
||||
backup = release_archive_serial_numbers([doc.id])
|
||||
logger.info(
|
||||
"Queueing removal of original document after consumption of the split documents",
|
||||
)
|
||||
chord(header=consume_tasks, body=delete.si([doc.id])).delay()
|
||||
try:
|
||||
chord(
|
||||
header=consume_tasks,
|
||||
body=delete.si([doc.id]),
|
||||
).apply_async(
|
||||
link_error=[restore_archive_serial_numbers_task.s(backup)],
|
||||
)
|
||||
except Exception:
|
||||
restore_archive_serial_numbers(backup)
|
||||
raise
|
||||
else:
|
||||
group(consume_tasks).delay()
|
||||
|
||||
@@ -551,7 +614,7 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["OK"]:
|
||||
|
||||
def edit_pdf(
|
||||
doc_ids: list[int],
|
||||
operations: list[dict],
|
||||
operations: list[dict[str, int]],
|
||||
*,
|
||||
delete_original: bool = False,
|
||||
update_document: bool = False,
|
||||
@@ -614,7 +677,10 @@ def edit_pdf(
|
||||
)
|
||||
if user is not None:
|
||||
overrides.owner_id = user.id
|
||||
|
||||
if not delete_original:
|
||||
overrides.skip_asn_if_exists = True
|
||||
if delete_original and len(pdf_docs) == 1:
|
||||
overrides.asn = doc.archive_serial_number
|
||||
for idx, pdf in enumerate(pdf_docs, start=1):
|
||||
filepath: Path = (
|
||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||
@@ -633,7 +699,17 @@ def edit_pdf(
|
||||
)
|
||||
|
||||
if delete_original:
|
||||
chord(header=consume_tasks, body=delete.si([doc.id])).delay()
|
||||
backup = release_archive_serial_numbers([doc.id])
|
||||
try:
|
||||
chord(
|
||||
header=consume_tasks,
|
||||
body=delete.si([doc.id]),
|
||||
).apply_async(
|
||||
link_error=[restore_archive_serial_numbers_task.s(backup)],
|
||||
)
|
||||
except Exception:
|
||||
restore_archive_serial_numbers(backup)
|
||||
raise
|
||||
else:
|
||||
group(consume_tasks).delay()
|
||||
|
||||
@@ -721,7 +797,7 @@ def reflect_doclinks(
|
||||
document: Document,
|
||||
field: CustomField,
|
||||
target_doc_ids: list[int],
|
||||
):
|
||||
) -> None:
|
||||
"""
|
||||
Add or remove 'symmetrical' links to `document` on all `target_doc_ids`
|
||||
"""
|
||||
@@ -784,7 +860,7 @@ def remove_doclink(
|
||||
document: Document,
|
||||
field: CustomField,
|
||||
target_doc_id: int,
|
||||
):
|
||||
) -> None:
|
||||
"""
|
||||
Removes a 'symmetrical' link to `document` from the target document's existing custom field instance
|
||||
"""
|
||||
|
||||
@@ -122,7 +122,7 @@ class DocumentClassifier:
|
||||
)
|
||||
self._stop_words = None
|
||||
|
||||
def _update_data_vectorizer_hash(self):
|
||||
def _update_data_vectorizer_hash(self) -> None:
|
||||
self.data_vectorizer_hash = sha256(
|
||||
pickle.dumps(self.data_vectorizer),
|
||||
).hexdigest()
|
||||
|
||||
@@ -5,6 +5,7 @@ import tempfile
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Final
|
||||
|
||||
import magic
|
||||
from django.conf import settings
|
||||
@@ -32,12 +33,12 @@ from documents.models import WorkflowTrigger
|
||||
from documents.parsers import DocumentParser
|
||||
from documents.parsers import ParseError
|
||||
from documents.parsers import get_parser_class_for_mime_type
|
||||
from documents.parsers import parse_date
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.plugins.base import AlwaysRunPluginMixin
|
||||
from documents.plugins.base import ConsumeTaskPlugin
|
||||
from documents.plugins.base import NoCleanupPluginMixin
|
||||
from documents.plugins.base import NoSetupPluginMixin
|
||||
from documents.plugins.date_parsing import get_date_parser
|
||||
from documents.plugins.helpers import ProgressManager
|
||||
from documents.plugins.helpers import ProgressStatusOptions
|
||||
from documents.signals import document_consumption_finished
|
||||
@@ -49,6 +50,8 @@ from documents.utils import copy_file_with_basic_stats
|
||||
from documents.utils import run_subprocess
|
||||
from paperless_mail.parsers import MailDocumentParser
|
||||
|
||||
LOGGING_NAME: Final[str] = "paperless.consumer"
|
||||
|
||||
|
||||
class WorkflowTriggerPlugin(
|
||||
NoCleanupPluginMixin,
|
||||
@@ -120,7 +123,7 @@ class ConsumerPluginMixin:
|
||||
status: ProgressStatusOptions,
|
||||
message: ConsumerStatusShortMessage | str | None = None,
|
||||
document_id=None,
|
||||
): # pragma: no cover
|
||||
) -> None: # pragma: no cover
|
||||
self.status_mgr.send_progress(
|
||||
status,
|
||||
message,
|
||||
@@ -156,9 +159,9 @@ class ConsumerPlugin(
|
||||
ConsumerPluginMixin,
|
||||
ConsumeTaskPlugin,
|
||||
):
|
||||
logging_name = "paperless.consumer"
|
||||
logging_name = LOGGING_NAME
|
||||
|
||||
def run_pre_consume_script(self):
|
||||
def run_pre_consume_script(self) -> None:
|
||||
"""
|
||||
If one is configured and exists, run the pre-consume script and
|
||||
handle its output and/or errors
|
||||
@@ -201,7 +204,7 @@ class ConsumerPlugin(
|
||||
exception=e,
|
||||
)
|
||||
|
||||
def run_post_consume_script(self, document: Document):
|
||||
def run_post_consume_script(self, document: Document) -> None:
|
||||
"""
|
||||
If one is configured and exists, run the pre-consume script and
|
||||
handle its output and/or errors
|
||||
@@ -361,7 +364,10 @@ class ConsumerPlugin(
|
||||
tempdir.cleanup()
|
||||
raise
|
||||
|
||||
def progress_callback(current_progress, max_progress): # pragma: no cover
|
||||
def progress_callback(
|
||||
current_progress,
|
||||
max_progress,
|
||||
) -> None: # pragma: no cover
|
||||
# recalculate progress to be within 20 and 80
|
||||
p = int((current_progress / max_progress) * 50 + 20)
|
||||
self._send_progress(p, 100, ProgressStatusOptions.WORKING)
|
||||
@@ -426,7 +432,8 @@ class ConsumerPlugin(
|
||||
ProgressStatusOptions.WORKING,
|
||||
ConsumerStatusShortMessage.PARSE_DATE,
|
||||
)
|
||||
date = parse_date(self.filename, text)
|
||||
with get_date_parser() as date_parser:
|
||||
date = next(date_parser.parse(self.filename, text), None)
|
||||
archive_path = document_parser.get_archive_path()
|
||||
page_count = document_parser.get_page_count(self.working_copy, mime_type)
|
||||
|
||||
@@ -670,7 +677,7 @@ class ConsumerPlugin(
|
||||
|
||||
return document
|
||||
|
||||
def apply_overrides(self, document):
|
||||
def apply_overrides(self, document) -> None:
|
||||
if self.metadata.correspondent_id:
|
||||
document.correspondent = Correspondent.objects.get(
|
||||
pk=self.metadata.correspondent_id,
|
||||
@@ -690,7 +697,7 @@ class ConsumerPlugin(
|
||||
pk=self.metadata.storage_path_id,
|
||||
)
|
||||
|
||||
if self.metadata.asn is not None and not self.metadata.skip_asn:
|
||||
if self.metadata.asn is not None:
|
||||
document.archive_serial_number = self.metadata.asn
|
||||
|
||||
if self.metadata.owner_id:
|
||||
@@ -730,7 +737,7 @@ class ConsumerPlugin(
|
||||
}
|
||||
CustomFieldInstance.objects.create(**args) # adds to document
|
||||
|
||||
def _write(self, source, target):
|
||||
def _write(self, source, target) -> None:
|
||||
with (
|
||||
Path(source).open("rb") as read_file,
|
||||
Path(target).open("wb") as write_file,
|
||||
@@ -753,9 +760,9 @@ class ConsumerPreflightPlugin(
|
||||
ConsumeTaskPlugin,
|
||||
):
|
||||
NAME: str = "ConsumerPreflightPlugin"
|
||||
logging_name = "paperless.consumer"
|
||||
logging_name = LOGGING_NAME
|
||||
|
||||
def pre_check_file_exists(self):
|
||||
def pre_check_file_exists(self) -> None:
|
||||
"""
|
||||
Confirm the input file still exists where it should
|
||||
"""
|
||||
@@ -769,7 +776,7 @@ class ConsumerPreflightPlugin(
|
||||
f"Cannot consume {self.input_doc.original_file}: File not found.",
|
||||
)
|
||||
|
||||
def pre_check_duplicate(self):
|
||||
def pre_check_duplicate(self) -> None:
|
||||
"""
|
||||
Using the MD5 of the file, check this exact file doesn't already exist
|
||||
"""
|
||||
@@ -819,7 +826,7 @@ class ConsumerPreflightPlugin(
|
||||
failure_msg,
|
||||
)
|
||||
|
||||
def pre_check_directories(self):
|
||||
def pre_check_directories(self) -> None:
|
||||
"""
|
||||
Ensure all required directories exist before attempting to use them
|
||||
"""
|
||||
@@ -828,12 +835,38 @@ class ConsumerPreflightPlugin(
|
||||
settings.ORIGINALS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
settings.ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def pre_check_asn_value(self):
|
||||
def run(self) -> None:
|
||||
self._send_progress(
|
||||
0,
|
||||
100,
|
||||
ProgressStatusOptions.STARTED,
|
||||
ConsumerStatusShortMessage.NEW_FILE,
|
||||
)
|
||||
|
||||
# Make sure that preconditions for consuming the file are met.
|
||||
|
||||
self.pre_check_file_exists()
|
||||
self.pre_check_duplicate()
|
||||
self.pre_check_directories()
|
||||
|
||||
|
||||
class AsnCheckPlugin(
|
||||
NoCleanupPluginMixin,
|
||||
NoSetupPluginMixin,
|
||||
AlwaysRunPluginMixin,
|
||||
LoggingMixin,
|
||||
ConsumerPluginMixin,
|
||||
ConsumeTaskPlugin,
|
||||
):
|
||||
NAME: str = "AsnCheckPlugin"
|
||||
logging_name = LOGGING_NAME
|
||||
|
||||
def pre_check_asn_value(self) -> None:
|
||||
"""
|
||||
Check that if override_asn is given, it is unique and within a valid range
|
||||
"""
|
||||
if self.metadata.skip_asn or self.metadata.asn is None:
|
||||
# if skip is set or ASN is None
|
||||
if self.metadata.asn is None:
|
||||
# if ASN is None
|
||||
return
|
||||
# Validate the range is above zero and less than uint32_t max
|
||||
# otherwise, Whoosh can't handle it in the index
|
||||
@@ -865,16 +898,4 @@ class ConsumerPreflightPlugin(
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
self._send_progress(
|
||||
0,
|
||||
100,
|
||||
ProgressStatusOptions.STARTED,
|
||||
ConsumerStatusShortMessage.NEW_FILE,
|
||||
)
|
||||
|
||||
# Make sure that preconditions for consuming the file are met.
|
||||
|
||||
self.pre_check_file_exists()
|
||||
self.pre_check_duplicate()
|
||||
self.pre_check_directories()
|
||||
self.pre_check_asn_value()
|
||||
|
||||
@@ -30,7 +30,7 @@ class DocumentMetadataOverrides:
|
||||
change_users: list[int] | None = None
|
||||
change_groups: list[int] | None = None
|
||||
custom_fields: dict | None = None
|
||||
skip_asn: bool = False
|
||||
skip_asn_if_exists: bool = False
|
||||
|
||||
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
|
||||
"""
|
||||
@@ -50,8 +50,8 @@ class DocumentMetadataOverrides:
|
||||
self.storage_path_id = other.storage_path_id
|
||||
if other.owner_id is not None:
|
||||
self.owner_id = other.owner_id
|
||||
if other.skip_asn:
|
||||
self.skip_asn = True
|
||||
if other.skip_asn_if_exists:
|
||||
self.skip_asn_if_exists = True
|
||||
|
||||
# merge
|
||||
if self.tag_ids is None:
|
||||
@@ -164,7 +164,7 @@ class ConsumableDocument:
|
||||
mailrule_id: int | None = None
|
||||
mime_type: str = dataclasses.field(init=False, default=None)
|
||||
|
||||
def __post_init__(self):
|
||||
def __post_init__(self) -> None:
|
||||
"""
|
||||
After a dataclass is initialized, this is called to finalize some data
|
||||
1. Make sure the original path is an absolute, fully qualified path
|
||||
|
||||
@@ -120,7 +120,7 @@ class StoragePathFilterSet(FilterSet):
|
||||
|
||||
|
||||
class ObjectFilter(Filter):
|
||||
def __init__(self, *, exclude=False, in_list=False, field_name=""):
|
||||
def __init__(self, *, exclude=False, in_list=False, field_name="") -> None:
|
||||
super().__init__()
|
||||
self.exclude = exclude
|
||||
self.in_list = in_list
|
||||
@@ -255,7 +255,7 @@ class MimeTypeFilter(Filter):
|
||||
|
||||
|
||||
class SelectField(serializers.CharField):
|
||||
def __init__(self, custom_field: CustomField):
|
||||
def __init__(self, custom_field: CustomField) -> None:
|
||||
self._options = custom_field.extra_data["select_options"]
|
||||
super().__init__(max_length=16)
|
||||
|
||||
@@ -676,7 +676,7 @@ class CustomFieldQueryParser:
|
||||
|
||||
@extend_schema_field(serializers.CharField)
|
||||
class CustomFieldQueryFilter(Filter):
|
||||
def __init__(self, validation_prefix):
|
||||
def __init__(self, validation_prefix) -> None:
|
||||
"""
|
||||
A filter that filters documents based on custom field name and value.
|
||||
|
||||
|
||||
@@ -414,13 +414,13 @@ class DelayedQuery:
|
||||
|
||||
|
||||
class ManualResultsPage(list):
|
||||
def __init__(self, hits):
|
||||
def __init__(self, hits) -> None:
|
||||
super().__init__(hits)
|
||||
self.results = ManualResults(hits)
|
||||
|
||||
|
||||
class ManualResults:
|
||||
def __init__(self, hits):
|
||||
def __init__(self, hits) -> None:
|
||||
self._docnums = [hit.docnum for hit in hits]
|
||||
|
||||
def docs(self):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user