mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-11 23:59:31 -06:00
Compare commits
1 Commits
feature-zx
...
feature-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa8991b4ca |
@@ -64,6 +64,8 @@ ARG RUNTIME_PACKAGES="\
|
|||||||
libmagic1 \
|
libmagic1 \
|
||||||
media-types \
|
media-types \
|
||||||
zlib1g \
|
zlib1g \
|
||||||
|
# Barcode splitter
|
||||||
|
libzbar0 \
|
||||||
poppler-utils \
|
poppler-utils \
|
||||||
htop \
|
htop \
|
||||||
sudo"
|
sudo"
|
||||||
|
|||||||
@@ -91,12 +91,12 @@ Additional tasks are available for common maintenance operations:
|
|||||||
|
|
||||||
## Committing from the Host Machine
|
## Committing from the Host Machine
|
||||||
|
|
||||||
The DevContainer automatically installs Git pre-commit hooks during setup. However, these hooks are configured for use inside the container.
|
The DevContainer automatically installs 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 prek 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 pre-commit on your host. This installs it as a standalone tool.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install prek && prek install
|
uv tool install pre-commit && pre-commit install
|
||||||
```
|
```
|
||||||
|
|
||||||
After this, you can commit either from inside the DevContainer or from your host machine.
|
After this, you can commit either from inside the DevContainer or from your host machine.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"containerEnv": {
|
"containerEnv": {
|
||||||
"UV_CACHE_DIR": "/usr/src/paperless/paperless-ngx/.uv-cache"
|
"UV_CACHE_DIR": "/usr/src/paperless/paperless-ngx/.uv-cache"
|
||||||
},
|
},
|
||||||
"postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run prek install'",
|
"postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run pre-commit install'",
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
|
|||||||
@@ -116,9 +116,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Maintenance: Build Documentation",
|
"label": "Maintenance: Build Documentation",
|
||||||
"description": "Build the documentation with Zensical",
|
"description": "Build the documentation with MkDocs",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "uv run zensical build && uv run zensical serve",
|
"command": "uv run mkdocs build --config-file mkdocs.yml && uv run mkdocs serve",
|
||||||
"group": "none",
|
"group": "none",
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"echo": true,
|
"echo": true,
|
||||||
|
|||||||
@@ -28,4 +28,3 @@
|
|||||||
./resources
|
./resources
|
||||||
# Other stuff
|
# Other stuff
|
||||||
**/*.drawio.png
|
**/*.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 included testing coverage for new code in this PR, for [backend](https://docs.paperless-ngx.com/development/#testing) and / or [front-end](https://docs.paperless-ngx.com/development/#testing-and-code-style) changes.
|
||||||
- [ ] If applicable, I have tested my code for breaking changes & regressions on both mobile & desktop devices, using the latest version of major browsers.
|
- [ ] If applicable, I have tested my code for 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).
|
- [ ] If applicable, I have checked that all tests pass, see [documentation](https://docs.paperless-ngx.com/development/#back-end-development).
|
||||||
- [ ] I have run all Git `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks).
|
- [ ] I have run all `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks).
|
||||||
- [ ] I have made corresponding changes to the documentation as needed.
|
- [ ] I have made corresponding changes to the documentation as needed.
|
||||||
- [ ] In the description of the PR above I have disclosed the use of AI tools in the coding of this PR.
|
- [ ] In the description of the PR above I have disclosed the use of AI tools in the coding of this PR.
|
||||||
|
|||||||
5
.github/dependabot.yml
vendored
5
.github/dependabot.yml
vendored
@@ -46,8 +46,8 @@ updates:
|
|||||||
patterns:
|
patterns:
|
||||||
- "*pytest*"
|
- "*pytest*"
|
||||||
- "ruff"
|
- "ruff"
|
||||||
- "zensical"
|
- "mkdocs-material"
|
||||||
- "prek*"
|
- "pre-commit*"
|
||||||
# Django & DRF Ecosystem
|
# Django & DRF Ecosystem
|
||||||
django-ecosystem:
|
django-ecosystem:
|
||||||
patterns:
|
patterns:
|
||||||
@@ -69,6 +69,7 @@ updates:
|
|||||||
patterns:
|
patterns:
|
||||||
- "ocrmypdf"
|
- "ocrmypdf"
|
||||||
- "pdf2image"
|
- "pdf2image"
|
||||||
|
- "pyzbar"
|
||||||
- "zxing-cpp"
|
- "zxing-cpp"
|
||||||
- "tika-client"
|
- "tika-client"
|
||||||
- "gotenberg-client"
|
- "gotenberg-client"
|
||||||
|
|||||||
53
.github/workflows/ci-backend.yml
vendored
53
.github/workflows/ci-backend.yml
vendored
@@ -23,7 +23,7 @@ concurrency:
|
|||||||
group: backend-${{ github.event.pull_request.number || github.ref }}
|
group: backend-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.10.x"
|
DEFAULT_UV_VERSION: "0.9.x"
|
||||||
NLTK_DATA: "/usr/share/nltk_data"
|
NLTK_DATA: "/usr/share/nltk_data"
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -qq --no-install-recommends \
|
sudo apt-get install -qq --no-install-recommends \
|
||||||
unpaper tesseract-ocr imagemagick ghostscript poppler-utils
|
unpaper tesseract-ocr imagemagick ghostscript libzbar0 poppler-utils
|
||||||
- name: Configure ImageMagick
|
- name: Configure ImageMagick
|
||||||
run: |
|
run: |
|
||||||
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml
|
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml
|
||||||
@@ -99,52 +99,3 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker compose --file docker/compose/docker-compose.ci-test.yml logs
|
docker compose --file docker/compose/docker-compose.ci-test.yml logs
|
||||||
docker compose --file docker/compose/docker-compose.ci-test.yml down
|
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
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3.12.0
|
uses: docker/setup-buildx-action@v3.12.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3.7.0
|
uses: docker/login-action@v3.6.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -180,20 +180,20 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3.12.0
|
uses: docker/setup-buildx-action@v3.12.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3.7.0
|
uses: docker/login-action@v3.6.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: needs.build-arch.outputs.push-external == 'true'
|
if: needs.build-arch.outputs.push-external == 'true'
|
||||||
uses: docker/login-action@v3.7.0
|
uses: docker/login-action@v3.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
if: needs.build-arch.outputs.push-external == 'true'
|
if: needs.build-arch.outputs.push-external == 'true'
|
||||||
uses: docker/login-action@v3.7.0
|
uses: docker/login-action@v3.6.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
|
|||||||
60
.github/workflows/ci-docs.yml
vendored
60
.github/workflows/ci-docs.yml
vendored
@@ -6,34 +6,25 @@ on:
|
|||||||
- dev
|
- dev
|
||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'zensical.toml'
|
- 'mkdocs.yml'
|
||||||
- 'pyproject.toml'
|
|
||||||
- 'uv.lock'
|
|
||||||
- '.github/workflows/ci-docs.yml'
|
- '.github/workflows/ci-docs.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'zensical.toml'
|
- 'mkdocs.yml'
|
||||||
- 'pyproject.toml'
|
|
||||||
- 'uv.lock'
|
|
||||||
- '.github/workflows/ci-docs.yml'
|
- '.github/workflows/ci-docs.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
concurrency:
|
concurrency:
|
||||||
group: docs-${{ github.event.pull_request.number || github.ref }}
|
group: docs-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.10.x"
|
DEFAULT_UV_VERSION: "0.9.x"
|
||||||
DEFAULT_PYTHON_VERSION: "3.12"
|
DEFAULT_PYTHON_VERSION: "3.11"
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build Documentation
|
name: Build Documentation
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/configure-pages@v5
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
@@ -56,23 +47,42 @@ jobs:
|
|||||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||||
--dev \
|
--dev \
|
||||||
--frozen \
|
--frozen \
|
||||||
zensical build --clean
|
mkdocs build --config-file ./mkdocs.yml
|
||||||
- name: Upload GitHub Pages artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: site
|
name: documentation
|
||||||
name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
path: site/
|
||||||
|
retention-days: 7
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy Documentation
|
name: Deploy Documentation
|
||||||
needs: build
|
needs: build
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Deploy GitHub Pages
|
- name: Checkout
|
||||||
uses: actions/deploy-pages@v4
|
uses: actions/checkout@v6
|
||||||
id: deployment
|
- name: Set up Python
|
||||||
|
id: setup-python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
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
|
||||||
|
|||||||
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 }})"
|
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
||||||
needs: install-dependencies
|
needs: install-dependencies
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
container: mcr.microsoft.com/playwright:v1.58.2-noble
|
container: mcr.microsoft.com/playwright:v1.58.1-noble
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
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 }}
|
group: lint-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
pre-commit:
|
||||||
name: Linting via prek
|
name: Pre-commit Checks
|
||||||
runs-on: ubuntu-slim
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6.0.2
|
uses: actions/checkout@v6
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v6.2.0
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.14"
|
python-version: "3.11"
|
||||||
- name: Run prek
|
- name: Run pre-commit
|
||||||
uses: j178/prek-action@v1.1.1
|
uses: pre-commit/action@v3.0.1
|
||||||
|
|||||||
10
.github/workflows/ci-release.yml
vendored
10
.github/workflows/ci-release.yml
vendored
@@ -8,15 +8,15 @@ concurrency:
|
|||||||
group: release-${{ github.ref }}
|
group: release-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.10.x"
|
DEFAULT_UV_VERSION: "0.9.x"
|
||||||
DEFAULT_PYTHON_VERSION: "3.12"
|
DEFAULT_PYTHON_VERSION: "3.11"
|
||||||
jobs:
|
jobs:
|
||||||
wait-for-docker:
|
wait-for-docker:
|
||||||
name: Wait for Docker Build
|
name: Wait for Docker Build
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Wait for Docker build
|
- name: Wait for Docker build
|
||||||
uses: lewagon/wait-on-check-action@v1.5.0
|
uses: lewagon/wait-on-check-action@v1.4.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.sha }}
|
ref: ${{ github.sha }}
|
||||||
check-name: 'Build Docker Image'
|
check-name: 'Build Docker Image'
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||||
--dev \
|
--dev \
|
||||||
--frozen \
|
--frozen \
|
||||||
zensical build --clean
|
mkdocs build --config-file ./mkdocs.yml
|
||||||
# ---- Prepare Release ----
|
# ---- Prepare Release ----
|
||||||
- name: Generate requirements file
|
- name: Generate requirements file
|
||||||
run: |
|
run: |
|
||||||
@@ -211,7 +211,7 @@ jobs:
|
|||||||
uv run \
|
uv run \
|
||||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||||
--dev \
|
--dev \
|
||||||
prek run --files changelog.md || true
|
pre-commit run --files changelog.md || true
|
||||||
|
|
||||||
git config --global user.name "github-actions"
|
git config --global user.name "github-actions"
|
||||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
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:
|
# Django stuff:
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Zensical documentation
|
# MkDocs documentation
|
||||||
site/
|
site/
|
||||||
|
|
||||||
# PyBuilder
|
# PyBuilder
|
||||||
|
|||||||
2469
.mypy-baseline.txt
2469
.mypy-baseline.txt
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
|||||||
# This file configures pre-commit hooks.
|
# This file configures pre-commit hooks.
|
||||||
# See https://pre-commit.com/ for general information
|
# See https://pre-commit.com/ for general information
|
||||||
# See https://pre-commit.com/hooks.html for a listing of possible hooks
|
# 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:
|
repos:
|
||||||
# General hooks
|
# General hooks
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
@@ -50,12 +49,12 @@ repos:
|
|||||||
- 'prettier-plugin-organize-imports@4.1.0'
|
- 'prettier-plugin-organize-imports@4.1.0'
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.15.0
|
rev: v0.14.14
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||||
rev: "v2.12.1"
|
rev: "v2.11.1"
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyproject-fmt
|
- id: pyproject-fmt
|
||||||
# Dockerfile hooks
|
# Dockerfile hooks
|
||||||
|
|||||||
17368
.pyrefly-baseline.json
17368
.pyrefly-baseline.json
File diff suppressed because one or more lines are too long
@@ -30,7 +30,7 @@ RUN set -eux \
|
|||||||
# Purpose: Installs s6-overlay and rootfs
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - Don't leave anything extra in here either
|
||||||
FROM ghcr.io/astral-sh/uv:0.10.0-python3.12-trixie-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.9.26-python3.12-trixie-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
@@ -154,6 +154,8 @@ ARG RUNTIME_PACKAGES="\
|
|||||||
libmagic1 \
|
libmagic1 \
|
||||||
media-types \
|
media-types \
|
||||||
zlib1g \
|
zlib1g \
|
||||||
|
# Barcode splitter
|
||||||
|
libzbar0 \
|
||||||
poppler-utils"
|
poppler-utils"
|
||||||
|
|
||||||
# Install basic runtime packages.
|
# Install basic runtime packages.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# correct networking for the tests
|
# correct networking for the tests
|
||||||
services:
|
services:
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.26
|
image: docker.io/gotenberg/gotenberg:8.25
|
||||||
hostname: gotenberg
|
hostname: gotenberg
|
||||||
container_name: gotenberg
|
container_name: gotenberg
|
||||||
network_mode: host
|
network_mode: host
|
||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
- "3143:3143" # IMAP
|
- "3143:3143" # IMAP
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
nginx:
|
nginx:
|
||||||
image: docker.io/nginx:1.29.5-alpine
|
image: docker.io/nginx:1.29-alpine
|
||||||
hostname: nginx
|
hostname: nginx
|
||||||
container_name: nginx
|
container_name: nginx
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.26
|
image: docker.io/gotenberg/gotenberg:8.25
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.26
|
image: docker.io/gotenberg/gotenberg:8.25
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.26
|
image: docker.io/gotenberg/gotenberg:8.25
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
|||||||
@@ -774,6 +774,7 @@ At this time, the library utilized for detection of barcodes supports the follow
|
|||||||
- QR Code
|
- QR Code
|
||||||
- SQ Code
|
- SQ Code
|
||||||
|
|
||||||
|
You may check for updates on the [zbar library homepage](https://github.com/mchehab/zbar).
|
||||||
For usage in Paperless, the type of barcode does not matter, only the contents of it.
|
For usage in Paperless, the type of barcode does not matter, only the contents of it.
|
||||||
|
|
||||||
For how to enable barcode usage, see [the configuration](configuration.md#barcodes).
|
For how to enable barcode usage, see [the configuration](configuration.md#barcodes).
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# REST API
|
# The REST API
|
||||||
|
|
||||||
Paperless-ngx now ships with a fully-documented REST API and a browsable
|
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
|
web interface to explore it. The API browsable interface is available at
|
||||||
|
|||||||
@@ -1,31 +1,13 @@
|
|||||||
:root>* {
|
:root > * {
|
||||||
--paperless-green: #17541f;
|
--md-primary-fg-color: #17541f;
|
||||||
--paperless-green-accent: #2b8a38;
|
--md-primary-fg-color--dark: #17541f;
|
||||||
--md-primary-fg-color: var(--paperless-green);
|
--md-primary-fg-color--light: #17541f;
|
||||||
--md-primary-fg-color--dark: var(--paperless-green);
|
--md-accent-fg-color: #2b8a38;
|
||||||
--md-primary-fg-color--light: var(--paperless-green-accent);
|
|
||||||
--md-accent-fg-color: var(--paperless-green-accent);
|
|
||||||
--md-typeset-a-color: #21652a;
|
--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"] {
|
[data-md-color-scheme="slate"] {
|
||||||
--md-hue: 222;
|
--md-hue: 222;
|
||||||
--md-default-bg-color: hsla(var(--md-hue), 15%, 10%, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
@@ -87,8 +69,8 @@ h4 code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Hide config vars from sidebar, toc and move the border on mobile case their hidden */
|
/* Hide config vars from sidebar, toc and move the border on mobile case their hidden */
|
||||||
.md-nav.md-nav--secondary .md-nav__item:has(> .md-nav__link[href*="PAPERLESS_"]),
|
.md-nav.md-nav--secondary .md-nav__item .md-nav__link[href*="PAPERLESS_"],
|
||||||
.md-nav.md-nav--secondary .md-nav__item:has(> .md-nav__link[href*="USERMAP_"]) {
|
.md-nav.md-nav--secondary .md-nav__item .md-nav__link[href*="USERMAP_"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,3 +83,18 @@ h4 code {
|
|||||||
border-top: .05rem solid var(--md-default-fg-color--lightest);
|
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: "/";
|
||||||
|
}
|
||||||
|
|||||||
@@ -1222,6 +1222,14 @@ using Python's `re.match()`, which anchors at the start of the filename.
|
|||||||
|
|
||||||
The default ignores are `[.stfolder, .stversions, .localized, @eaDir, .Spotlight-V100, .Trashes, __MACOSX]` and cannot be overridden.
|
The default ignores are `[.stfolder, .stversions, .localized, @eaDir, .Spotlight-V100, .Trashes, __MACOSX]` and cannot be overridden.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_CONSUMER_BARCODE_SCANNER=<string>`](#PAPERLESS_CONSUMER_BARCODE_SCANNER) {#PAPERLESS_CONSUMER_BARCODE_SCANNER}
|
||||||
|
|
||||||
|
: Sets the barcode scanner used for barcode functionality.
|
||||||
|
|
||||||
|
Currently, "PYZBAR" (the default) or "ZXING" might be selected.
|
||||||
|
If you have problems that your Barcodes/QR-Codes are not detected
|
||||||
|
(especially with bad scan quality and/or small codes), try the other one.
|
||||||
|
|
||||||
#### [`PAPERLESS_PRE_CONSUME_SCRIPT=<filename>`](#PAPERLESS_PRE_CONSUME_SCRIPT) {#PAPERLESS_PRE_CONSUME_SCRIPT}
|
#### [`PAPERLESS_PRE_CONSUME_SCRIPT=<filename>`](#PAPERLESS_PRE_CONSUME_SCRIPT) {#PAPERLESS_PRE_CONSUME_SCRIPT}
|
||||||
|
|
||||||
: After some initial validation, Paperless can trigger an arbitrary
|
: After some initial validation, Paperless can trigger an arbitrary
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ first-time setup.
|
|||||||
5. Install pre-commit hooks:
|
5. Install pre-commit hooks:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ uv run prek install
|
$ uv run pre-commit install
|
||||||
```
|
```
|
||||||
|
|
||||||
6. Apply migrations and create a superuser (also can be done via the web UI) for your development instance:
|
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
|
command such as
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ git ls-files -- '*.ts' | xargs prek run prettier --files
|
$ git ls-files -- '*.ts' | xargs pre-commit run prettier --files
|
||||||
```
|
```
|
||||||
|
|
||||||
Front end testing uses Jest and Playwright. Unit tests and e2e tests,
|
Front end testing uses Jest and Playwright. Unit tests and e2e tests,
|
||||||
@@ -338,13 +338,13 @@ LANGUAGES = [
|
|||||||
|
|
||||||
## Building the documentation
|
## Building the documentation
|
||||||
|
|
||||||
The documentation is built using Zensical, see their [documentation](https://zensical.org/docs/).
|
The documentation is built using material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/).
|
||||||
If you want to build the documentation locally, this is how you do it:
|
If you want to build the documentation locally, this is how you do it:
|
||||||
|
|
||||||
1. Build the documentation
|
1. Build the documentation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ uv run zensical build
|
$ uv run mkdocs build --config-file mkdocs.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
_alternatively..._
|
_alternatively..._
|
||||||
@@ -355,7 +355,7 @@ If you want to build the documentation locally, this is how you do it:
|
|||||||
something.
|
something.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ uv run zensical serve
|
$ uv run mkdocs serve
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building the Docker image
|
## Building the Docker image
|
||||||
@@ -481,147 +481,3 @@ To get started:
|
|||||||
|
|
||||||
5. The project is ready for debugging, start either run the fullstack debug or individual debug
|
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**
|
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,7 +1,3 @@
|
|||||||
---
|
|
||||||
title: FAQs
|
|
||||||
---
|
|
||||||
|
|
||||||
# Frequently Asked Questions
|
# Frequently Asked Questions
|
||||||
|
|
||||||
## _What's the general plan for Paperless-ngx?_
|
## _What's the general plan for Paperless-ngx?_
|
||||||
@@ -67,10 +63,8 @@ elsewhere. Here are a couple notes about that.
|
|||||||
Paperless also supports various Office documents (.docx, .doc, odt,
|
Paperless also supports various Office documents (.docx, .doc, odt,
|
||||||
.ppt, .pptx, .odp, .xls, .xlsx, .ods).
|
.ppt, .pptx, .odp, .xls, .xlsx, .ods).
|
||||||
|
|
||||||
Paperless-ngx determines the type of a file by inspecting its content
|
Paperless-ngx determines the type of a file by inspecting its content.
|
||||||
rather than its file extensions. However, files processed via the
|
The file extensions do not matter.
|
||||||
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?_
|
## _Will paperless-ngx run on Raspberry Pi?_
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
---
|
|
||||||
title: Home
|
|
||||||
---
|
|
||||||
|
|
||||||
<div class="grid-left" markdown>
|
<div class="grid-left" markdown>
|
||||||
{.index-logo}
|
{.index-logo}
|
||||||
{.index-logo}
|
{.index-logo}
|
||||||
|
|||||||
@@ -23,28 +23,3 @@ separating the directory ignore from the file ignore.
|
|||||||
Document and thumbnail encryption is no longer supported. This was previously deprecated in [paperless-ng 0.9.3](https://github.com/paperless-ngx/paperless-ngx/blob/dev/docs/changelog.md#paperless-ng-093)
|
Document and thumbnail encryption is no longer supported. This was previously deprecated in [paperless-ng 0.9.3](https://github.com/paperless-ngx/paperless-ngx/blob/dev/docs/changelog.md#paperless-ng-093)
|
||||||
|
|
||||||
Users must decrypt their document using the `decrypt_documents` command before upgrading.
|
Users must decrypt their document using the `decrypt_documents` command before upgrading.
|
||||||
|
|
||||||
## Barcode Scanner Changes
|
|
||||||
|
|
||||||
Support for [pyzbar](https://github.com/NaturalHistoryMuseum/pyzbar) has been removed. The underlying libzbar library has
|
|
||||||
seen no updates in 16 years and is largely unmaintained, and the pyzbar Python wrapper last saw a release in March 2022. In
|
|
||||||
practice, pyzbar struggled with barcode detection reliability, particularly on skewed, low-contrast, or partially
|
|
||||||
obscured barcodes. [zxing-cpp](https://github.com/zxing-cpp/zxing-cpp) is actively maintained, significantly more
|
|
||||||
reliable at finding barcodes, and now ships pre-built wheels for both x86_64 and arm64, removing the need to build the library.
|
|
||||||
|
|
||||||
The `CONSUMER_BARCODE_SCANNER` setting has been removed. zxing-cpp is now the only backend.
|
|
||||||
|
|
||||||
### Summary
|
|
||||||
|
|
||||||
| Old Setting | New Setting | Notes |
|
|
||||||
| -------------------------- | ----------- | --------------------------------- |
|
|
||||||
| `CONSUMER_BARCODE_SCANNER` | _Removed_ | zxing-cpp is now the only backend |
|
|
||||||
|
|
||||||
### Action Required
|
|
||||||
|
|
||||||
- If you were already using `CONSUMER_BARCODE_SCANNER=ZXING`, simply remove the setting.
|
|
||||||
- If you had `CONSUMER_BARCODE_SCANNER=PYZBAR` or were using the default, no functional changes are needed beyond
|
|
||||||
removing the setting. zxing-cpp supports all the same barcode formats and you should see improved detection
|
|
||||||
reliability.
|
|
||||||
- The `libzbar0` / `libzbar-dev` system packages are no longer required and can be removed from any custom Docker
|
|
||||||
images or host installations.
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
---
|
## Installation
|
||||||
title: Setup
|
|
||||||
---
|
|
||||||
|
|
||||||
# Installation
|
|
||||||
|
|
||||||
You can go multiple routes to setup and run Paperless:
|
You can go multiple routes to setup and run Paperless:
|
||||||
|
|
||||||
@@ -207,12 +203,13 @@ are released, dependency support is confirmed, etc.
|
|||||||
- `libpq-dev` for PostgreSQL
|
- `libpq-dev` for PostgreSQL
|
||||||
- `libmagic-dev` for mime type detection
|
- `libmagic-dev` for mime type detection
|
||||||
- `mariadb-client` for MariaDB compile time
|
- `mariadb-client` for MariaDB compile time
|
||||||
|
- `libzbar0` for barcode detection
|
||||||
- `poppler-utils` for barcode detection
|
- `poppler-utils` for barcode detection
|
||||||
|
|
||||||
Use this list for your preferred package management:
|
Use this list for your preferred package management:
|
||||||
|
|
||||||
```
|
```
|
||||||
python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev poppler-utils
|
python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev libzbar0 poppler-utils
|
||||||
```
|
```
|
||||||
|
|
||||||
These dependencies are required for OCRmyPDF, which is used for text
|
These dependencies are required for OCRmyPDF, which is used for text
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
---
|
# Usage Overview
|
||||||
title: Basic Usage
|
|
||||||
---
|
|
||||||
|
|
||||||
# Usage
|
|
||||||
|
|
||||||
Paperless-ngx is an application that manages your personal documents. With
|
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
|
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
|
||||||
@@ -566,8 +562,8 @@ you may want to adjust these settings to prevent abuse.
|
|||||||
|
|
||||||
#### Workflow placeholders
|
#### Workflow placeholders
|
||||||
|
|
||||||
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
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, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
|
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)
|
||||||
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
|
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
|
||||||
The template is provided as a string.
|
The template is provided as a string.
|
||||||
|
|
||||||
@@ -590,7 +586,7 @@ applied. You can use the following placeholders in the template with any trigger
|
|||||||
- `{{added_time}}`: added time in HH:MM format
|
- `{{added_time}}`: added time in HH:MM format
|
||||||
- `{{original_filename}}`: original file name without extension
|
- `{{original_filename}}`: original file name without extension
|
||||||
- `{{filename}}`: current file name without extension
|
- `{{filename}}`: current file name without extension
|
||||||
- `{{doc_title}}`: current document title (cannot be used in title assignment)
|
- `{{doc_title}}`: current document title
|
||||||
|
|
||||||
The following placeholders are only available for "added" or "updated" triggers
|
The following placeholders are only available for "added" or "updated" triggers
|
||||||
|
|
||||||
|
|||||||
87
mkdocs.yml
Normal file
87
mkdocs.yml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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.
|
# WARNING: django does not use semver.
|
||||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||||
"django~=5.2.10",
|
"django~=5.2.10",
|
||||||
"django-allauth[mfa,socialaccount]~=65.14.0",
|
"django-allauth[mfa,socialaccount]~=65.13.1",
|
||||||
"django-auditlog~=3.4.1",
|
"django-auditlog~=3.4.1",
|
||||||
"django-cachalot~=2.9.0",
|
"django-cachalot~=2.8.0",
|
||||||
"django-celery-results~=2.6.0",
|
"django-celery-results~=2.6.0",
|
||||||
"django-compression-middleware~=0.5.0",
|
"django-compression-middleware~=0.5.0",
|
||||||
"django-cors-headers~=4.9.0",
|
"django-cors-headers~=4.9.0",
|
||||||
@@ -42,7 +42,7 @@ dependencies = [
|
|||||||
"djangorestframework~=3.16",
|
"djangorestframework~=3.16",
|
||||||
"djangorestframework-guardian~=0.4.0",
|
"djangorestframework-guardian~=0.4.0",
|
||||||
"drf-spectacular~=0.28",
|
"drf-spectacular~=0.28",
|
||||||
"drf-spectacular-sidecar~=2026.1.1",
|
"drf-spectacular-sidecar~=2025.10.1",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"faiss-cpu>=1.10",
|
"faiss-cpu>=1.10",
|
||||||
"filelock~=3.20.0",
|
"filelock~=3.20.0",
|
||||||
@@ -68,6 +68,7 @@ dependencies = [
|
|||||||
"python-gnupg~=0.5.4",
|
"python-gnupg~=0.5.4",
|
||||||
"python-ipware~=3.0.0",
|
"python-ipware~=3.0.0",
|
||||||
"python-magic~=0.4.27",
|
"python-magic~=0.4.27",
|
||||||
|
"pyzbar~=0.1.9",
|
||||||
"rapidfuzz~=3.14.0",
|
"rapidfuzz~=3.14.0",
|
||||||
"redis[hiredis]~=5.2.1",
|
"redis[hiredis]~=5.2.1",
|
||||||
"regex>=2025.9.18",
|
"regex>=2025.9.18",
|
||||||
@@ -75,12 +76,12 @@ dependencies = [
|
|||||||
"sentence-transformers>=4.1",
|
"sentence-transformers>=4.1",
|
||||||
"setproctitle~=1.3.4",
|
"setproctitle~=1.3.4",
|
||||||
"tika-client~=0.10.0",
|
"tika-client~=0.10.0",
|
||||||
"torch~=2.10.0",
|
"torch~=2.9.1",
|
||||||
"tqdm~=4.67.1",
|
"tqdm~=4.67.1",
|
||||||
"watchfiles>=1.1.1",
|
"watchfiles>=1.1.1",
|
||||||
"whitenoise~=6.11",
|
"whitenoise~=6.11",
|
||||||
"whoosh-reloaded>=2.7.5",
|
"whoosh-reloaded>=2.7.5",
|
||||||
"zxing-cpp~=3.0.0",
|
"zxing-cpp~=2.3.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
optional-dependencies.mariadb = [
|
optional-dependencies.mariadb = [
|
||||||
@@ -93,7 +94,7 @@ optional-dependencies.postgres = [
|
|||||||
"psycopg-pool==3.3",
|
"psycopg-pool==3.3",
|
||||||
]
|
]
|
||||||
optional-dependencies.webserver = [
|
optional-dependencies.webserver = [
|
||||||
"granian[uvloop]~=2.7.0",
|
"granian[uvloop]~=2.6.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -105,7 +106,8 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
docs = [
|
docs = [
|
||||||
"zensical>=0.0.21",
|
"mkdocs-glightbox~=0.5.1",
|
||||||
|
"mkdocs-material~=9.7.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
testing = [
|
testing = [
|
||||||
@@ -125,8 +127,9 @@ testing = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
lint = [
|
lint = [
|
||||||
"prek~=0.3.0",
|
"pre-commit~=4.5.1",
|
||||||
"ruff~=0.15.0",
|
"pre-commit-uv~=4.2.0",
|
||||||
|
"ruff~=0.14.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
typing = [
|
typing = [
|
||||||
@@ -135,12 +138,8 @@ typing = [
|
|||||||
"django-stubs[compatible-mypy]",
|
"django-stubs[compatible-mypy]",
|
||||||
"djangorestframework-stubs[compatible-mypy]",
|
"djangorestframework-stubs[compatible-mypy]",
|
||||||
"lxml-stubs",
|
"lxml-stubs",
|
||||||
"microsoft-python-type-stubs @ git+https://github.com/microsoft/python-type-stubs.git",
|
|
||||||
"mypy",
|
"mypy",
|
||||||
"mypy-baseline",
|
|
||||||
"pyrefly",
|
|
||||||
"types-bleach",
|
"types-bleach",
|
||||||
"types-channels",
|
|
||||||
"types-colorama",
|
"types-colorama",
|
||||||
"types-dateparser",
|
"types-dateparser",
|
||||||
"types-markdown",
|
"types-markdown",
|
||||||
@@ -160,22 +159,26 @@ environments = [
|
|||||||
"sys_platform == 'linux'",
|
"sys_platform == 'linux'",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[tool.uv.index]]
|
|
||||||
name = "pytorch-cpu"
|
|
||||||
url = "https://download.pytorch.org/whl/cpu"
|
|
||||||
explicit = true
|
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
# Markers are chosen to select these almost exclusively when building the Docker image
|
# Markers are chosen to select these almost exclusively when building the Docker image
|
||||||
psycopg-c = [
|
psycopg-c = [
|
||||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
||||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
||||||
]
|
]
|
||||||
|
zxing-cpp = [
|
||||||
|
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
||||||
|
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
||||||
|
]
|
||||||
|
|
||||||
torch = [
|
torch = [
|
||||||
{ index = "pytorch-cpu" },
|
{ index = "pytorch-cpu" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[tool.uv.index]]
|
||||||
|
name = "pytorch-cpu"
|
||||||
|
url = "https://download.pytorch.org/whl/cpu"
|
||||||
|
explicit = true
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py310"
|
target-version = "py310"
|
||||||
line-length = 88
|
line-length = 88
|
||||||
@@ -303,20 +306,12 @@ markers = [
|
|||||||
"gotenberg: Tests requiring Gotenberg service",
|
"gotenberg: Tests requiring Gotenberg service",
|
||||||
"tika: Tests requiring Tika service",
|
"tika: Tests requiring Tika service",
|
||||||
"greenmail: Tests requiring Greenmail service",
|
"greenmail: Tests requiring Greenmail service",
|
||||||
"date_parsing: Tests which cover date parsing from content or filename",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pytest_env]
|
[tool.pytest_env]
|
||||||
PAPERLESS_DISABLE_DBHANDLER = "true"
|
PAPERLESS_DISABLE_DBHANDLER = "true"
|
||||||
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
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]
|
[tool.coverage.run]
|
||||||
source = [
|
source = [
|
||||||
"src/",
|
"src/",
|
||||||
@@ -328,6 +323,13 @@ omit = [
|
|||||||
"paperless/auth.py",
|
"paperless/auth.py",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_also = [
|
||||||
|
"if settings.AUDIT_LOG_ENABLED:",
|
||||||
|
"if AUDIT_LOG_ENABLED:",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
mypy_path = "src"
|
mypy_path = "src"
|
||||||
plugins = [
|
plugins = [
|
||||||
@@ -341,15 +343,5 @@ disallow_untyped_defs = true
|
|||||||
warn_redundant_casts = true
|
warn_redundant_casts = true
|
||||||
warn_unused_ignores = true
|
warn_unused_ignores = true
|
||||||
|
|
||||||
[tool.pyrefly]
|
|
||||||
search-path = [ "src" ]
|
|
||||||
baseline = ".pyrefly-baseline.json"
|
|
||||||
python-platform = "linux"
|
|
||||||
|
|
||||||
[tool.django-stubs]
|
[tool.django-stubs]
|
||||||
django_settings_module = "paperless.settings"
|
django_settings_module = "paperless.settings"
|
||||||
|
|
||||||
[tool.mypy-baseline]
|
|
||||||
baseline_path = ".mypy-baseline.txt"
|
|
||||||
sort_baseline = true
|
|
||||||
ignore_categories = [ "note" ]
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": [],
|
"scripts": [],
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
|
"ng2-pdf-viewer",
|
||||||
"file-saver",
|
"file-saver",
|
||||||
"utif"
|
"utif"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ test('should show a mobile preview', async ({ page }) => {
|
|||||||
await page.setViewportSize({ width: 400, height: 1000 })
|
await page.setViewportSize({ width: 400, height: 1000 })
|
||||||
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
|
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
|
||||||
await page.getByRole('tab', { name: 'Preview' }).click()
|
await page.getByRole('tab', { name: 'Preview' }).click()
|
||||||
await page.waitForSelector('pngx-pdf-viewer')
|
await page.waitForSelector('pdf-viewer')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should show a list of notes', async ({ page }) => {
|
test('should show a list of notes', async ({ page }) => {
|
||||||
|
|||||||
@@ -31,10 +31,6 @@ module.exports = {
|
|||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
...esmPreset.moduleNameMapper,
|
...esmPreset.moduleNameMapper,
|
||||||
'^src/(.*)': '<rootDir>/src/$1',
|
'^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',
|
workerIdleMemoryLimit: '512MB',
|
||||||
reporters: [
|
reporters: [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,15 +11,15 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^21.1.3",
|
"@angular/cdk": "^21.1.2",
|
||||||
"@angular/common": "~21.1.3",
|
"@angular/common": "~21.1.2",
|
||||||
"@angular/compiler": "~21.1.3",
|
"@angular/compiler": "~21.1.2",
|
||||||
"@angular/core": "~21.1.3",
|
"@angular/core": "~21.1.2",
|
||||||
"@angular/forms": "~21.1.3",
|
"@angular/forms": "~21.1.2",
|
||||||
"@angular/localize": "~21.1.3",
|
"@angular/localize": "~21.1.2",
|
||||||
"@angular/platform-browser": "~21.1.3",
|
"@angular/platform-browser": "~21.1.2",
|
||||||
"@angular/platform-browser-dynamic": "~21.1.3",
|
"@angular/platform-browser-dynamic": "~21.1.2",
|
||||||
"@angular/router": "~21.1.3",
|
"@angular/router": "~21.1.2",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
||||||
"@ng-select/ng-select": "^21.2.0",
|
"@ng-select/ng-select": "^21.2.0",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
@@ -27,12 +27,12 @@
|
|||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
|
"ng2-pdf-viewer": "^10.4.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^10.1.0",
|
"ngx-color": "^10.1.0",
|
||||||
"ngx-cookie-service": "^21.1.0",
|
"ngx-cookie-service": "^21.1.0",
|
||||||
"ngx-device-detector": "^11.0.0",
|
"ngx-device-detector": "^11.0.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^18.0.0",
|
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
|
||||||
"pdfjs-dist": "^5.4.624",
|
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
@@ -42,20 +42,20 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "^21.0.3",
|
"@angular-builders/custom-webpack": "^21.0.3",
|
||||||
"@angular-builders/jest": "^21.0.3",
|
"@angular-builders/jest": "^21.0.3",
|
||||||
"@angular-devkit/core": "^21.1.3",
|
"@angular-devkit/core": "^21.1.2",
|
||||||
"@angular-devkit/schematics": "^21.1.3",
|
"@angular-devkit/schematics": "^21.1.2",
|
||||||
"@angular-eslint/builder": "21.2.0",
|
"@angular-eslint/builder": "21.2.0",
|
||||||
"@angular-eslint/eslint-plugin": "21.2.0",
|
"@angular-eslint/eslint-plugin": "21.2.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "21.2.0",
|
"@angular-eslint/eslint-plugin-template": "21.2.0",
|
||||||
"@angular-eslint/schematics": "21.2.0",
|
"@angular-eslint/schematics": "21.2.0",
|
||||||
"@angular-eslint/template-parser": "21.2.0",
|
"@angular-eslint/template-parser": "21.2.0",
|
||||||
"@angular/build": "^21.1.3",
|
"@angular/build": "^21.1.2",
|
||||||
"@angular/cli": "~21.1.3",
|
"@angular/cli": "~21.1.2",
|
||||||
"@angular/compiler-cli": "~21.1.3",
|
"@angular/compiler-cli": "~21.1.2",
|
||||||
"@codecov/webpack-plugin": "^1.9.1",
|
"@codecov/webpack-plugin": "^1.9.1",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^25.2.1",
|
"@types/node": "^25.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||||
"@typescript-eslint/parser": "^8.54.0",
|
"@typescript-eslint/parser": "^8.54.0",
|
||||||
"@typescript-eslint/utils": "^8.54.0",
|
"@typescript-eslint/utils": "^8.54.0",
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"webpack": "^5.105.0"
|
"webpack": "^5.103.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.17.1",
|
"packageManager": "pnpm@10.17.1",
|
||||||
"pnpm": {
|
"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(globalThis, 'open', { value: jest.fn() })
|
Object.defineProperty(window, 'open', { value: jest.fn() })
|
||||||
Object.defineProperty(globalThis, 'localStorage', { value: mock() })
|
Object.defineProperty(window, 'localStorage', { value: mock() })
|
||||||
Object.defineProperty(globalThis, 'sessionStorage', { value: mock() })
|
Object.defineProperty(window, 'sessionStorage', { value: mock() })
|
||||||
Object.defineProperty(globalThis, 'getComputedStyle', {
|
Object.defineProperty(window, 'getComputedStyle', {
|
||||||
value: () => ['-webkit-appearance'],
|
value: () => ['-webkit-appearance'],
|
||||||
})
|
})
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
@@ -115,33 +115,13 @@ Object.defineProperty(navigator, 'canShare', { value: () => true })
|
|||||||
if (!navigator.share) {
|
if (!navigator.share) {
|
||||||
Object.defineProperty(navigator, 'share', { value: jest.fn() })
|
Object.defineProperty(navigator, 'share', { value: jest.fn() })
|
||||||
}
|
}
|
||||||
if (!globalThis.URL.createObjectURL) {
|
if (!URL.createObjectURL) {
|
||||||
Object.defineProperty(globalThis.URL, 'createObjectURL', { value: jest.fn() })
|
Object.defineProperty(window.URL, 'createObjectURL', { value: jest.fn() })
|
||||||
}
|
}
|
||||||
if (!globalThis.URL.revokeObjectURL) {
|
if (!URL.revokeObjectURL) {
|
||||||
Object.defineProperty(globalThis.URL, 'revokeObjectURL', { value: jest.fn() })
|
Object.defineProperty(window.URL, 'revokeObjectURL', { value: jest.fn() })
|
||||||
}
|
}
|
||||||
class MockResizeObserver {
|
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
|
||||||
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') {
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
class MockIntersectionObserver {
|
class MockIntersectionObserver {
|
||||||
@@ -156,7 +136,7 @@ if (typeof IntersectionObserver === 'undefined') {
|
|||||||
takeRecords = jest.fn()
|
takeRecords = jest.fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'IntersectionObserver', {
|
Object.defineProperty(window, 'IntersectionObserver', {
|
||||||
writable: true,
|
writable: true,
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: MockIntersectionObserver,
|
value: MockIntersectionObserver,
|
||||||
|
|||||||
@@ -9,11 +9,7 @@ import {
|
|||||||
import { Router, RouterModule } from '@angular/router'
|
import { Router, RouterModule } from '@angular/router'
|
||||||
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import {
|
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
provideUiTour,
|
|
||||||
TourNgBootstrap,
|
|
||||||
TourService,
|
|
||||||
} from 'ngx-ui-tour-ng-bootstrap'
|
|
||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import { routes } from './app-routing.module'
|
import { routes } from './app-routing.module'
|
||||||
import { AppComponent } from './app.component'
|
import { AppComponent } from './app.component'
|
||||||
@@ -44,12 +40,12 @@ describe('AppComponent', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
|
TourNgBootstrapModule,
|
||||||
RouterModule.forRoot(routes),
|
RouterModule.forRoot(routes),
|
||||||
NgbModalModule,
|
NgbModalModule,
|
||||||
AppComponent,
|
AppComponent,
|
||||||
ToastsComponent,
|
ToastsComponent,
|
||||||
FileDropComponent,
|
FileDropComponent,
|
||||||
TourNgBootstrap,
|
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -57,7 +53,6 @@ describe('AppComponent', () => {
|
|||||||
DirtySavedViewGuard,
|
DirtySavedViewGuard,
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideUiTour(),
|
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, inject, OnDestroy, OnInit, Renderer2 } from '@angular/core'
|
import { Component, inject, OnDestroy, OnInit, Renderer2 } from '@angular/core'
|
||||||
import { Router, RouterOutlet } from '@angular/router'
|
import { Router, RouterOutlet } from '@angular/router'
|
||||||
import { TourNgBootstrap, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { first, Subscription } from 'rxjs'
|
import { first, Subscription } from 'rxjs'
|
||||||
import { ToastsComponent } from './components/common/toasts/toasts.component'
|
import { ToastsComponent } from './components/common/toasts/toasts.component'
|
||||||
import { FileDropComponent } from './components/file-drop/file-drop.component'
|
import { FileDropComponent } from './components/file-drop/file-drop.component'
|
||||||
@@ -21,7 +21,12 @@ import { WebsocketStatusService } from './services/websocket-status.service'
|
|||||||
selector: 'pngx-root',
|
selector: 'pngx-root',
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrls: ['./app.component.scss'],
|
styleUrls: ['./app.component.scss'],
|
||||||
imports: [FileDropComponent, ToastsComponent, TourNgBootstrap, RouterOutlet],
|
imports: [
|
||||||
|
FileDropComponent,
|
||||||
|
ToastsComponent,
|
||||||
|
TourNgBootstrapModule,
|
||||||
|
RouterOutlet,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppComponent implements OnInit, OnDestroy {
|
export class AppComponent implements OnInit, OnDestroy {
|
||||||
private settings = inject(SettingsService)
|
private settings = inject(SettingsService)
|
||||||
@@ -162,91 +167,108 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
this.tourService.initialize([
|
const prevBtnTitle = $localize`Prev`
|
||||||
{
|
const nextBtnTitle = $localize`Next`
|
||||||
anchorId: 'tour.dashboard',
|
const endBtnTitle = $localize`End`
|
||||||
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.`,
|
|
||||||
route: '/dashboard',
|
this.tourService.initialize(
|
||||||
delayAfterNavigation: 500,
|
[
|
||||||
isOptional: false,
|
{
|
||||||
},
|
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.`,
|
||||||
anchorId: 'tour.upload-widget',
|
route: '/dashboard',
|
||||||
content: $localize`Drag-and-drop documents here to start uploading or place them in the consume folder. You can also drag-and-drop documents anywhere on all other pages of the web app. Once you do, Paperless-ngx will start training its machine learning algorithms.`,
|
delayAfterNavigation: 500,
|
||||||
route: '/dashboard',
|
isOptional: false,
|
||||||
},
|
|
||||||
{
|
|
||||||
anchorId: 'tour.documents',
|
|
||||||
content: $localize`The documents list shows all of your documents and allows for filtering as well as bulk-editing. There are three different view styles: list, small cards and large cards. A list of documents currently opened for editing is shown in the sidebar.`,
|
|
||||||
route: '/documents?sort=created&reverse=1&page=1',
|
|
||||||
delayAfterNavigation: 500,
|
|
||||||
placement: 'bottom',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
anchorId: 'tour.documents-filter-editor',
|
|
||||||
content: $localize`The filtering tools allow you to quickly find documents using various searches, dates, tags, etc.`,
|
|
||||||
route: '/documents?sort=created&reverse=1&page=1',
|
|
||||||
placement: 'bottom',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
anchorId: 'tour.documents-views',
|
|
||||||
content: $localize`Any combination of filters can be saved as a 'view' which can then be displayed on the dashboard and / or sidebar.`,
|
|
||||||
route: '/documents?sort=created&reverse=1&page=1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
anchorId: 'tour.tags',
|
|
||||||
content: $localize`Tags, correspondents, document types and storage paths can all be managed using these pages. They can also be created from the document edit view.`,
|
|
||||||
route: '/tags',
|
|
||||||
backdropConfig: {
|
|
||||||
offset: 0,
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
anchorId: 'tour.upload-widget',
|
||||||
anchorId: 'tour.mail',
|
content: $localize`Drag-and-drop documents here to start uploading or place them in the consume folder. You can also drag-and-drop documents anywhere on all other pages of the web app. Once you do, Paperless-ngx will start training its machine learning algorithms.`,
|
||||||
content: $localize`Manage e-mail accounts and rules for automatically importing documents.`,
|
route: '/dashboard',
|
||||||
route: '/mail',
|
|
||||||
backdropConfig: {
|
|
||||||
offset: 0,
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
anchorId: 'tour.documents',
|
||||||
anchorId: 'tour.workflows',
|
content: $localize`The documents list shows all of your documents and allows for filtering as well as bulk-editing. There are three different view styles: list, small cards and large cards. A list of documents currently opened for editing is shown in the sidebar.`,
|
||||||
content: $localize`Workflows give you more control over the document pipeline.`,
|
route: '/documents?sort=created&reverse=1&page=1',
|
||||||
route: '/workflows',
|
delayAfterNavigation: 500,
|
||||||
backdropConfig: {
|
placement: 'bottom',
|
||||||
offset: 0,
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
anchorId: 'tour.documents-filter-editor',
|
||||||
anchorId: 'tour.file-tasks',
|
content: $localize`The filtering tools allow you to quickly find documents using various searches, dates, tags, etc.`,
|
||||||
content: $localize`File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process.`,
|
route: '/documents?sort=created&reverse=1&page=1',
|
||||||
route: '/tasks',
|
placement: 'bottom',
|
||||||
backdropConfig: {
|
|
||||||
offset: 0,
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
anchorId: 'tour.documents-views',
|
||||||
anchorId: 'tour.settings',
|
content: $localize`Any combination of filters can be saved as a 'view' which can then be displayed on the dashboard and / or sidebar.`,
|
||||||
content: $localize`Check out the settings for various tweaks to the web app.`,
|
route: '/documents?sort=created&reverse=1&page=1',
|
||||||
route: '/settings',
|
|
||||||
backdropConfig: {
|
|
||||||
offset: 0,
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
anchorId: 'tour.tags',
|
||||||
anchorId: 'tour.outro',
|
content: $localize`Tags, correspondents, document types and storage paths can all be managed using these pages. They can also be created from the document edit view.`,
|
||||||
title: $localize`Thank you! 🙏`,
|
route: '/tags',
|
||||||
content:
|
backdropConfig: {
|
||||||
$localize`There are <em>tons</em> more features and info we didn't cover here, but this should get you started. Check out the documentation or visit the project on GitHub to learn more or to report issues.` +
|
offset: 0,
|
||||||
'<br/><br/>' +
|
},
|
||||||
$localize`Lastly, on behalf of every contributor to this community-supported project, thank you for using Paperless-ngx!`,
|
|
||||||
route: '/dashboard',
|
|
||||||
isOptional: false,
|
|
||||||
backdropConfig: {
|
|
||||||
offset: 0,
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
])
|
anchorId: 'tour.mail',
|
||||||
|
content: $localize`Manage e-mail accounts and rules for automatically importing documents.`,
|
||||||
|
route: '/mail',
|
||||||
|
backdropConfig: {
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchorId: 'tour.workflows',
|
||||||
|
content: $localize`Workflows give you more control over the document pipeline.`,
|
||||||
|
route: '/workflows',
|
||||||
|
backdropConfig: {
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchorId: 'tour.file-tasks',
|
||||||
|
content: $localize`File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process.`,
|
||||||
|
route: '/tasks',
|
||||||
|
backdropConfig: {
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchorId: 'tour.settings',
|
||||||
|
content: $localize`Check out the settings for various tweaks to the web app.`,
|
||||||
|
route: '/settings',
|
||||||
|
backdropConfig: {
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchorId: 'tour.outro',
|
||||||
|
title: $localize`Thank you! 🙏`,
|
||||||
|
content:
|
||||||
|
$localize`There are <em>tons</em> more features and info we didn't cover here, but this should get you started. Check out the documentation or visit the project on GitHub to learn more or to report issues.` +
|
||||||
|
'<br/><br/>' +
|
||||||
|
$localize`Lastly, on behalf of every contributor to this community-supported project, thank you for using Paperless-ngx!`,
|
||||||
|
route: '/dashboard',
|
||||||
|
isOptional: false,
|
||||||
|
backdropConfig: {
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
enableBackdrop: true,
|
||||||
|
backdropConfig: {
|
||||||
|
offset: 10,
|
||||||
|
},
|
||||||
|
prevBtnTitle,
|
||||||
|
nextBtnTitle,
|
||||||
|
endBtnTitle,
|
||||||
|
isOptional: true,
|
||||||
|
useLegacyTitle: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
this.tourService.start$.subscribe(() => {
|
this.tourService.start$.subscribe(() => {
|
||||||
this.renderer.addClass(document.body, 'tour-active')
|
this.renderer.addClass(document.body, 'tour-active')
|
||||||
|
|||||||
@@ -222,8 +222,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<select class="form-select" formControlName="pdfViewerDefaultZoom">
|
<select class="form-select" formControlName="pdfViewerDefaultZoom">
|
||||||
<option [ngValue]="PdfZoomScale.PageWidth" i18n>Fit width</option>
|
<option [ngValue]="ZoomSetting.PageWidth" i18n>Fit width</option>
|
||||||
<option [ngValue]="PdfZoomScale.PageFit" i18n>Fit page</option>
|
<option [ngValue]="ZoomSetting.PageFit" i18n>Fit page</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="small text-muted mt-1" i18n>Only applies to the Paperless-ngx PDF viewer.</p>
|
<p class="small text-muted mt-1" i18n>Only applies to the Paperless-ngx PDF viewer.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import {
|
import {
|
||||||
@@ -148,7 +147,6 @@ describe('SettingsComponent', () => {
|
|||||||
PermissionsGuard,
|
PermissionsGuard,
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideUiTour(),
|
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ import { PermissionsUserComponent } from '../../common/input/permissions/permiss
|
|||||||
import { SelectComponent } from '../../common/input/select/select.component'
|
import { SelectComponent } from '../../common/input/select/select.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { PdfEditorEditMode } from '../../common/pdf-editor/pdf-editor-edit-mode'
|
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 { 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'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
|
|
||||||
enum SettingsNavIDs {
|
enum SettingsNavIDs {
|
||||||
@@ -196,7 +196,7 @@ export class SettingsComponent
|
|||||||
|
|
||||||
public readonly GlobalSearchType = GlobalSearchType
|
public readonly GlobalSearchType = GlobalSearchType
|
||||||
|
|
||||||
public readonly PdfZoomScale = PdfZoomScale
|
public readonly ZoomSetting = ZoomSetting
|
||||||
|
|
||||||
public readonly PdfEditorEditMode = PdfEditorEditMode
|
public readonly PdfEditorEditMode = PdfEditorEditMode
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { ActivatedRoute, Router } from '@angular/router'
|
|||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
@@ -158,7 +157,6 @@ describe('AppFrameComponent', () => {
|
|||||||
PermissionsGuard,
|
PermissionsGuard,
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideUiTour(),
|
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import { first } from 'rxjs/operators'
|
import { first } from 'rxjs/operators'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
@@ -69,7 +69,7 @@ import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.compo
|
|||||||
NgbNavModule,
|
NgbNavModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrapModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppFrameComponent
|
export class AppFrameComponent
|
||||||
|
|||||||
@@ -430,24 +430,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
|||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import {
|
import {
|
||||||
FormArray,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
@@ -995,32 +994,4 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
component.removeSelectedCustomField(3, formGroup)
|
component.removeSelectedCustomField(3, formGroup)
|
||||||
expect(formGroup.get('assign_custom_fields').value).toEqual([])
|
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,10 +139,6 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
|||||||
id: WorkflowActionType.Webhook,
|
id: WorkflowActionType.Webhook,
|
||||||
name: $localize`Webhook`,
|
name: $localize`Webhook`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: WorkflowActionType.PasswordRemoval,
|
|
||||||
name: $localize`Password removal`,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export enum TriggerFilterType {
|
export enum TriggerFilterType {
|
||||||
@@ -1206,25 +1202,11 @@ export class WorkflowEditDialogComponent
|
|||||||
headers: new FormControl(action.webhook?.headers),
|
headers: new FormControl(action.webhook?.headers),
|
||||||
include_document: new FormControl(!!action.webhook?.include_document),
|
include_document: new FormControl(!!action.webhook?.include_document),
|
||||||
}),
|
}),
|
||||||
passwords: new FormControl(
|
|
||||||
this.formatPasswords(action.passwords ?? [])
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
{ emitEvent }
|
{ 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) {
|
private updateAllTriggerActionFields(emitEvent: boolean = false) {
|
||||||
this.triggerFields.clear({ emitEvent: false })
|
this.triggerFields.clear({ emitEvent: false })
|
||||||
this.object?.triggers.forEach((trigger) => {
|
this.object?.triggers.forEach((trigger) => {
|
||||||
@@ -1349,7 +1331,6 @@ export class WorkflowEditDialogComponent
|
|||||||
headers: null,
|
headers: null,
|
||||||
include_document: false,
|
include_document: false,
|
||||||
},
|
},
|
||||||
passwords: [],
|
|
||||||
}
|
}
|
||||||
this.object.actions.push(action)
|
this.object.actions.push(action)
|
||||||
this.createActionField(action)
|
this.createActionField(action)
|
||||||
@@ -1386,7 +1367,6 @@ export class WorkflowEditDialogComponent
|
|||||||
if (action.type !== WorkflowActionType.Email) {
|
if (action.type !== WorkflowActionType.Email) {
|
||||||
action.email = null
|
action.email = null
|
||||||
}
|
}
|
||||||
action.passwords = this.parsePasswords(action.passwords as any)
|
|
||||||
})
|
})
|
||||||
super.save()
|
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>
|
<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) {
|
@if (info) {
|
||||||
<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">
|
<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">
|
||||||
<i-bs name="question-circle"></i-bs>
|
<i-bs name="question-circle"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<ng-template #infoPopover>
|
<ng-template #infoPopover>
|
||||||
@@ -26,9 +26,6 @@
|
|||||||
}
|
}
|
||||||
</ng-template>
|
</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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-toolbar col col-md-auto gap-2">
|
<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 { Title } from '@angular/platform-browser'
|
||||||
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-page-header',
|
selector: 'pngx-page-header',
|
||||||
templateUrl: './page-header.component.html',
|
templateUrl: './page-header.component.html',
|
||||||
styleUrls: ['./page-header.component.scss'],
|
styleUrls: ['./page-header.component.scss'],
|
||||||
imports: [NgbPopoverModule, NgxBootstrapIconsModule, TourNgBootstrap],
|
imports: [NgbPopoverModule, NgxBootstrapIconsModule, TourNgBootstrapModule],
|
||||||
})
|
})
|
||||||
export class PageHeaderComponent {
|
export class PageHeaderComponent {
|
||||||
private titleService = inject(Title)
|
private titleService = inject(Title)
|
||||||
@@ -42,9 +42,6 @@ export class PageHeaderComponent {
|
|||||||
@Input()
|
@Input()
|
||||||
infoLink: string
|
infoLink: string
|
||||||
|
|
||||||
@Input()
|
|
||||||
loading: boolean = false
|
|
||||||
|
|
||||||
public copyID() {
|
public copyID() {
|
||||||
this.copied = this.clipboard.copy(this.id.toString())
|
this.copied = this.clipboard.copy(this.id.toString())
|
||||||
clearTimeout(this.copyTimeout)
|
clearTimeout(this.copyTimeout)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<pngx-pdf-viewer class="visually-hidden" [src]="pdfSrc" [renderMode]="PdfRenderMode.Single" [page]="1" [selectable]="false" (afterLoadComplete)="pdfLoaded($event)"></pngx-pdf-viewer>
|
<pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer>
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title">{{ title }}</h4>
|
<h4 class="modal-title">{{ title }}</h4>
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<span class="placeholder w-100 h-100"></span>
|
<span class="placeholder w-100 h-100"></span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<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>
|
<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>
|
||||||
} @placeholder {
|
} @placeholder {
|
||||||
<div class="placeholder-glow w-100 h-100 z-10">
|
<div class="placeholder-glow w-100 h-100 z-10">
|
||||||
<span class="placeholder w-100 h-100"></span>
|
<span class="placeholder w-100 h-100"></span>
|
||||||
|
|||||||
@@ -15,13 +15,13 @@
|
|||||||
background-color: gray;
|
background-color: gray;
|
||||||
height: 240px;
|
height: 240px;
|
||||||
|
|
||||||
pngx-pdf-viewer {
|
pdf-viewer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep .pngx-pdf-viewer-container {
|
::ng-deep .ng2-pdf-viewer-container {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,12 @@ import {
|
|||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
|
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'
|
import { PdfEditorEditMode } from './pdf-editor-edit-mode'
|
||||||
|
|
||||||
interface PageOperation {
|
interface PageOperation {
|
||||||
@@ -33,12 +29,11 @@ interface PageOperation {
|
|||||||
imports: [
|
imports: [
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
PdfViewerModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
PngxPdfViewerComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class PDFEditorComponent extends ConfirmDialogComponent {
|
export class PDFEditorComponent extends ConfirmDialogComponent {
|
||||||
PdfRenderMode = PdfRenderMode
|
|
||||||
public PdfEditorEditMode = PdfEditorEditMode
|
public PdfEditorEditMode = PdfEditorEditMode
|
||||||
|
|
||||||
private documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
@@ -58,7 +53,7 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
|
|||||||
return this.documentService.getPreviewUrl(this.documentID)
|
return this.documentService.getPreviewUrl(this.documentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
pdfLoaded(pdf: PngxPdfDocumentProxy) {
|
pdfLoaded(pdf: PDFDocumentProxy) {
|
||||||
this.totalPages = pdf.numPages
|
this.totalPages = pdf.numPages
|
||||||
this.pages = Array.from({ length: this.totalPages }, (_, i) => ({
|
this.pages = Array.from({ length: this.totalPages }, (_, i) => ({
|
||||||
page: i + 1,
|
page: i + 1,
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
<div #container class="pngx-pdf-viewer-container">
|
|
||||||
<div #viewer class="pdfViewer"></div>
|
|
||||||
</div>
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
: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;
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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,12 +23,14 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!requiresPassword) {
|
@if (!requiresPassword) {
|
||||||
<pngx-pdf-viewer
|
<pdf-viewer
|
||||||
[src]="previewUrl"
|
[src]="previewUrl"
|
||||||
[renderMode]="PdfRenderMode.All"
|
[original-size]="false"
|
||||||
[searchQuery]="documentService.searchQuery"
|
[show-borders]="false"
|
||||||
(loadError)="onError($event)">
|
[show-all]="true"
|
||||||
</pngx-pdf-viewer>
|
(text-layer-rendered)="onPageRendered()"
|
||||||
|
(error)="onError($event)" #pdfViewer>
|
||||||
|
</pdf-viewer>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { of, throwError } from 'rxjs'
|
|||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
|
|
||||||
import { PreviewPopupComponent } from './preview-popup.component'
|
import { PreviewPopupComponent } from './preview-popup.component'
|
||||||
|
|
||||||
const doc = {
|
const doc = {
|
||||||
@@ -79,7 +78,7 @@ describe('PreviewPopupComponent', () => {
|
|||||||
component.popover.open()
|
component.popover.open()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.debugElement.query(By.css('object'))).toBeNull()
|
expect(fixture.debugElement.query(By.css('object'))).toBeNull()
|
||||||
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
|
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show lock icon on password error', () => {
|
it('should show lock icon on password error', () => {
|
||||||
@@ -160,15 +159,23 @@ describe('PreviewPopupComponent', () => {
|
|||||||
expect(component.popover.isOpen()).toBeFalsy()
|
expect(component.popover.isOpen()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should pass searchQuery to viewer', () => {
|
it('should dispatch find event on viewer loaded if searchQuery set', () => {
|
||||||
documentService.searchQuery = 'test'
|
documentService.searchQuery = 'test'
|
||||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
||||||
component.popover.open()
|
component.popover.open()
|
||||||
|
jest.advanceTimersByTime(1000)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
const viewer = fixture.debugElement.query(
|
// normally setup by pdf-viewer
|
||||||
By.directive(PngxPdfViewerComponent)
|
jest.replaceProperty(component.pdfViewer, 'eventBus', {
|
||||||
)
|
dispatch: jest.fn(),
|
||||||
expect(viewer).not.toBeNull()
|
} as any)
|
||||||
expect(viewer.componentInstance.searchQuery).toBe('test')
|
const dispatchSpy = jest.spyOn(component.pdfViewer.eventBus, 'dispatch')
|
||||||
|
component.onPageRendered()
|
||||||
|
expect(dispatchSpy).toHaveBeenCalledWith('find', {
|
||||||
|
query: 'test',
|
||||||
|
caseSensitive: false,
|
||||||
|
highlightAll: true,
|
||||||
|
phraseSearch: true,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { HttpClient } from '@angular/common/http'
|
import { HttpClient } from '@angular/common/http'
|
||||||
import { Component, inject, Input, OnDestroy, ViewChild } from '@angular/core'
|
import { Component, inject, Input, OnDestroy, ViewChild } from '@angular/core'
|
||||||
import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { PdfViewerComponent, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { first, Subject, takeUntil } from 'rxjs'
|
import { first, Subject, takeUntil } from 'rxjs'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
@@ -9,8 +10,6 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
|||||||
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
|
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.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({
|
@Component({
|
||||||
selector: 'pngx-preview-popup',
|
selector: 'pngx-preview-popup',
|
||||||
@@ -19,15 +18,14 @@ import { PdfRenderMode } from '../pdf-viewer/pdf-viewer.types'
|
|||||||
imports: [
|
imports: [
|
||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
DocumentTitlePipe,
|
DocumentTitlePipe,
|
||||||
PngxPdfViewerComponent,
|
PdfViewerModule,
|
||||||
SafeUrlPipe,
|
SafeUrlPipe,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class PreviewPopupComponent implements OnDestroy {
|
export class PreviewPopupComponent implements OnDestroy {
|
||||||
PdfRenderMode = PdfRenderMode
|
|
||||||
private settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
public readonly documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
private http = inject(HttpClient)
|
private http = inject(HttpClient)
|
||||||
|
|
||||||
private _document: Document
|
private _document: Document
|
||||||
@@ -63,6 +61,8 @@ export class PreviewPopupComponent implements OnDestroy {
|
|||||||
|
|
||||||
@ViewChild('popover') popover: NgbPopover
|
@ViewChild('popover') popover: NgbPopover
|
||||||
|
|
||||||
|
@ViewChild('pdfViewer') pdfViewer: PdfViewerComponent
|
||||||
|
|
||||||
mouseOnPreview: boolean = false
|
mouseOnPreview: boolean = false
|
||||||
|
|
||||||
popoverClass: string = 'shadow popover-preview'
|
popoverClass: string = 'shadow popover-preview'
|
||||||
@@ -114,6 +114,18 @@ 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() {
|
mouseEnterPreview() {
|
||||||
this.mouseOnPreview = true
|
this.mouseOnPreview = true
|
||||||
if (!this.popover.isOpen()) {
|
if (!this.popover.isOpen()) {
|
||||||
|
|||||||
@@ -5,12 +5,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
|
|||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import {
|
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
provideUiTour,
|
|
||||||
TourNgBootstrap,
|
|
||||||
TourService,
|
|
||||||
} from 'ngx-ui-tour-ng-bootstrap'
|
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
@@ -79,7 +75,7 @@ describe('DashboardComponent', () => {
|
|||||||
imports: [
|
imports: [
|
||||||
NgbAlertModule,
|
NgbAlertModule,
|
||||||
RouterTestingModule,
|
RouterTestingModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrapModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
DashboardComponent,
|
DashboardComponent,
|
||||||
@@ -115,7 +111,6 @@ describe('DashboardComponent', () => {
|
|||||||
},
|
},
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideUiTour(),
|
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { RouterModule } from '@angular/router'
|
import { RouterModule } from '@angular/router'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { TourNgBootstrap, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
@@ -36,7 +36,7 @@ import { WelcomeWidgetComponent } from './widgets/welcome-widget/welcome-widget.
|
|||||||
WelcomeWidgetComponent,
|
WelcomeWidgetComponent,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrapModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
@@ -62,7 +61,6 @@ describe('UploadFileWidgetComponent', () => {
|
|||||||
},
|
},
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideUiTour(),
|
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
NgbProgressbarModule,
|
NgbProgressbarModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
@@ -33,7 +33,7 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
|
|||||||
NgbAlertModule,
|
NgbAlertModule,
|
||||||
NgbProgressbarModule,
|
NgbProgressbarModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrapModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
import { NgbAlert, NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
|
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 { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
|
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
|
||||||
import { WelcomeWidgetComponent } from './welcome-widget.component'
|
import { WelcomeWidgetComponent } from './welcome-widget.component'
|
||||||
@@ -12,7 +11,7 @@ describe('WelcomeWidgetComponent', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [PermissionsGuard, provideUiTour()],
|
providers: [PermissionsGuard],
|
||||||
imports: [NgbAlertModule, WelcomeWidgetComponent, WidgetFrameComponent],
|
imports: [NgbAlertModule, WelcomeWidgetComponent, WidgetFrameComponent],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
|||||||
@@ -456,15 +456,17 @@
|
|||||||
@case (ContentRenderType.PDF) {
|
@case (ContentRenderType.PDF) {
|
||||||
@if (!useNativePdfViewer) {
|
@if (!useNativePdfViewer) {
|
||||||
<div class="preview-sticky pdf-viewer-container">
|
<div class="preview-sticky pdf-viewer-container">
|
||||||
<pngx-pdf-viewer
|
<pdf-viewer
|
||||||
[src]="{ url: previewUrl, password: password }"
|
[src]="{ url: previewUrl, password: password }"
|
||||||
[renderMode]="PdfRenderMode.All"
|
[original-size]="false"
|
||||||
|
[show-borders]="true"
|
||||||
|
[show-all]="true"
|
||||||
[(page)]="previewCurrentPage"
|
[(page)]="previewCurrentPage"
|
||||||
[zoomScale]="previewZoomScale"
|
[zoom-scale]="previewZoomScale"
|
||||||
[zoom]="previewZoomSetting"
|
[zoom]="previewZoomSetting"
|
||||||
(loadError)="onError($event)"
|
(error)="onError($event)"
|
||||||
(afterLoadComplete)="pdfPreviewLoaded($event)">
|
(after-load-complete)="pdfPreviewLoaded($event)">
|
||||||
</pngx-pdf-viewer>
|
</pdf-viewer>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
|
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
|
||||||
|
|||||||
@@ -5,15 +5,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pdf-viewer-container {
|
.pdf-viewer-container {
|
||||||
padding: 8px;
|
padding-top: 10px;
|
||||||
background-color: gray;
|
background-color: gray;
|
||||||
|
|
||||||
pngx-pdf-viewer {
|
pdf-viewer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::ng-deep .ng2-pdf-viewer-container .page {
|
||||||
|
--page-margin: 0 auto 10px;
|
||||||
|
--page-border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-group .dropdown-toggle-split {
|
.btn-group .dropdown-toggle-split {
|
||||||
border-top-right-radius: inherit;
|
border-top-right-radius: inherit;
|
||||||
border-bottom-right-radius: inherit;
|
border-bottom-right-radius: inherit;
|
||||||
|
|||||||
@@ -69,11 +69,8 @@ import { environment } from 'src/environments/environment'
|
|||||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
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 { 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 { 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 { DocumentDetailComponent } from './document-detail.component'
|
||||||
|
import { ZoomSetting } from './zoom-setting'
|
||||||
|
|
||||||
const doc: Document = {
|
const doc: Document = {
|
||||||
id: 3,
|
id: 3,
|
||||||
@@ -863,7 +860,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
it('should support zoom controls', () => {
|
it('should support zoom controls', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.setZoom(PdfZoomLevel.One) // from select
|
component.setZoom(ZoomSetting.One) // from select
|
||||||
expect(component.previewZoomSetting).toEqual('1')
|
expect(component.previewZoomSetting).toEqual('1')
|
||||||
component.increaseZoom()
|
component.increaseZoom()
|
||||||
expect(component.previewZoomSetting).toEqual('1.5')
|
expect(component.previewZoomSetting).toEqual('1.5')
|
||||||
@@ -871,18 +868,18 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(component.previewZoomSetting).toEqual('2')
|
expect(component.previewZoomSetting).toEqual('2')
|
||||||
component.decreaseZoom()
|
component.decreaseZoom()
|
||||||
expect(component.previewZoomSetting).toEqual('1.5')
|
expect(component.previewZoomSetting).toEqual('1.5')
|
||||||
component.setZoom(PdfZoomLevel.One) // from select
|
component.setZoom(ZoomSetting.One) // from select
|
||||||
component.decreaseZoom()
|
component.decreaseZoom()
|
||||||
expect(component.previewZoomSetting).toEqual('.75')
|
expect(component.previewZoomSetting).toEqual('.75')
|
||||||
|
|
||||||
component.setZoom(PdfZoomScale.PageFit) // from select
|
component.setZoom(ZoomSetting.PageFit) // from select
|
||||||
expect(component.previewZoomScale).toEqual('page-fit')
|
expect(component.previewZoomScale).toEqual('page-fit')
|
||||||
expect(component.previewZoomSetting).toEqual('1')
|
expect(component.previewZoomSetting).toEqual('1')
|
||||||
component.increaseZoom()
|
component.increaseZoom()
|
||||||
expect(component.previewZoomSetting).toEqual('1.5')
|
expect(component.previewZoomSetting).toEqual('1.5')
|
||||||
expect(component.previewZoomScale).toEqual('page-width')
|
expect(component.previewZoomScale).toEqual('page-width')
|
||||||
|
|
||||||
component.setZoom(PdfZoomScale.PageFit) // from select
|
component.setZoom(ZoomSetting.PageFit) // from select
|
||||||
expect(component.previewZoomScale).toEqual('page-fit')
|
expect(component.previewZoomScale).toEqual('page-fit')
|
||||||
expect(component.previewZoomSetting).toEqual('1')
|
expect(component.previewZoomSetting).toEqual('1')
|
||||||
component.decreaseZoom()
|
component.decreaseZoom()
|
||||||
@@ -892,10 +889,10 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
it('should select correct zoom setting in dropdown', () => {
|
it('should select correct zoom setting in dropdown', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.setZoom(PdfZoomScale.PageFit)
|
component.setZoom(ZoomSetting.PageFit)
|
||||||
expect(component.currentZoom).toEqual(PdfZoomScale.PageFit)
|
expect(component.currentZoom).toEqual(ZoomSetting.PageFit)
|
||||||
component.setZoom(PdfZoomLevel.Quarter)
|
component.setZoom(ZoomSetting.Quarter)
|
||||||
expect(component.currentZoom).toEqual(PdfZoomLevel.Quarter)
|
expect(component.currentZoom).toEqual(ZoomSetting.Quarter)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support updating notes dynamically', () => {
|
it('should support updating notes dynamically', () => {
|
||||||
@@ -1020,7 +1017,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
||||||
expect(component.useNativePdfViewer).toBeFalsy()
|
expect(component.useNativePdfViewer).toBeFalsy()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
|
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display native pdf viewer if enabled', () => {
|
it('should display native pdf viewer if enabled', () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
NgbNavModule,
|
NgbNavModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
|
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
|
||||||
|
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector'
|
import { DeviceDetectorService } from 'ngx-device-detector'
|
||||||
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
|
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
|
||||||
@@ -107,19 +108,13 @@ import { UrlComponent } from '../common/input/url/url.component'
|
|||||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
||||||
import { PdfEditorEditMode } from '../common/pdf-editor/pdf-editor-edit-mode'
|
import { PdfEditorEditMode } from '../common/pdf-editor/pdf-editor-edit-mode'
|
||||||
import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component'
|
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 { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
|
||||||
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
|
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
|
||||||
import { DocumentHistoryComponent } from '../document-history/document-history.component'
|
import { DocumentHistoryComponent } from '../document-history/document-history.component'
|
||||||
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
|
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
|
||||||
|
import { ZoomSetting } from './zoom-setting'
|
||||||
|
|
||||||
enum DocumentDetailNavIDs {
|
enum DocumentDetailNavIDs {
|
||||||
Details = 1,
|
Details = 1,
|
||||||
@@ -173,17 +168,16 @@ enum ContentRenderType {
|
|||||||
NgbNavModule,
|
NgbNavModule,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
PdfViewerModule,
|
||||||
TextAreaComponent,
|
TextAreaComponent,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
PngxPdfViewerComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentDetailComponent
|
export class DocumentDetailComponent
|
||||||
extends ComponentWithPermissions
|
extends ComponentWithPermissions
|
||||||
implements OnInit, OnDestroy, DirtyComponent
|
implements OnInit, OnDestroy, DirtyComponent
|
||||||
{
|
{
|
||||||
PdfRenderMode = PdfRenderMode
|
private documentsService = inject(DocumentService)
|
||||||
documentsService = inject(DocumentService)
|
|
||||||
private route = inject(ActivatedRoute)
|
private route = inject(ActivatedRoute)
|
||||||
private tagService = inject(TagService)
|
private tagService = inject(TagService)
|
||||||
private correspondentService = inject(CorrespondentService)
|
private correspondentService = inject(CorrespondentService)
|
||||||
@@ -252,8 +246,8 @@ export class DocumentDetailComponent
|
|||||||
|
|
||||||
previewCurrentPage: number = 1
|
previewCurrentPage: number = 1
|
||||||
previewNumPages: number
|
previewNumPages: number
|
||||||
previewZoomSetting: PdfZoomLevel = PdfZoomLevel.One
|
previewZoomSetting: ZoomSetting = ZoomSetting.One
|
||||||
previewZoomScale: PdfZoomScale = PdfZoomScale.PageWidth
|
previewZoomScale: ZoomSetting = ZoomSetting.PageWidth
|
||||||
|
|
||||||
store: BehaviorSubject<any>
|
store: BehaviorSubject<any>
|
||||||
isDirty$: Observable<boolean>
|
isDirty$: Observable<boolean>
|
||||||
@@ -509,9 +503,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.setZoom(
|
this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING))
|
||||||
this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale
|
|
||||||
)
|
|
||||||
this.documentForm.valueChanges
|
this.documentForm.valueChanges
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((values) => {
|
.subscribe((values) => {
|
||||||
@@ -1212,7 +1204,7 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pdfPreviewLoaded(pdf: PngxPdfDocumentProxy) {
|
pdfPreviewLoaded(pdf: PDFDocumentProxy) {
|
||||||
this.previewNumPages = pdf.numPages
|
this.previewNumPages = pdf.numPages
|
||||||
if (this.password) this.requiresPassword = false
|
if (this.password) this.requiresPassword = false
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1233,33 +1225,31 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setZoom(setting: PdfZoomScale | PdfZoomLevel) {
|
setZoom(setting: ZoomSetting) {
|
||||||
if (
|
if (ZoomSetting.PageFit === setting || ZoomSetting.PageWidth === setting) {
|
||||||
setting === PdfZoomScale.PageFit ||
|
|
||||||
setting === PdfZoomScale.PageWidth
|
|
||||||
) {
|
|
||||||
this.previewZoomScale = setting
|
this.previewZoomScale = setting
|
||||||
this.previewZoomSetting = PdfZoomLevel.One
|
this.previewZoomSetting = ZoomSetting.One
|
||||||
return
|
} else {
|
||||||
|
this.previewZoomSetting = setting
|
||||||
|
this.previewZoomScale = ZoomSetting.PageWidth
|
||||||
}
|
}
|
||||||
this.previewZoomSetting = setting
|
|
||||||
this.previewZoomScale = PdfZoomScale.PageWidth
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get zoomSettings() {
|
get zoomSettings() {
|
||||||
return [PdfZoomScale.PageFit, ...Object.values(PdfZoomLevel)]
|
return Object.values(ZoomSetting).filter(
|
||||||
|
(setting) => setting !== ZoomSetting.PageWidth
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get currentZoom() {
|
get currentZoom() {
|
||||||
if (this.previewZoomScale === PdfZoomScale.PageFit) {
|
if (this.previewZoomScale === ZoomSetting.PageFit) {
|
||||||
return PdfZoomScale.PageFit
|
return ZoomSetting.PageFit
|
||||||
}
|
} else return this.previewZoomSetting
|
||||||
return this.previewZoomSetting
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getZoomSettingTitle(setting: PdfZoomScale | PdfZoomLevel): string {
|
getZoomSettingTitle(setting: ZoomSetting): string {
|
||||||
switch (setting) {
|
switch (setting) {
|
||||||
case PdfZoomScale.PageFit:
|
case ZoomSetting.PageFit:
|
||||||
return $localize`Page Fit`
|
return $localize`Page Fit`
|
||||||
default:
|
default:
|
||||||
return `${parseFloat(setting) * 100}%`
|
return `${parseFloat(setting) * 100}%`
|
||||||
@@ -1267,24 +1257,25 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
increaseZoom(): void {
|
increaseZoom(): void {
|
||||||
const zoomLevels = Object.values(PdfZoomLevel)
|
let currentIndex = Object.values(ZoomSetting).indexOf(
|
||||||
let currentIndex = zoomLevels.indexOf(this.previewZoomSetting)
|
this.previewZoomSetting
|
||||||
if (this.previewZoomScale === PdfZoomScale.PageFit) {
|
)
|
||||||
currentIndex = zoomLevels.indexOf(PdfZoomLevel.One)
|
if (this.previewZoomScale === ZoomSetting.PageFit) currentIndex = 5
|
||||||
}
|
this.previewZoomScale = ZoomSetting.PageWidth
|
||||||
this.previewZoomScale = PdfZoomScale.PageWidth
|
|
||||||
this.previewZoomSetting =
|
this.previewZoomSetting =
|
||||||
zoomLevels[Math.min(zoomLevels.length - 1, currentIndex + 1)]
|
Object.values(ZoomSetting)[
|
||||||
|
Math.min(Object.values(ZoomSetting).length - 1, currentIndex + 1)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
decreaseZoom(): void {
|
decreaseZoom(): void {
|
||||||
const zoomLevels = Object.values(PdfZoomLevel)
|
let currentIndex = Object.values(ZoomSetting).indexOf(
|
||||||
let currentIndex = zoomLevels.indexOf(this.previewZoomSetting)
|
this.previewZoomSetting
|
||||||
if (this.previewZoomScale === PdfZoomScale.PageFit) {
|
)
|
||||||
currentIndex = zoomLevels.indexOf(PdfZoomLevel.ThreeQuarters)
|
if (this.previewZoomScale === ZoomSetting.PageFit) currentIndex = 4
|
||||||
}
|
this.previewZoomScale = ZoomSetting.PageWidth
|
||||||
this.previewZoomScale = PdfZoomScale.PageWidth
|
this.previewZoomSetting =
|
||||||
this.previewZoomSetting = zoomLevels[Math.max(0, currentIndex - 1)]
|
Object.values(ZoomSetting)[Math.max(2, currentIndex - 1)]
|
||||||
}
|
}
|
||||||
|
|
||||||
get showPermissions(): boolean {
|
get showPermissions(): boolean {
|
||||||
|
|||||||
11
src-ui/src/app/components/document-detail/zoom-setting.ts
Normal file
11
src-ui/src/app/components/document-detail/zoom-setting.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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,7 +16,6 @@ import {
|
|||||||
NgbModalRef,
|
NgbModalRef,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
|
||||||
import { Subject, of, throwError } from 'rxjs'
|
import { Subject, of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import {
|
import {
|
||||||
@@ -106,7 +105,6 @@ describe('DocumentListComponent', () => {
|
|||||||
PermissionsGuard,
|
PermissionsGuard,
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideUiTour(),
|
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { filter, first, map, Subject, switchMap, takeUntil } from 'rxjs'
|
import { filter, first, map, Subject, switchMap, takeUntil } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
DEFAULT_DISPLAY_FIELDS,
|
DEFAULT_DISPLAY_FIELDS,
|
||||||
@@ -99,7 +99,7 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi
|
|||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrapModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentListComponent
|
export class DocumentListComponent
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
@@ -252,7 +251,6 @@ describe('FilterEditorComponent', () => {
|
|||||||
SettingsService,
|
SettingsService,
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideUiTour(),
|
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
NgbTypeaheadModule,
|
NgbTypeaheadModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { Observable, Subject, from } from 'rxjs'
|
import { Observable, Subject, from } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
catchError,
|
catchError,
|
||||||
@@ -251,7 +251,7 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
|
|||||||
NgbTypeaheadModule,
|
NgbTypeaheadModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrapModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FilterEditorComponent
|
export class FilterEditorComponent
|
||||||
|
|||||||
@@ -129,7 +129,7 @@
|
|||||||
<div class="row fade" [class.show]="showRules">
|
<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 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-1 d-flex align-items-center d-none d-sm-flex">{{rule.order}}</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">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
|
||||||
<div class="col-2 d-flex align-items-center d-none d-sm-flex">
|
<div class="col-2 d-flex align-items-center d-none d-sm-flex">
|
||||||
<div class="form-check form-switch mb-0">
|
<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 }">
|
<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,3 +1,4 @@
|
|||||||
|
import { AsyncPipe } from '@angular/common'
|
||||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { ActivatedRoute } from '@angular/router'
|
import { ActivatedRoute } from '@angular/router'
|
||||||
@@ -36,6 +37,7 @@ import { ProcessedMailDialogComponent } from './processed-mail-dialog/processed-
|
|||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
IfOwnerDirective,
|
IfOwnerDirective,
|
||||||
|
AsyncPipe,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
@@ -46,8 +48,8 @@ export class MailComponent
|
|||||||
extends ComponentWithPermissions
|
extends ComponentWithPermissions
|
||||||
implements OnInit, OnDestroy
|
implements OnInit, OnDestroy
|
||||||
{
|
{
|
||||||
private readonly mailAccountService = inject(MailAccountService)
|
mailAccountService = inject(MailAccountService)
|
||||||
private readonly mailRuleService = inject(MailRuleService)
|
mailRuleService = inject(MailRuleService)
|
||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
permissionsService = inject(PermissionsService)
|
permissionsService = inject(PermissionsService)
|
||||||
@@ -56,19 +58,8 @@ export class MailComponent
|
|||||||
|
|
||||||
public MailAccountType = MailAccountType
|
public MailAccountType = MailAccountType
|
||||||
|
|
||||||
private _mailAccounts: MailAccount[] = []
|
mailAccounts: MailAccount[] = []
|
||||||
|
mailRules: MailRule[] = []
|
||||||
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()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
oAuthAccountId: number
|
oAuthAccountId: number
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions" [loading]="loading">
|
<pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions">
|
||||||
|
|
||||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
||||||
@@ -46,30 +46,14 @@
|
|||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col mb-2 mb-xl-0">
|
<div class="col-md mb-2 mb-xl-0">
|
||||||
<div class="form-inline d-flex align-items-center">
|
<div class="form-inline d-flex align-items-center">
|
||||||
<label class="text-muted me-2 mb-0" i18n>Filter by:</label>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-auto mb-2 mb-xl-0">
|
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
<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>
|
||||||
|
|
||||||
<div class="card border table-responsive mb-3">
|
<div class="card border table-responsive mb-3">
|
||||||
@@ -92,7 +76,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@if (loading && data.length === 0) {
|
@if (loading) {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
@@ -107,7 +91,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!loading || data.length > 0) {
|
@if (!loading) {
|
||||||
<div class="d-flex mb-2">
|
<div class="d-flex mb-2">
|
||||||
@if (collectionSize > 0) {
|
@if (collectionSize > 0) {
|
||||||
<div>
|
<div>
|
||||||
@@ -118,7 +102,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (collectionSize > 20) {
|
@if (collectionSize > 20) {
|
||||||
<ngb-pagination class="ms-auto" [pageSize]="pageSize" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,3 @@ td.name-cell {
|
|||||||
margin-left: .5rem;
|
margin-left: .5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
select.small {
|
|
||||||
font-size: 0.875rem !important; // 14px
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
MATCH_NONE,
|
MATCH_NONE,
|
||||||
} from 'src/app/data/matching-model'
|
} from 'src/app/data/matching-model'
|
||||||
import { Tag } from 'src/app/data/tag'
|
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 { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
@@ -42,7 +41,6 @@ import {
|
|||||||
} from 'src/app/services/permissions.service'
|
} from 'src/app/services/permissions.service'
|
||||||
import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-filter-service'
|
import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-filter-service'
|
||||||
import { TagService } from 'src/app/services/rest/tag.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 { ToastService } from 'src/app/services/toast.service'
|
||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { EditDialogComponent } from '../../common/edit-dialog/edit-dialog.component'
|
import { EditDialogComponent } from '../../common/edit-dialog/edit-dialog.component'
|
||||||
@@ -81,7 +79,6 @@ describe('ManagementListComponent', () => {
|
|||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
let documentListViewService: DocumentListViewService
|
let documentListViewService: DocumentListViewService
|
||||||
let permissionsService: PermissionsService
|
let permissionsService: PermissionsService
|
||||||
let settingsService: SettingsService
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -133,7 +130,6 @@ describe('ManagementListComponent', () => {
|
|||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
|
||||||
fixture = TestBed.createComponent(TagListComponent)
|
fixture = TestBed.createComponent(TagListComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -451,66 +447,4 @@ describe('ManagementListComponent', () => {
|
|||||||
).getSelectableIDs.call({}, [{ id: 1 }, { id: 5 }] as any)
|
).getSelectableIDs.call({}, [{ id: 1 }, { id: 5 }] as any)
|
||||||
expect(ids).toEqual([1, 5])
|
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,7 +23,6 @@ import {
|
|||||||
MatchingModel,
|
MatchingModel,
|
||||||
} from 'src/app/data/matching-model'
|
} from 'src/app/data/matching-model'
|
||||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
|
||||||
import {
|
import {
|
||||||
SortableDirective,
|
SortableDirective,
|
||||||
SortEvent,
|
SortEvent,
|
||||||
@@ -38,7 +37,6 @@ import {
|
|||||||
AbstractNameFilterService,
|
AbstractNameFilterService,
|
||||||
BulkEditObjectOperation,
|
BulkEditObjectOperation,
|
||||||
} from 'src/app/services/rest/abstract-name-filter-service'
|
} 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 { ToastService } from 'src/app/services/toast.service'
|
||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||||
@@ -82,8 +80,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
public permissionType: PermissionType
|
public permissionType: PermissionType
|
||||||
public extraColumns: ManagementListColumn[]
|
public extraColumns: ManagementListColumn[]
|
||||||
|
|
||||||
private readonly settingsService = inject(SettingsService)
|
|
||||||
|
|
||||||
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
|
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
|
||||||
|
|
||||||
public data: T[] = []
|
public data: T[] = []
|
||||||
@@ -164,7 +160,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
this.service
|
this.service
|
||||||
.listFiltered(
|
.listFiltered(
|
||||||
this.page,
|
this.page,
|
||||||
this.pageSize,
|
null,
|
||||||
this.sortField,
|
this.sortField,
|
||||||
this.sortReverse,
|
this.sortReverse,
|
||||||
this._nameFilter,
|
this._nameFilter,
|
||||||
@@ -284,30 +280,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
if (event.code == 'Escape') this.nameFilterDebounce.next(null)
|
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 {
|
userCanDelete(object: ObjectWithPermissions): boolean {
|
||||||
return this.permissionsService.currentUserOwnsObject(object)
|
return this.permissionsService.currentUserOwnsObject(object)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ describe('TagListComponent', () => {
|
|||||||
it('should request only parent tags when no name filter is applied', () => {
|
it('should request only parent tags when no name filter is applied', () => {
|
||||||
expect(tagService.listFiltered).toHaveBeenCalledWith(
|
expect(tagService.listFiltered).toHaveBeenCalledWith(
|
||||||
1,
|
1,
|
||||||
25,
|
null,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -116,7 +116,7 @@ describe('TagListComponent', () => {
|
|||||||
component.reloadData()
|
component.reloadData()
|
||||||
expect(tagService.listFiltered).toHaveBeenCalledWith(
|
expect(tagService.listFiltered).toHaveBeenCalledWith(
|
||||||
1,
|
1,
|
||||||
25,
|
null,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
'Tag',
|
'Tag',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode'
|
import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode'
|
||||||
import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types'
|
import { ZoomSetting } from '../components/document-detail/zoom-setting'
|
||||||
import { User } from './user'
|
import { User } from './user'
|
||||||
|
|
||||||
export interface UiSettings {
|
export interface UiSettings {
|
||||||
@@ -63,7 +63,6 @@ export const SETTINGS_KEYS = {
|
|||||||
SIDEBAR_VIEWS_SHOW_COUNT:
|
SIDEBAR_VIEWS_SHOW_COUNT:
|
||||||
'general-settings:saved-views:sidebar-views-show-count',
|
'general-settings:saved-views:sidebar-views-show-count',
|
||||||
TOUR_COMPLETE: 'general-settings:tour-complete',
|
TOUR_COMPLETE: 'general-settings:tour-complete',
|
||||||
OBJECT_LIST_SIZES: 'general-settings:object-list-sizes',
|
|
||||||
DEFAULT_PERMS_OWNER: 'general-settings:permissions:default-owner',
|
DEFAULT_PERMS_OWNER: 'general-settings:permissions:default-owner',
|
||||||
DEFAULT_PERMS_VIEW_USERS: 'general-settings:permissions:default-view-users',
|
DEFAULT_PERMS_VIEW_USERS: 'general-settings:permissions:default-view-users',
|
||||||
DEFAULT_PERMS_VIEW_GROUPS: 'general-settings:permissions:default-view-groups',
|
DEFAULT_PERMS_VIEW_GROUPS: 'general-settings:permissions:default-view-groups',
|
||||||
@@ -202,16 +201,6 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: false,
|
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,
|
key: SETTINGS_KEYS.DEFAULT_PERMS_OWNER,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -310,7 +299,7 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
{
|
{
|
||||||
key: SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
|
key: SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
|
||||||
type: 'string',
|
type: 'string',
|
||||||
default: PdfZoomScale.PageWidth,
|
default: ZoomSetting.PageWidth,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: SETTINGS_KEYS.AI_ENABLED,
|
key: SETTINGS_KEYS.AI_ENABLED,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export enum WorkflowActionType {
|
|||||||
Removal = 2,
|
Removal = 2,
|
||||||
Email = 3,
|
Email = 3,
|
||||||
Webhook = 4,
|
Webhook = 4,
|
||||||
PasswordRemoval = 5,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkflowActionEmail extends ObjectWithId {
|
export interface WorkflowActionEmail extends ObjectWithId {
|
||||||
@@ -98,6 +97,4 @@ export interface WorkflowAction extends ObjectWithId {
|
|||||||
email?: WorkflowActionEmail
|
email?: WorkflowActionEmail
|
||||||
|
|
||||||
webhook?: WorkflowActionWebhook
|
webhook?: WorkflowActionWebhook
|
||||||
|
|
||||||
passwords?: string[]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { TestBed } from '@angular/core/testing'
|
|||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
|
||||||
import { routes } from '../app-routing.module'
|
import { routes } from '../app-routing.module'
|
||||||
import { ConfirmDialogComponent } from '../components/common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../components/common/confirm-dialog/confirm-dialog.component'
|
||||||
import { DocumentListComponent } from '../components/document-list/document-list.component'
|
import { DocumentListComponent } from '../components/document-list/document-list.component'
|
||||||
@@ -31,7 +30,6 @@ describe('DirtySavedViewGuard', () => {
|
|||||||
DocumentListComponent,
|
DocumentListComponent,
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideUiTour(),
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { TestBed } from '@angular/core/testing'
|
import { TestBed } from '@angular/core/testing'
|
||||||
import { ActivatedRoute, RouterState } from '@angular/router'
|
import { ActivatedRoute, RouterState } from '@angular/router'
|
||||||
import { provideUiTour, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import {
|
import {
|
||||||
PermissionAction,
|
PermissionAction,
|
||||||
PermissionsService,
|
|
||||||
PermissionType,
|
PermissionType,
|
||||||
|
PermissionsService,
|
||||||
} from '../services/permissions.service'
|
} from '../services/permissions.service'
|
||||||
import { ToastService } from '../services/toast.service'
|
import { ToastService } from '../services/toast.service'
|
||||||
import { PermissionsGuard } from './permissions.guard'
|
import { PermissionsGuard } from './permissions.guard'
|
||||||
@@ -45,7 +45,6 @@ describe('PermissionsGuard', () => {
|
|||||||
},
|
},
|
||||||
TourService,
|
TourService,
|
||||||
ToastService,
|
ToastService,
|
||||||
provideUiTour(),
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||||
import { inject, Injectable } from '@angular/core'
|
import { inject, Injectable } from '@angular/core'
|
||||||
import { Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import { map, shareReplay, tap } from 'rxjs/operators'
|
import { map, publishReplay, refCount, tap } from 'rxjs/operators'
|
||||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||||
import { Results } from 'src/app/data/results'
|
import { Results } from 'src/app/data/results'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
@@ -90,7 +90,7 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
|
|||||||
sortField,
|
sortField,
|
||||||
sortReverse,
|
sortReverse,
|
||||||
extraParams
|
extraParams
|
||||||
).pipe(shareReplay({ bufferSize: 1, refCount: true }))
|
).pipe(publishReplay(1), refCount())
|
||||||
}
|
}
|
||||||
return this._listAll
|
return this._listAll
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,26 +70,4 @@ describe('LocalizedDateParserFormatter', () => {
|
|||||||
dateStr = dateParserFormatter.format(dateStruct)
|
dateStr = dateParserFormatter.format(dateStruct)
|
||||||
expect(dateStr).toEqual('04.05.2023')
|
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,25 +106,15 @@ export class LocalizedDateParserFormatter extends NgbDateParserFormatter {
|
|||||||
value = this.preformatDateInput(value)
|
value = this.preformatDateInput(value)
|
||||||
let match = this.getDateParseRegex().exec(value)
|
let match = this.getDateParseRegex().exec(value)
|
||||||
if (match) {
|
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 = {
|
let dateStruct = {
|
||||||
day: +match.groups.day,
|
day: +match.groups.day,
|
||||||
month: +match.groups.month,
|
month: +match.groups.month,
|
||||||
year,
|
year: +match.groups.year,
|
||||||
|
}
|
||||||
|
if (dateStruct.year <= new Date().getFullYear() - 2000) {
|
||||||
|
dateStruct.year += 2000
|
||||||
|
} else if (dateStruct.year < 100) {
|
||||||
|
dateStruct.year += 1900
|
||||||
}
|
}
|
||||||
return dateStruct
|
return dateStruct
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
NgbModule,
|
NgbModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
|
import { PdfViewerModule } from 'ng2-pdf-viewer'
|
||||||
import {
|
import {
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
airplane,
|
airplane,
|
||||||
@@ -144,6 +145,7 @@ import {
|
|||||||
} from 'ngx-bootstrap-icons'
|
} from 'ngx-bootstrap-icons'
|
||||||
import { ColorSliderModule } from 'ngx-color/slider'
|
import { ColorSliderModule } from 'ngx-color/slider'
|
||||||
import { CookieService } from 'ngx-cookie-service'
|
import { CookieService } from 'ngx-cookie-service'
|
||||||
|
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { AppRoutingModule } from './app/app-routing.module'
|
import { AppRoutingModule } from './app/app-routing.module'
|
||||||
import { AppComponent } from './app/app.component'
|
import { AppComponent } from './app/app.component'
|
||||||
import { DirtyDocGuard } from './app/guards/dirty-doc.guard'
|
import { DirtyDocGuard } from './app/guards/dirty-doc.guard'
|
||||||
@@ -193,7 +195,6 @@ import localeUk from '@angular/common/locales/uk'
|
|||||||
import localeVi from '@angular/common/locales/vi'
|
import localeVi from '@angular/common/locales/vi'
|
||||||
import localeZh from '@angular/common/locales/zh'
|
import localeZh from '@angular/common/locales/zh'
|
||||||
import localeZhHant from '@angular/common/locales/zh-Hant'
|
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 { CorrespondentNamePipe } from './app/pipes/correspondent-name.pipe'
|
||||||
import { DocumentTypeNamePipe } from './app/pipes/document-type-name.pipe'
|
import { DocumentTypeNamePipe } from './app/pipes/document-type-name.pipe'
|
||||||
import { StoragePathNamePipe } from './app/pipes/storage-path-name.pipe'
|
import { StoragePathNamePipe } from './app/pipes/storage-path-name.pipe'
|
||||||
@@ -370,8 +371,10 @@ bootstrapApplication(AppComponent, {
|
|||||||
NgbModule,
|
NgbModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
|
PdfViewerModule,
|
||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
ColorSliderModule,
|
ColorSliderModule,
|
||||||
|
TourNgBootstrapModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
NgxBootstrapIconsModule.pick(icons)
|
NgxBootstrapIconsModule.pick(icons)
|
||||||
),
|
),
|
||||||
@@ -394,16 +397,5 @@ bootstrapApplication(AppComponent, {
|
|||||||
withInterceptors([withCsrfInterceptor, withApiVersionInterceptor]),
|
withInterceptors([withCsrfInterceptor, withApiVersionInterceptor]),
|
||||||
withFetch()
|
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))
|
}).catch((err) => console.error(err))
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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()))
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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,7 +17,6 @@
|
|||||||
],
|
],
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.spec.ts",
|
"src/**/*.spec.ts",
|
||||||
"src/**/*.d.ts",
|
"src/**/*.d.ts"
|
||||||
"src/test/**/*.ts"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ class DocumentsConfig(AppConfig):
|
|||||||
|
|
||||||
verbose_name = _("Documents")
|
verbose_name = _("Documents")
|
||||||
|
|
||||||
def ready(self) -> None:
|
def ready(self):
|
||||||
from documents.signals import document_consumption_finished
|
from documents.signals import document_consumption_finished
|
||||||
from documents.signals import document_updated
|
from documents.signals import document_updated
|
||||||
from documents.signals.handlers import add_inbox_tags
|
from documents.signals.handlers import add_inbox_tags
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from pikepdf import Pdf
|
|||||||
from documents.converters import convert_from_tiff_to_pdf
|
from documents.converters import convert_from_tiff_to_pdf
|
||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
from documents.models import Document
|
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.plugins.base import ConsumeTaskPlugin
|
from documents.plugins.base import ConsumeTaskPlugin
|
||||||
from documents.plugins.base import StopConsumeTaskError
|
from documents.plugins.base import StopConsumeTaskError
|
||||||
@@ -28,6 +27,8 @@ from documents.utils import maybe_override_pixel_limit
|
|||||||
from paperless.config import BarcodeConfig
|
from paperless.config import BarcodeConfig
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.barcodes")
|
logger = logging.getLogger("paperless.barcodes")
|
||||||
@@ -128,24 +129,6 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
|||||||
self._tiff_conversion_done = False
|
self._tiff_conversion_done = False
|
||||||
self.barcodes: list[Barcode] = []
|
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:
|
def run(self) -> None:
|
||||||
# Some operations may use PIL, override pixel setting if needed
|
# Some operations may use PIL, override pixel setting if needed
|
||||||
maybe_override_pixel_limit()
|
maybe_override_pixel_limit()
|
||||||
@@ -223,8 +206,13 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
|||||||
|
|
||||||
# Update/overwrite an ASN if possible
|
# Update/overwrite an ASN if possible
|
||||||
# After splitting, as otherwise each split document gets the same ASN
|
# After splitting, as otherwise each split document gets the same ASN
|
||||||
if self.settings.barcode_enable_asn and (located_asn := self.asn) is not None:
|
if (
|
||||||
self._apply_detected_asn(located_asn)
|
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
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
self.temp_dir.cleanup()
|
self.temp_dir.cleanup()
|
||||||
@@ -260,6 +248,26 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
|||||||
|
|
||||||
return barcodes
|
return barcodes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def read_barcodes_pyzbar(image: Image.Image) -> list[str]:
|
||||||
|
barcodes = []
|
||||||
|
|
||||||
|
from pyzbar import pyzbar
|
||||||
|
|
||||||
|
# Decode the barcode image
|
||||||
|
detected_barcodes = pyzbar.decode(image)
|
||||||
|
|
||||||
|
# Traverse through all the detected barcodes in image
|
||||||
|
for barcode in detected_barcodes:
|
||||||
|
if barcode.data:
|
||||||
|
decoded_barcode = barcode.data.decode("utf-8")
|
||||||
|
barcodes.append(decoded_barcode)
|
||||||
|
logger.debug(
|
||||||
|
f"Barcode of type {barcode.type} found: {decoded_barcode}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return barcodes
|
||||||
|
|
||||||
def detect(self) -> None:
|
def detect(self) -> None:
|
||||||
"""
|
"""
|
||||||
Scan all pages of the PDF as images, updating barcodes and the pages
|
Scan all pages of the PDF as images, updating barcodes and the pages
|
||||||
@@ -272,6 +280,14 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
|||||||
# No op if not a TIFF
|
# No op if not a TIFF
|
||||||
self.convert_from_tiff_to_pdf()
|
self.convert_from_tiff_to_pdf()
|
||||||
|
|
||||||
|
# Choose the library for reading
|
||||||
|
if settings.CONSUMER_BARCODE_SCANNER == "PYZBAR":
|
||||||
|
reader: Callable[[Image.Image], list[str]] = self.read_barcodes_pyzbar
|
||||||
|
logger.debug("Scanning for barcodes using PYZBAR")
|
||||||
|
else:
|
||||||
|
reader = self.read_barcodes_zxing
|
||||||
|
logger.debug("Scanning for barcodes using ZXING")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Read number of pages from pdf
|
# Read number of pages from pdf
|
||||||
with Pdf.open(self.pdf_file) as pdf:
|
with Pdf.open(self.pdf_file) as pdf:
|
||||||
@@ -319,7 +335,7 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Detect barcodes
|
# Detect barcodes
|
||||||
for barcode_value in self.read_barcodes_zxing(page):
|
for barcode_value in reader(page):
|
||||||
self.barcodes.append(
|
self.barcodes.append(
|
||||||
Barcode(current_page_number, barcode_value, self.settings),
|
Barcode(current_page_number, barcode_value, self.settings),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
from celery import chain
|
||||||
from celery import chord
|
from celery import chord
|
||||||
from celery import group
|
from celery import group
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
@@ -37,42 +38,6 @@ if TYPE_CHECKING:
|
|||||||
logger: logging.Logger = logging.getLogger("paperless.bulk_edit")
|
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(
|
def set_correspondent(
|
||||||
doc_ids: list[int],
|
doc_ids: list[int],
|
||||||
correspondent: Correspondent,
|
correspondent: Correspondent,
|
||||||
@@ -340,10 +305,10 @@ def reprocess(doc_ids: list[int]) -> Literal["OK"]:
|
|||||||
|
|
||||||
def set_permissions(
|
def set_permissions(
|
||||||
doc_ids: list[int],
|
doc_ids: list[int],
|
||||||
set_permissions: dict,
|
set_permissions,
|
||||||
*,
|
*,
|
||||||
owner: User | None = None,
|
owner=None,
|
||||||
merge: bool = False,
|
merge=False,
|
||||||
) -> Literal["OK"]:
|
) -> Literal["OK"]:
|
||||||
qs = Document.objects.filter(id__in=doc_ids).select_related("owner")
|
qs = Document.objects.filter(id__in=doc_ids).select_related("owner")
|
||||||
|
|
||||||
@@ -421,7 +386,6 @@ def merge(
|
|||||||
|
|
||||||
merged_pdf = pikepdf.new()
|
merged_pdf = pikepdf.new()
|
||||||
version: str = merged_pdf.pdf_version
|
version: str = merged_pdf.pdf_version
|
||||||
handoff_asn: int | None = None
|
|
||||||
# use doc_ids to preserve order
|
# use doc_ids to preserve order
|
||||||
for doc_id in doc_ids:
|
for doc_id in doc_ids:
|
||||||
doc = qs.get(id=doc_id)
|
doc = qs.get(id=doc_id)
|
||||||
@@ -437,8 +401,6 @@ def merge(
|
|||||||
version = max(version, pdf.pdf_version)
|
version = max(version, pdf.pdf_version)
|
||||||
merged_pdf.pages.extend(pdf.pages)
|
merged_pdf.pages.extend(pdf.pages)
|
||||||
affected_docs.append(doc.id)
|
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:
|
except Exception as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
|
f"Error merging document {doc.id}, it will not be included in the merge: {e}",
|
||||||
@@ -464,8 +426,6 @@ def merge(
|
|||||||
DocumentMetadataOverrides.from_document(metadata_document)
|
DocumentMetadataOverrides.from_document(metadata_document)
|
||||||
)
|
)
|
||||||
overrides.title = metadata_document.title + " (merged)"
|
overrides.title = metadata_document.title + " (merged)"
|
||||||
if metadata_document.archive_serial_number is not None:
|
|
||||||
handoff_asn = metadata_document.archive_serial_number
|
|
||||||
else:
|
else:
|
||||||
overrides = DocumentMetadataOverrides()
|
overrides = DocumentMetadataOverrides()
|
||||||
else:
|
else:
|
||||||
@@ -473,11 +433,8 @@ def merge(
|
|||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
overrides.owner_id = user.id
|
overrides.owner_id = user.id
|
||||||
if not delete_originals:
|
# Avoid copying or detecting ASN from merged PDFs to prevent collision
|
||||||
overrides.skip_asn_if_exists = True
|
overrides.skip_asn = True
|
||||||
|
|
||||||
if delete_originals and handoff_asn is not None:
|
|
||||||
overrides.asn = handoff_asn
|
|
||||||
|
|
||||||
logger.info("Adding merged document to the task queue.")
|
logger.info("Adding merged document to the task queue.")
|
||||||
|
|
||||||
@@ -490,20 +447,12 @@ def merge(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if delete_originals:
|
if delete_originals:
|
||||||
backup = release_archive_serial_numbers(affected_docs)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Queueing removal of original documents after consumption of merged document",
|
"Queueing removal of original documents after consumption of merged document",
|
||||||
)
|
)
|
||||||
try:
|
chain(consume_task, delete.si(affected_docs)).delay()
|
||||||
consume_task.apply_async(
|
else:
|
||||||
link=[delete.si(affected_docs)],
|
consume_task.delay()
|
||||||
link_error=[restore_archive_serial_numbers_task.s(backup)],
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
restore_archive_serial_numbers(backup)
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
consume_task.delay()
|
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
@@ -545,8 +494,6 @@ def split(
|
|||||||
overrides.title = f"{doc.title} (split {idx + 1})"
|
overrides.title = f"{doc.title} (split {idx + 1})"
|
||||||
if user is not None:
|
if user is not None:
|
||||||
overrides.owner_id = user.id
|
overrides.owner_id = user.id
|
||||||
if not delete_originals:
|
|
||||||
overrides.skip_asn_if_exists = True
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Adding split document with pages {split_doc} to the task queue.",
|
f"Adding split document with pages {split_doc} to the task queue.",
|
||||||
)
|
)
|
||||||
@@ -561,20 +508,10 @@ def split(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if delete_originals:
|
if delete_originals:
|
||||||
backup = release_archive_serial_numbers([doc.id])
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Queueing removal of original document after consumption of the split documents",
|
"Queueing removal of original document after consumption of the split documents",
|
||||||
)
|
)
|
||||||
try:
|
chord(header=consume_tasks, body=delete.si([doc.id])).delay()
|
||||||
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:
|
else:
|
||||||
group(consume_tasks).delay()
|
group(consume_tasks).delay()
|
||||||
|
|
||||||
@@ -614,7 +551,7 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["OK"]:
|
|||||||
|
|
||||||
def edit_pdf(
|
def edit_pdf(
|
||||||
doc_ids: list[int],
|
doc_ids: list[int],
|
||||||
operations: list[dict[str, int]],
|
operations: list[dict],
|
||||||
*,
|
*,
|
||||||
delete_original: bool = False,
|
delete_original: bool = False,
|
||||||
update_document: bool = False,
|
update_document: bool = False,
|
||||||
@@ -677,10 +614,7 @@ def edit_pdf(
|
|||||||
)
|
)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
overrides.owner_id = user.id
|
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):
|
for idx, pdf in enumerate(pdf_docs, start=1):
|
||||||
filepath: Path = (
|
filepath: Path = (
|
||||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||||
@@ -699,17 +633,7 @@ def edit_pdf(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if delete_original:
|
if delete_original:
|
||||||
backup = release_archive_serial_numbers([doc.id])
|
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:
|
else:
|
||||||
group(consume_tasks).delay()
|
group(consume_tasks).delay()
|
||||||
|
|
||||||
@@ -797,7 +721,7 @@ def reflect_doclinks(
|
|||||||
document: Document,
|
document: Document,
|
||||||
field: CustomField,
|
field: CustomField,
|
||||||
target_doc_ids: list[int],
|
target_doc_ids: list[int],
|
||||||
) -> None:
|
):
|
||||||
"""
|
"""
|
||||||
Add or remove 'symmetrical' links to `document` on all `target_doc_ids`
|
Add or remove 'symmetrical' links to `document` on all `target_doc_ids`
|
||||||
"""
|
"""
|
||||||
@@ -860,7 +784,7 @@ def remove_doclink(
|
|||||||
document: Document,
|
document: Document,
|
||||||
field: CustomField,
|
field: CustomField,
|
||||||
target_doc_id: int,
|
target_doc_id: int,
|
||||||
) -> None:
|
):
|
||||||
"""
|
"""
|
||||||
Removes a 'symmetrical' link to `document` from the target document's existing custom field instance
|
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
|
self._stop_words = None
|
||||||
|
|
||||||
def _update_data_vectorizer_hash(self) -> None:
|
def _update_data_vectorizer_hash(self):
|
||||||
self.data_vectorizer_hash = sha256(
|
self.data_vectorizer_hash = sha256(
|
||||||
pickle.dumps(self.data_vectorizer),
|
pickle.dumps(self.data_vectorizer),
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user