mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-14 00:09:35 -06:00
Compare commits
105 Commits
feature/mc
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65d56b0132 | ||
|
|
8db1c4e08b | ||
|
|
1c70d3f8a8 | ||
|
|
491b5a4355 | ||
|
|
d41d4e12bf | ||
|
|
775e32bf3b | ||
|
|
e8e027abc0 | ||
|
|
c4ed4e7f36 | ||
|
|
0b89e2847e | ||
|
|
21623e4455 | ||
|
|
9d7231d2dc | ||
|
|
4208d9255a | ||
|
|
9e9e55758f | ||
|
|
6a87c3f4dd | ||
|
|
d7c64760ed | ||
|
|
750c77736b | ||
|
|
30b1d3c6d7 | ||
|
|
d3ff856202 | ||
|
|
3bc4631a0f | ||
|
|
ab328e0212 | ||
|
|
5c3d02e6d4 | ||
|
|
1d89ec402b | ||
|
|
6192915be7 | ||
|
|
b9b90ec9f7 | ||
|
|
0dc58cf686 | ||
|
|
505ff31748 | ||
|
|
3c51b3f9cd | ||
|
|
dfbac35f9c | ||
|
|
8f917555b1 | ||
|
|
734b5b9a45 | ||
|
|
0f1cae03ec | ||
|
|
71663fdbe2 | ||
|
|
1188a89369 | ||
|
|
b8e3b6590e | ||
|
|
4a5116adf8 | ||
|
|
bbf2e63f10 | ||
|
|
33cbe2ad54 | ||
|
|
261e10ebeb | ||
|
|
585c28b460 | ||
|
|
e77ab3357c | ||
|
|
05ab091ea4 | ||
|
|
fb7abf7a6e | ||
|
|
6ad2fc0356 | ||
|
|
2ec8ec96c8 | ||
|
|
276dc13e3f | ||
|
|
d0c02e7a8d | ||
|
|
e45fca475a | ||
|
|
63c0e2f72b | ||
|
|
00ef0837d2 | ||
|
|
231561ad55 | ||
|
|
4fa38708a1 | ||
|
|
5c2366fb24 | ||
|
|
e5edfd0f7f | ||
|
|
470c824684 | ||
|
|
3b5ffbf9fa | ||
|
|
a9c0b06e28 | ||
|
|
5fde91a891 | ||
|
|
3293655b0f | ||
|
|
36e07f5d40 | ||
|
|
fd78beff77 | ||
|
|
563156bd8e | ||
|
|
b26da51507 | ||
|
|
ff308a2010 | ||
|
|
6442fdc235 | ||
|
|
a42df003fb | ||
|
|
9ddd66ccbc | ||
|
|
72b861b5eb | ||
|
|
1513cbaaf5 | ||
|
|
aac6858aad | ||
|
|
c3b036e0d3 | ||
|
|
d27a5f688a | ||
|
|
c5bb5b237b | ||
|
|
11a5714cba | ||
|
|
4363567fa7 | ||
|
|
3e41d99a82 | ||
|
|
5cc3c087d9 | ||
|
|
c8c4c7c749 | ||
|
|
836c81e037 | ||
|
|
e4b861d76f | ||
|
|
a367b8ad1c | ||
|
|
d16d3fb618 | ||
|
|
5577f70c69 | ||
|
|
4d9aa2e943 | ||
|
|
6913f9d79c | ||
|
|
66593ec660 | ||
|
|
5af0d1da26 | ||
|
|
3281ec2401 | ||
|
|
dc9061eb97 | ||
|
|
6859e7e3c2 | ||
|
|
3e645bd9e2 | ||
|
|
09d39de200 | ||
|
|
94231dbb0f | ||
|
|
2f76350023 | ||
|
|
4cbe56e3af | ||
|
|
01b21377af | ||
|
|
56b5d838d7 | ||
|
|
d294508982 | ||
|
|
02002620d2 | ||
|
|
6d93ae93b4 | ||
|
|
c84f2f04b3 | ||
|
|
d9d83e3045 | ||
|
|
1f074390e4 | ||
|
|
50d676c592 | ||
|
|
94b0f4e114 | ||
|
|
045994042b |
@@ -64,8 +64,6 @@ ARG RUNTIME_PACKAGES="\
|
|||||||
libmagic1 \
|
libmagic1 \
|
||||||
media-types \
|
media-types \
|
||||||
zlib1g \
|
zlib1g \
|
||||||
# Barcode splitter
|
|
||||||
libzbar0 \
|
|
||||||
poppler-utils \
|
poppler-utils \
|
||||||
htop \
|
htop \
|
||||||
sudo"
|
sudo"
|
||||||
|
|||||||
@@ -89,6 +89,18 @@ Additional tasks are available for common maintenance operations:
|
|||||||
- **Migrate Database**: To apply database migrations.
|
- **Migrate Database**: To apply database migrations.
|
||||||
- **Create Superuser**: To create an admin user for the application.
|
- **Create Superuser**: To create an admin user for the application.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
If you want to commit changes from your host machine (outside the DevContainer), you need to set up prek on your host. This installs it as a standalone tool.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool install prek && prek install
|
||||||
|
```
|
||||||
|
|
||||||
|
After this, you can commit either from inside the DevContainer or from your host machine.
|
||||||
|
|
||||||
## Let's Get Started!
|
## Let's Get Started!
|
||||||
|
|
||||||
Follow the steps above to get your development environment up and running. Happy coding!
|
Follow the steps above to get your development environment up and running. Happy coding!
|
||||||
|
|||||||
@@ -3,26 +3,31 @@
|
|||||||
"dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml",
|
"dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml",
|
||||||
"service": "paperless-development",
|
"service": "paperless-development",
|
||||||
"workspaceFolder": "/usr/src/paperless/paperless-ngx",
|
"workspaceFolder": "/usr/src/paperless/paperless-ngx",
|
||||||
"postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run pre-commit install'",
|
"forwardPorts": [4200, 8000],
|
||||||
|
"containerEnv": {
|
||||||
|
"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'",
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"mhutchie.git-graph",
|
"mhutchie.git-graph",
|
||||||
"ms-python.python",
|
"ms-python.python",
|
||||||
"ms-vscode.js-debug-nightly",
|
"ms-vscode.js-debug-nightly",
|
||||||
"eamodio.gitlens",
|
"eamodio.gitlens",
|
||||||
"yzhang.markdown-all-in-one"
|
"yzhang.markdown-all-in-one",
|
||||||
],
|
"pnpm.pnpm"
|
||||||
"settings": {
|
],
|
||||||
"python.defaultInterpreterPath": "/usr/src/paperless/paperless-ngx/.venv/bin/python",
|
"settings": {
|
||||||
"python.pythonPath": "/usr/src/paperless/paperless-ngx/.venv/bin/python",
|
"python.defaultInterpreterPath": "/usr/src/paperless/paperless-ngx/.venv/bin/python",
|
||||||
"python.terminal.activateEnvInCurrentTerminal": true,
|
"python.pythonPath": "/usr/src/paperless/paperless-ngx/.venv/bin/python",
|
||||||
"editor.formatOnPaste": false,
|
"python.terminal.activateEnvInCurrentTerminal": true,
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnPaste": false,
|
||||||
"editor.formatOnType": true,
|
"editor.formatOnSave": true,
|
||||||
"files.trimTrailingWhitespace": true
|
"editor.formatOnType": true,
|
||||||
}
|
"files.trimTrailingWhitespace": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"remoteUser": "paperless"
|
"remoteUser": "paperless"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"label": "Start: Frontend Angular",
|
"label": "Start: Frontend Angular",
|
||||||
"description": "Start the Frontend Angular Dev Server",
|
"description": "Start the Frontend Angular Dev Server",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "pnpm start",
|
"command": "pnpm exec ng serve --host 0.0.0.0",
|
||||||
"isBackground": true,
|
"isBackground": true,
|
||||||
"options": {
|
"options": {
|
||||||
"cwd": "${workspaceFolder}/src-ui"
|
"cwd": "${workspaceFolder}/src-ui"
|
||||||
@@ -116,9 +116,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Maintenance: Build Documentation",
|
"label": "Maintenance: Build Documentation",
|
||||||
"description": "Build the documentation with MkDocs",
|
"description": "Build the documentation with Zensical",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "uv run mkdocs build --config-file mkdocs.yml && uv run mkdocs serve",
|
"command": "uv run zensical build && uv run zensical serve",
|
||||||
"group": "none",
|
"group": "none",
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"echo": true,
|
"echo": true,
|
||||||
@@ -174,12 +174,22 @@
|
|||||||
{
|
{
|
||||||
"label": "Maintenance: Install Frontend Dependencies",
|
"label": "Maintenance: Install Frontend Dependencies",
|
||||||
"description": "Install frontend (pnpm) dependencies",
|
"description": "Install frontend (pnpm) dependencies",
|
||||||
"type": "pnpm",
|
"type": "shell",
|
||||||
"script": "install",
|
"command": "pnpm install",
|
||||||
"path": "src-ui",
|
|
||||||
"group": "clean",
|
"group": "clean",
|
||||||
"problemMatcher": [],
|
"problemMatcher": [],
|
||||||
"detail": "install dependencies from package"
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/src-ui"
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": true,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": false,
|
||||||
|
"clear": true,
|
||||||
|
"revealProblems": "onProblem"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Clean install frontend dependencies and build the frontend for production",
|
"description": "Clean install frontend dependencies and build the frontend for production",
|
||||||
|
|||||||
@@ -28,3 +28,4 @@
|
|||||||
./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 `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks).
|
- [ ] I have run all Git `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks).
|
||||||
- [ ] I have made corresponding changes to the documentation as needed.
|
- [ ] 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"
|
||||||
- "mkdocs-material"
|
- "zensical"
|
||||||
- "pre-commit*"
|
- "prek*"
|
||||||
# Django & DRF Ecosystem
|
# Django & DRF Ecosystem
|
||||||
django-ecosystem:
|
django-ecosystem:
|
||||||
patterns:
|
patterns:
|
||||||
@@ -69,7 +69,6 @@ updates:
|
|||||||
patterns:
|
patterns:
|
||||||
- "ocrmypdf"
|
- "ocrmypdf"
|
||||||
- "pdf2image"
|
- "pdf2image"
|
||||||
- "pyzbar"
|
|
||||||
- "zxing-cpp"
|
- "zxing-cpp"
|
||||||
- "tika-client"
|
- "tika-client"
|
||||||
- "gotenberg-client"
|
- "gotenberg-client"
|
||||||
|
|||||||
56
.github/workflows/ci-backend.yml
vendored
56
.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.9.x"
|
DEFAULT_UV_VERSION: "0.10.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 libzbar0 poppler-utils
|
unpaper tesseract-ocr imagemagick ghostscript 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
|
||||||
@@ -75,9 +75,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NLTK_DATA: ${{ env.NLTK_DATA }}
|
NLTK_DATA: ${{ env.NLTK_DATA }}
|
||||||
PAPERLESS_CI_TEST: 1
|
PAPERLESS_CI_TEST: 1
|
||||||
PAPERLESS_MAIL_TEST_HOST: ${{ secrets.TEST_MAIL_HOST }}
|
|
||||||
PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }}
|
|
||||||
PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }}
|
|
||||||
run: |
|
run: |
|
||||||
uv run \
|
uv run \
|
||||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||||
@@ -102,3 +99,52 @@ 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
|
||||||
|
|||||||
29
.github/workflows/ci-docker.yml
vendored
29
.github/workflows/ci-docker.yml
vendored
@@ -46,14 +46,13 @@ jobs:
|
|||||||
id: ref
|
id: ref
|
||||||
run: |
|
run: |
|
||||||
ref_name="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}"
|
ref_name="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}"
|
||||||
# Sanitize by replacing / with - for cache keys
|
# Sanitize by replacing / with - for use in tags and cache keys
|
||||||
cache_ref="${ref_name//\//-}"
|
sanitized_ref="${ref_name//\//-}"
|
||||||
|
|
||||||
echo "ref_name=${ref_name}"
|
echo "ref_name=${ref_name}"
|
||||||
echo "cache_ref=${cache_ref}"
|
echo "sanitized_ref=${sanitized_ref}"
|
||||||
|
|
||||||
echo "name=${ref_name}" >> $GITHUB_OUTPUT
|
echo "name=${sanitized_ref}" >> $GITHUB_OUTPUT
|
||||||
echo "cache-ref=${cache_ref}" >> $GITHUB_OUTPUT
|
|
||||||
- name: Check push permissions
|
- name: Check push permissions
|
||||||
id: check-push
|
id: check-push
|
||||||
env:
|
env:
|
||||||
@@ -62,12 +61,14 @@ jobs:
|
|||||||
# should-push: Should we push to GHCR?
|
# should-push: Should we push to GHCR?
|
||||||
# True for:
|
# True for:
|
||||||
# 1. Pushes (tags/dev/beta) - filtered via the workflow triggers
|
# 1. Pushes (tags/dev/beta) - filtered via the workflow triggers
|
||||||
# 2. Internal PRs where the branch name starts with 'feature-' - filtered here when a PR is synced
|
# 2. Manual dispatch - always push to GHCR
|
||||||
|
# 3. Internal PRs where the branch name starts with 'feature-' or 'fix-'
|
||||||
|
|
||||||
should_push="false"
|
should_push="false"
|
||||||
|
|
||||||
if [[ "${{ github.event_name }}" == "push" ]]; then
|
if [[ "${{ github.event_name }}" == "push" ]]; then
|
||||||
should_push="true"
|
should_push="true"
|
||||||
|
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
|
should_push="true"
|
||||||
elif [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
|
elif [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
|
||||||
if [[ "${REF_NAME}" == feature-* || "${REF_NAME}" == fix-* ]]; then
|
if [[ "${REF_NAME}" == feature-* || "${REF_NAME}" == fix-* ]]; then
|
||||||
should_push="true"
|
should_push="true"
|
||||||
@@ -105,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.6.0
|
uses: docker/login-action@v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -129,7 +130,7 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
- name: Build and push by digest
|
- name: Build and push by digest
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v6.18.0
|
uses: docker/build-push-action@v6.19.2
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
@@ -139,9 +140,9 @@ jobs:
|
|||||||
PNGX_TAG_VERSION=${{ steps.docker-meta.outputs.version }}
|
PNGX_TAG_VERSION=${{ steps.docker-meta.outputs.version }}
|
||||||
outputs: type=image,name=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }},push-by-digest=true,name-canonical=true,push=${{ steps.check-push.outputs.should-push }}
|
outputs: type=image,name=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }},push-by-digest=true,name-canonical=true,push=${{ steps.check-push.outputs.should-push }}
|
||||||
cache-from: |
|
cache-from: |
|
||||||
type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}/cache/app:${{ steps.ref.outputs.cache-ref }}-${{ matrix.arch }}
|
type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}/cache/app:${{ steps.ref.outputs.name }}-${{ matrix.arch }}
|
||||||
type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}/cache/app:dev-${{ matrix.arch }}
|
type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}/cache/app:dev-${{ matrix.arch }}
|
||||||
cache-to: ${{ steps.check-push.outputs.should-push == 'true' && format('type=registry,mode=max,ref={0}/{1}/cache/app:{2}-{3}', env.REGISTRY, steps.repo.outputs.name, steps.ref.outputs.cache-ref, matrix.arch) || '' }}
|
cache-to: ${{ steps.check-push.outputs.should-push == 'true' && format('type=registry,mode=max,ref={0}/{1}/cache/app:{2}-{3}', env.REGISTRY, steps.repo.outputs.name, steps.ref.outputs.name, matrix.arch) || '' }}
|
||||||
- name: Export digest
|
- name: Export digest
|
||||||
if: steps.check-push.outputs.should-push == 'true'
|
if: steps.check-push.outputs.should-push == 'true'
|
||||||
run: |
|
run: |
|
||||||
@@ -179,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.6.0
|
uses: docker/login-action@v3.7.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.6.0
|
uses: docker/login-action@v3.7.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.6.0
|
uses: docker/login-action@v3.7.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,25 +6,34 @@ on:
|
|||||||
- dev
|
- dev
|
||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'mkdocs.yml'
|
- 'zensical.toml'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- 'uv.lock'
|
||||||
- '.github/workflows/ci-docs.yml'
|
- '.github/workflows/ci-docs.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'mkdocs.yml'
|
- 'zensical.toml'
|
||||||
|
- '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.9.x"
|
DEFAULT_UV_VERSION: "0.10.x"
|
||||||
DEFAULT_PYTHON_VERSION: "3.11"
|
DEFAULT_PYTHON_VERSION: "3.12"
|
||||||
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
|
||||||
@@ -47,42 +56,23 @@ jobs:
|
|||||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||||
--dev \
|
--dev \
|
||||||
--frozen \
|
--frozen \
|
||||||
mkdocs build --config-file ./mkdocs.yml
|
zensical build --clean
|
||||||
- name: Upload artifact
|
- name: Upload GitHub Pages artifact
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-pages-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: documentation
|
path: site
|
||||||
path: site/
|
name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
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: Checkout
|
- name: Deploy GitHub Pages
|
||||||
uses: actions/checkout@v6
|
uses: actions/deploy-pages@v4
|
||||||
- name: Set up Python
|
id: deployment
|
||||||
id: setup-python
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
- 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.57.0-noble
|
container: mcr.microsoft.com/playwright:v1.58.2-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:
|
||||||
pre-commit:
|
lint:
|
||||||
name: Pre-commit Checks
|
name: Linting via prek
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-slim
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6.0.2
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.14"
|
||||||
- name: Run pre-commit
|
- name: Run prek
|
||||||
uses: pre-commit/action@v3.0.1
|
uses: j178/prek-action@v1.1.1
|
||||||
|
|||||||
11
.github/workflows/ci-release.yml
vendored
11
.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.9.x"
|
DEFAULT_UV_VERSION: "0.10.x"
|
||||||
DEFAULT_PYTHON_VERSION: "3.11"
|
DEFAULT_PYTHON_VERSION: "3.12"
|
||||||
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.4.1
|
uses: lewagon/wait-on-check-action@v1.5.0
|
||||||
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 \
|
||||||
mkdocs build --config-file ./mkdocs.yml
|
zensical build --clean
|
||||||
# ---- Prepare Release ----
|
# ---- Prepare Release ----
|
||||||
- name: Generate requirements file
|
- name: Generate requirements file
|
||||||
run: |
|
run: |
|
||||||
@@ -155,6 +155,7 @@ jobs:
|
|||||||
version: ${{ steps.get-version.outputs.version }}
|
version: ${{ steps.get-version.outputs.version }}
|
||||||
prerelease: ${{ steps.get-version.outputs.prerelease }}
|
prerelease: ${{ steps.get-version.outputs.prerelease }}
|
||||||
publish: true
|
publish: true
|
||||||
|
commitish: main
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Upload release archive
|
- name: Upload release archive
|
||||||
@@ -210,7 +211,7 @@ jobs:
|
|||||||
uv run \
|
uv run \
|
||||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||||
--dev \
|
--dev \
|
||||||
pre-commit run --files changelog.md || true
|
prek run --files changelog.md || true
|
||||||
|
|
||||||
git config --global user.name "github-actions"
|
git config --global user.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"
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -40,6 +40,7 @@ htmlcov/
|
|||||||
.coverage
|
.coverage
|
||||||
.coverage.*
|
.coverage.*
|
||||||
.cache
|
.cache
|
||||||
|
.uv-cache
|
||||||
nosetests.xml
|
nosetests.xml
|
||||||
coverage.xml
|
coverage.xml
|
||||||
*,cover
|
*,cover
|
||||||
@@ -53,7 +54,7 @@ junit.xml
|
|||||||
# Django stuff:
|
# Django stuff:
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# MkDocs documentation
|
# Zensical documentation
|
||||||
site/
|
site/
|
||||||
|
|
||||||
# PyBuilder
|
# PyBuilder
|
||||||
|
|||||||
2467
.mypy-baseline.txt
Normal file
2467
.mypy-baseline.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
# This file configures pre-commit hooks.
|
# 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
|
||||||
@@ -37,7 +38,7 @@ repos:
|
|||||||
- json
|
- json
|
||||||
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
||||||
- repo: https://github.com/rbubley/mirrors-prettier
|
- repo: https://github.com/rbubley/mirrors-prettier
|
||||||
rev: 'v3.6.2'
|
rev: 'v3.8.1'
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or:
|
types_or:
|
||||||
@@ -49,12 +50,12 @@ repos:
|
|||||||
- 'prettier-plugin-organize-imports@4.1.0'
|
- 'prettier-plugin-organize-imports@4.1.0'
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.14.5
|
rev: v0.15.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||||
rev: "v2.11.1"
|
rev: "v2.12.1"
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyproject-fmt
|
- id: pyproject-fmt
|
||||||
# Dockerfile hooks
|
# Dockerfile hooks
|
||||||
@@ -76,7 +77,7 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: shellcheck
|
- id: shellcheck
|
||||||
- repo: https://github.com/google/yamlfmt
|
- repo: https://github.com/google/yamlfmt
|
||||||
rev: v0.20.0
|
rev: v0.21.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: yamlfmt
|
- id: yamlfmt
|
||||||
exclude: "^src-ui/pnpm-lock.yaml"
|
exclude: "^src-ui/pnpm-lock.yaml"
|
||||||
|
|||||||
17368
.pyrefly-baseline.json
Normal file
17368
.pyrefly-baseline.json
Normal file
File diff suppressed because one or more lines are too long
@@ -30,7 +30,7 @@ RUN set -eux \
|
|||||||
# Purpose: Installs s6-overlay and rootfs
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - Don't leave anything extra in here either
|
||||||
FROM ghcr.io/astral-sh/uv:0.9.26-python3.12-trixie-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.10.0-python3.12-trixie-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
@@ -154,8 +154,6 @@ 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.25
|
image: docker.io/gotenberg/gotenberg:8.26
|
||||||
hostname: gotenberg
|
hostname: gotenberg
|
||||||
container_name: gotenberg
|
container_name: gotenberg
|
||||||
network_mode: host
|
network_mode: host
|
||||||
@@ -23,3 +23,24 @@ services:
|
|||||||
container_name: tika
|
container_name: tika
|
||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
greenmail:
|
||||||
|
image: greenmail/standalone:2.1.8
|
||||||
|
hostname: greenmail
|
||||||
|
container_name: greenmail
|
||||||
|
environment:
|
||||||
|
# Enable only IMAP for now (SMTP available via 3025 if needed later)
|
||||||
|
GREENMAIL_OPTS: >-
|
||||||
|
-Dgreenmail.setup.test.imap -Dgreenmail.users=test@localhost:test -Dgreenmail.users.login=test@localhost -Dgreenmail.verbose
|
||||||
|
ports:
|
||||||
|
- "3143:3143" # IMAP
|
||||||
|
restart: unless-stopped
|
||||||
|
nginx:
|
||||||
|
image: docker.io/nginx:1.29.5-alpine
|
||||||
|
hostname: nginx
|
||||||
|
container_name: nginx
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ../../docs/assets:/usr/share/nginx/html/assets:ro
|
||||||
|
- ./test-nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.25
|
image: docker.io/gotenberg/gotenberg:8.26
|
||||||
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.25
|
image: docker.io/gotenberg/gotenberg:8.26
|
||||||
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.25
|
image: docker.io/gotenberg/gotenberg:8.26
|
||||||
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.
|
||||||
|
|||||||
14
docker/compose/test-nginx.conf
Normal file
14
docker/compose/test-nginx.conf
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
# Enable CORS for test requests
|
||||||
|
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, HEAD, OPTIONS' always;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ cd "${PAPERLESS_SRC_DIR}"
|
|||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py management_command "$@"
|
python3 manage.py management_command "$@"
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
elif [[ $(id -u) == 0 ]]; then
|
||||||
s6-setuidgid paperless python3 manage.py management_command "$@"
|
s6-setuidgid paperless python3 manage.py management_command "$@"
|
||||||
|
elif [[ $(id -un) == "paperless" ]]; then
|
||||||
|
python3 manage.py management_command "$@"
|
||||||
|
else
|
||||||
|
echo "Unknown user."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ set -e
|
|||||||
cd "${PAPERLESS_SRC_DIR}"
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py convert_mariadb_uuid "$@"
|
python3 manage.py convert_mariadb_uuid "$@"
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
elif [[ $(id -u) == 0 ]]; then
|
||||||
s6-setuidgid paperless python3 manage.py convert_mariadb_uuid "$@"
|
s6-setuidgid paperless python3 manage.py convert_mariadb_uuid "$@"
|
||||||
|
elif [[ $(id -un) == "paperless" ]]; then
|
||||||
|
python3 manage.py convert_mariadb_uuid "$@"
|
||||||
|
else
|
||||||
|
echo "Unknown user."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ set -e
|
|||||||
cd "${PAPERLESS_SRC_DIR}"
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py createsuperuser "$@"
|
python3 manage.py createsuperuser "$@"
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
elif [[ $(id -u) == 0 ]]; then
|
||||||
s6-setuidgid paperless python3 manage.py createsuperuser "$@"
|
s6-setuidgid paperless python3 manage.py createsuperuser "$@"
|
||||||
|
elif [[ $(id -un) == "paperless" ]]; then
|
||||||
|
python3 manage.py createsuperuser "$@"
|
||||||
|
else
|
||||||
|
echo "Unknown user."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ set -e
|
|||||||
cd "${PAPERLESS_SRC_DIR}"
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py document_archiver "$@"
|
python3 manage.py document_archiver "$@"
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
elif [[ $(id -u) == 0 ]]; then
|
||||||
s6-setuidgid paperless python3 manage.py document_archiver "$@"
|
s6-setuidgid paperless python3 manage.py document_archiver "$@"
|
||||||
|
elif [[ $(id -un) == "paperless" ]]; then
|
||||||
|
python3 manage.py document_archiver "$@"
|
||||||
|
else
|
||||||
|
echo "Unknown user."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ set -e
|
|||||||
cd "${PAPERLESS_SRC_DIR}"
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py document_create_classifier "$@"
|
python3 manage.py document_create_classifier "$@"
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
elif [[ $(id -u) == 0 ]]; then
|
||||||
s6-setuidgid paperless python3 manage.py document_create_classifier "$@"
|
s6-setuidgid paperless python3 manage.py document_create_classifier "$@"
|
||||||
|
elif [[ $(id -un) == "paperless" ]]; then
|
||||||
|
python3 manage.py document_create_classifier "$@"
|
||||||
|
else
|
||||||
|
echo "Unknown user."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ set -e
|
|||||||
cd "${PAPERLESS_SRC_DIR}"
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py document_exporter "$@"
|
python3 manage.py document_exporter "$@"
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
elif [[ $(id -u) == 0 ]]; then
|
||||||
s6-setuidgid paperless python3 manage.py document_exporter "$@"
|
s6-setuidgid paperless python3 manage.py document_exporter "$@"
|
||||||
|
elif [[ $(id -un) == "paperless" ]]; then
|
||||||
|
python3 manage.py document_exporter "$@"
|
||||||
|
else
|
||||||
|
echo "Unknown user."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ set -e
|
|||||||
cd "${PAPERLESS_SRC_DIR}"
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py document_fuzzy_match "$@"
|
python3 manage.py document_fuzzy_match "$@"
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
elif [[ $(id -u) == 0 ]]; then
|
||||||
s6-setuidgid paperless python3 manage.py document_fuzzy_match "$@"
|
s6-setuidgid paperless python3 manage.py document_fuzzy_match "$@"
|
||||||
|
elif [[ $(id -un) == "paperless" ]]; then
|
||||||
|
python3 manage.py document_fuzzy_match "$@"
|
||||||
|
else
|
||||||
|
echo "Unknown user."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ set -e
|
|||||||
cd "${PAPERLESS_SRC_DIR}"
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py document_importer "$@"
|
python3 manage.py document_importer "$@"
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
elif [[ $(id -u) == 0 ]]; then
|
||||||
s6-setuidgid paperless python3 manage.py document_importer "$@"
|
s6-setuidgid paperless python3 manage.py document_importer "$@"
|
||||||
|
elif [[ $(id -un) == "paperless" ]]; then
|
||||||
|
python3 manage.py document_importer "$@"
|
||||||
|
else
|
||||||
|
echo "Unknown user."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ set -e
|
|||||||
cd "${PAPERLESS_SRC_DIR}"
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py document_index "$@"
|
python3 manage.py document_index "$@"
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
elif [[ $(id -u) == 0 ]]; then
|
||||||
s6-setuidgid paperless python3 manage.py document_index "$@"
|
s6-setuidgid paperless python3 manage.py document_index "$@"
|
||||||
|
elif [[ $(id -un) == "paperless" ]]; then
|
||||||
|
python3 manage.py document_index "$@"
|
||||||
|
else
|
||||||
|
echo "Unknown user."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ set -e
|
|||||||
cd "${PAPERLESS_SRC_DIR}"
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py document_renamer "$@"
|
python3 manage.py document_renamer "$@"
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
elif [[ $(id -u) == 0 ]]; then
|
||||||
s6-setuidgid paperless python3 manage.py document_renamer "$@"
|
s6-setuidgid paperless python3 manage.py document_renamer "$@"
|
||||||
|
elif [[ $(id -un) == "paperless" ]]; then
|
||||||
|
python3 manage.py document_renamer "$@"
|
||||||
|
else
|
||||||
|
echo "Unknown user."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ set -e
|
|||||||
cd "${PAPERLESS_SRC_DIR}"
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py document_retagger "$@"
|
python3 manage.py document_retagger "$@"
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
elif [[ $(id -u) == 0 ]]; then
|
||||||
s6-setuidgid paperless python3 manage.py document_retagger "$@"
|
s6-setuidgid paperless python3 manage.py document_retagger "$@"
|
||||||
|
elif [[ $(id -un) == "paperless" ]]; then
|
||||||
|
python3 manage.py document_retagger "$@"
|
||||||
|
else
|
||||||
|
echo "Unknown user."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ set -e
|
|||||||
cd "${PAPERLESS_SRC_DIR}"
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py document_sanity_checker "$@"
|
python3 manage.py document_sanity_checker "$@"
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
elif [[ $(id -u) == 0 ]]; then
|
||||||
s6-setuidgid paperless python3 manage.py document_sanity_checker "$@"
|
s6-setuidgid paperless python3 manage.py document_sanity_checker "$@"
|
||||||
|
elif [[ $(id -un) == "paperless" ]]; then
|
||||||
|
python3 manage.py document_sanity_checker "$@"
|
||||||
|
else
|
||||||
|
echo "Unknown user."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ set -e
|
|||||||
cd "${PAPERLESS_SRC_DIR}"
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py document_thumbnails "$@"
|
python3 manage.py document_thumbnails "$@"
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
elif [[ $(id -u) == 0 ]]; then
|
||||||
s6-setuidgid paperless python3 manage.py document_thumbnails "$@"
|
s6-setuidgid paperless python3 manage.py document_thumbnails "$@"
|
||||||
|
elif [[ $(id -un) == "paperless" ]]; then
|
||||||
|
python3 manage.py document_thumbnails "$@"
|
||||||
|
else
|
||||||
|
echo "Unknown user."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ set -e
|
|||||||
cd "${PAPERLESS_SRC_DIR}"
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py mail_fetcher "$@"
|
python3 manage.py mail_fetcher "$@"
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
elif [[ $(id -u) == 0 ]]; then
|
||||||
s6-setuidgid paperless python3 manage.py mail_fetcher "$@"
|
s6-setuidgid paperless python3 manage.py mail_fetcher "$@"
|
||||||
|
elif [[ $(id -un) == "paperless" ]]; then
|
||||||
|
python3 manage.py mail_fetcher "$@"
|
||||||
|
else
|
||||||
|
echo "Unknown user."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ set -e
|
|||||||
cd "${PAPERLESS_SRC_DIR}"
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py manage_superuser "$@"
|
python3 manage.py manage_superuser "$@"
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
elif [[ $(id -u) == 0 ]]; then
|
||||||
s6-setuidgid paperless python3 manage.py manage_superuser "$@"
|
s6-setuidgid paperless python3 manage.py manage_superuser "$@"
|
||||||
|
elif [[ $(id -un) == "paperless" ]]; then
|
||||||
|
python3 manage.py manage_superuser "$@"
|
||||||
|
else
|
||||||
|
echo "Unknown user."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ set -e
|
|||||||
cd "${PAPERLESS_SRC_DIR}"
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
python3 manage.py prune_audit_logs "$@"
|
python3 manage.py prune_audit_logs "$@"
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
elif [[ $(id -u) == 0 ]]; then
|
||||||
s6-setuidgid paperless python3 manage.py prune_audit_logs "$@"
|
s6-setuidgid paperless python3 manage.py prune_audit_logs "$@"
|
||||||
|
elif [[ $(id -un) == "paperless" ]]; then
|
||||||
|
python3 manage.py prune_audit_logs "$@"
|
||||||
|
else
|
||||||
|
echo "Unknown user."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -582,7 +582,7 @@ document.
|
|||||||
|
|
||||||
### Detecting duplicates {#fuzzy_duplicate}
|
### Detecting duplicates {#fuzzy_duplicate}
|
||||||
|
|
||||||
Paperless already catches and prevents upload of exactly matching documents,
|
Paperless-ngx already catches and warns of exactly matching documents,
|
||||||
however a new scan of an existing document may not produce an exact bit for bit
|
however a new scan of an existing document may not produce an exact bit for bit
|
||||||
duplicate. But the content should be exact or close, allowing detection.
|
duplicate. But the content should be exact or close, allowing detection.
|
||||||
|
|
||||||
|
|||||||
@@ -774,7 +774,6 @@ 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).
|
||||||
@@ -805,6 +804,27 @@ See the relevant settings [`PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE`](configuratio
|
|||||||
and [`PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING`](configuration.md#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING)
|
and [`PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING`](configuration.md#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING)
|
||||||
for more information.
|
for more information.
|
||||||
|
|
||||||
|
#### Splitting on Tag Barcodes
|
||||||
|
|
||||||
|
By default, tag barcodes only assign tags to documents without splitting them. However,
|
||||||
|
you can enable document splitting on tag barcodes by setting
|
||||||
|
[`PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT`](configuration.md#PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT)
|
||||||
|
to `true`.
|
||||||
|
|
||||||
|
When enabled, documents will be split at pages containing tag barcodes, similar to how
|
||||||
|
ASN barcodes work. Key features:
|
||||||
|
|
||||||
|
- The page with the tag barcode is **retained** in the resulting document
|
||||||
|
- **Each split document extracts its own tags** - only tags on pages within that document are assigned
|
||||||
|
- Multiple tag barcodes can trigger multiple splits in the same document
|
||||||
|
- Works seamlessly with ASN barcodes - each split document gets its own ASN and tags
|
||||||
|
|
||||||
|
This is useful for batch scanning where you place tag barcode pages between different
|
||||||
|
documents to both separate and categorize them in a single operation.
|
||||||
|
|
||||||
|
**Example:** A 6-page scan with TAG:invoice on page 3 and TAG:receipt on page 5 will create
|
||||||
|
three documents: pages 1-2 (no tags), pages 3-4 (tagged "invoice"), and pages 5-6 (tagged "receipt").
|
||||||
|
|
||||||
## Automatic collation of double-sided documents {#collate}
|
## Automatic collation of double-sided documents {#collate}
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The REST API
|
# 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,13 +1,31 @@
|
|||||||
:root > * {
|
:root>* {
|
||||||
--md-primary-fg-color: #17541f;
|
--paperless-green: #17541f;
|
||||||
--md-primary-fg-color--dark: #17541f;
|
--paperless-green-accent: #2b8a38;
|
||||||
--md-primary-fg-color--light: #17541f;
|
--md-primary-fg-color: var(--paperless-green);
|
||||||
--md-accent-fg-color: #2b8a38;
|
--md-primary-fg-color--dark: var(--paperless-green);
|
||||||
|
--md-primary-fg-color--light: var(--paperless-green-accent);
|
||||||
|
--md-accent-fg-color: var(--paperless-green-accent);
|
||||||
--md-typeset-a-color: #21652a;
|
--md-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) {
|
||||||
@@ -69,8 +87,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 .md-nav__link[href*="PAPERLESS_"],
|
.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*="USERMAP_"] {
|
.md-nav.md-nav--secondary .md-nav__item:has(> .md-nav__link[href*="USERMAP_"]) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,18 +101,3 @@ 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: "/";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 2.20.6
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: extract all ids for nested tags [@shamoon](https://github.com/shamoon) ([#11888](https://github.com/paperless-ngx/paperless-ngx/pull/11888))
|
||||||
|
- Fixhancement: change date calculation for 'this year' to include future documents [@shamoon](https://github.com/shamoon) ([#11884](https://github.com/paperless-ngx/paperless-ngx/pull/11884))
|
||||||
|
- Fix: Running management scripts under rootless could fail [@stumpylog](https://github.com/stumpylog) ([#11870](https://github.com/paperless-ngx/paperless-ngx/pull/11870))
|
||||||
|
- Fix: use correct field id for overrides [@shamoon](https://github.com/shamoon) ([#11869](https://github.com/paperless-ngx/paperless-ngx/pull/11869))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>3 changes</summary>
|
||||||
|
|
||||||
|
- Fix: extract all ids for nested tags [@shamoon](https://github.com/shamoon) ([#11888](https://github.com/paperless-ngx/paperless-ngx/pull/11888))
|
||||||
|
- Fixhancement: change date calculation for 'this year' to include future documents [@shamoon](https://github.com/shamoon) ([#11884](https://github.com/paperless-ngx/paperless-ngx/pull/11884))
|
||||||
|
- Fix: use correct field id for overrides [@shamoon](https://github.com/shamoon) ([#11869](https://github.com/paperless-ngx/paperless-ngx/pull/11869))
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 2.20.5
|
## paperless-ngx 2.20.5
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -1222,14 +1222,6 @@ 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
|
||||||
@@ -1557,6 +1549,20 @@ assigns or creates tags if a properly formatted barcode is detected.
|
|||||||
|
|
||||||
Please refer to the Python regex documentation for more information.
|
Please refer to the Python regex documentation for more information.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT=<bool>`](#PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT) {#PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT}
|
||||||
|
|
||||||
|
: Enables splitting of documents on tag barcodes, similar to how ASN barcodes work.
|
||||||
|
|
||||||
|
When enabled, documents will be split into separate PDFs at pages containing
|
||||||
|
tag barcodes that match the configured `PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING`
|
||||||
|
patterns. The page with the tag barcode will be retained in the new document.
|
||||||
|
|
||||||
|
Each split document will have the detected tags assigned to it.
|
||||||
|
|
||||||
|
This only has an effect if `PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE` is also enabled.
|
||||||
|
|
||||||
|
Defaults to false.
|
||||||
|
|
||||||
## Audit Trail
|
## Audit Trail
|
||||||
|
|
||||||
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
|
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
|
||||||
@@ -1617,6 +1623,16 @@ processing. This only has an effect if
|
|||||||
|
|
||||||
Defaults to `0 1 * * *`, once per day.
|
Defaults to `0 1 * * *`, once per day.
|
||||||
|
|
||||||
|
## Share links
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON=<cron expression>`](#PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON) {#PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON}
|
||||||
|
|
||||||
|
: Controls how often Paperless-ngx removes expired share link bundles (and their generated ZIP archives).
|
||||||
|
|
||||||
|
: If set to the string "disable", expired bundles are not cleaned up automatically.
|
||||||
|
|
||||||
|
Defaults to `0 2 * * *`, once per day at 02:00.
|
||||||
|
|
||||||
## Binaries
|
## Binaries
|
||||||
|
|
||||||
There are a few external software packages that Paperless expects to
|
There are a few external software packages that Paperless expects to
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ first-time setup.
|
|||||||
5. Install pre-commit hooks:
|
5. Install pre-commit hooks:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ uv run pre-commit install
|
$ uv run prek install
|
||||||
```
|
```
|
||||||
|
|
||||||
6. Apply migrations and create a superuser (also can be done via the web UI) for your development instance:
|
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 pre-commit run prettier --files
|
$ git ls-files -- '*.ts' | xargs prek run prettier --files
|
||||||
```
|
```
|
||||||
|
|
||||||
Front end testing uses Jest and Playwright. Unit tests and e2e tests,
|
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 material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/).
|
The documentation is built using Zensical, see their [documentation](https://zensical.org/docs/).
|
||||||
If you want to build the documentation locally, this is how you do it:
|
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 mkdocs build --config-file mkdocs.yml
|
$ uv run zensical build
|
||||||
```
|
```
|
||||||
|
|
||||||
_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 mkdocs serve
|
$ uv run zensical serve
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building the Docker image
|
## Building the Docker image
|
||||||
@@ -481,3 +481,147 @@ To get started:
|
|||||||
|
|
||||||
5. The project is ready for debugging, start either run the fullstack debug or individual debug
|
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,3 +1,7 @@
|
|||||||
|
---
|
||||||
|
title: FAQs
|
||||||
|
---
|
||||||
|
|
||||||
# Frequently Asked Questions
|
# Frequently Asked Questions
|
||||||
|
|
||||||
## _What's the general plan for Paperless-ngx?_
|
## _What's the general plan for Paperless-ngx?_
|
||||||
@@ -63,8 +67,10 @@ elsewhere. Here are a couple notes about that.
|
|||||||
Paperless also supports various Office documents (.docx, .doc, odt,
|
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
|
||||||
The file extensions do not matter.
|
rather than its file extensions. However, files processed via the
|
||||||
|
consumption directory will be rejected if they have a file extension that
|
||||||
|
not supported by any of the available parsers.
|
||||||
|
|
||||||
## _Will paperless-ngx run on Raspberry Pi?_
|
## _Will paperless-ngx run on Raspberry Pi?_
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
---
|
||||||
|
title: Home
|
||||||
|
---
|
||||||
|
|
||||||
<div class="grid-left" markdown>
|
<div class="grid-left" markdown>
|
||||||
{.index-logo}
|
{.index-logo}
|
||||||
{.index-logo}
|
{.index-logo}
|
||||||
|
|||||||
@@ -23,3 +23,28 @@ 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,4 +1,8 @@
|
|||||||
## Installation
|
---
|
||||||
|
title: Setup
|
||||||
|
---
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
You can go multiple routes to setup and run Paperless:
|
You can go multiple routes to setup and run Paperless:
|
||||||
|
|
||||||
@@ -203,13 +207,12 @@ 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 libzbar0 poppler-utils
|
python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev 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,4 +1,8 @@
|
|||||||
# 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
|
||||||
@@ -308,12 +312,14 @@ or using [email](#workflow-action-email) or [webhook](#workflow-action-webhook)
|
|||||||
|
|
||||||
### Share Links
|
### Share Links
|
||||||
|
|
||||||
"Share links" are shareable public links to files and can be created and managed under the 'Send' button on the document detail screen.
|
"Share links" are public links to files (or an archive of files) and can be created and managed under the 'Send' button on the document detail screen or from the bulk editor.
|
||||||
|
|
||||||
- Share links do not require a user to login and thus link directly to a file.
|
- Share links do not require a user to login and thus link directly to a file or bundled download.
|
||||||
- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
|
- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
|
||||||
- Links can optionally have an expiration time set.
|
- Links can optionally have an expiration time set.
|
||||||
- After a link expires or is deleted users will be redirected to the regular paperless-ngx login.
|
- After a link expires or is deleted users will be redirected to the regular paperless-ngx login.
|
||||||
|
- From the document detail screen you can create a share link for that single document.
|
||||||
|
- From the bulk editor you can create a **share link bundle** for any selection. Paperless-ngx prepares a ZIP archive in the background and exposes a single share link. You can revisit the "Manage share link bundles" dialog to monitor progress, retry failed bundles, or delete links.
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
|
|
||||||
@@ -560,8 +566,8 @@ you may want to adjust these settings to prevent abuse.
|
|||||||
|
|
||||||
#### Workflow placeholders
|
#### Workflow placeholders
|
||||||
|
|
||||||
Titles can be assigned by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||||
This allows for complex logic to be used to generate the title, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
|
This allows for complex logic to be used, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
|
||||||
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
|
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.
|
||||||
|
|
||||||
@@ -584,7 +590,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
|
- `{{doc_title}}`: current document title (cannot be used in title assignment)
|
||||||
|
|
||||||
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
87
mkdocs.yml
@@ -1,87 +0,0 @@
|
|||||||
site_name: Paperless-ngx
|
|
||||||
theme:
|
|
||||||
name: material
|
|
||||||
logo: assets/logo.svg
|
|
||||||
font:
|
|
||||||
text: Roboto
|
|
||||||
code: Roboto Mono
|
|
||||||
palette:
|
|
||||||
# Palette toggle for automatic mode
|
|
||||||
- media: "(prefers-color-scheme)"
|
|
||||||
toggle:
|
|
||||||
icon: material/brightness-auto
|
|
||||||
name: Switch to light mode
|
|
||||||
# Palette toggle for light mode
|
|
||||||
- media: "(prefers-color-scheme: light)"
|
|
||||||
scheme: default
|
|
||||||
toggle:
|
|
||||||
icon: material/brightness-7
|
|
||||||
name: Switch to dark mode
|
|
||||||
# Palette toggle for dark mode
|
|
||||||
- media: "(prefers-color-scheme: dark)"
|
|
||||||
scheme: slate
|
|
||||||
toggle:
|
|
||||||
icon: material/brightness-4
|
|
||||||
name: Switch to system preference
|
|
||||||
features:
|
|
||||||
- navigation.tabs
|
|
||||||
- navigation.top
|
|
||||||
- toc.integrate
|
|
||||||
- content.code.annotate
|
|
||||||
icon:
|
|
||||||
repo: fontawesome/brands/github
|
|
||||||
favicon: assets/favicon.png
|
|
||||||
repo_url: https://github.com/paperless-ngx/paperless-ngx
|
|
||||||
repo_name: paperless-ngx/paperless-ngx
|
|
||||||
edit_uri: blob/main/docs/
|
|
||||||
extra_css:
|
|
||||||
- assets/extra.css
|
|
||||||
markdown_extensions:
|
|
||||||
- attr_list
|
|
||||||
- md_in_html
|
|
||||||
- def_list
|
|
||||||
- admonition
|
|
||||||
- tables
|
|
||||||
- pymdownx.highlight:
|
|
||||||
anchor_linenums: true
|
|
||||||
- pymdownx.superfences
|
|
||||||
- pymdownx.inlinehilite
|
|
||||||
- pymdownx.snippets
|
|
||||||
- pymdownx.tilde
|
|
||||||
- footnotes
|
|
||||||
- pymdownx.superfences:
|
|
||||||
custom_fences:
|
|
||||||
- name: mermaid
|
|
||||||
class: mermaid
|
|
||||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
|
||||||
- pymdownx.emoji:
|
|
||||||
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
|
||||||
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
|
||||||
strict: true
|
|
||||||
nav:
|
|
||||||
- index.md
|
|
||||||
- setup.md
|
|
||||||
- 'Basic Usage': usage.md
|
|
||||||
- configuration.md
|
|
||||||
- administration.md
|
|
||||||
- advanced_usage.md
|
|
||||||
- 'REST API': api.md
|
|
||||||
- development.md
|
|
||||||
- 'FAQs': faq.md
|
|
||||||
- troubleshooting.md
|
|
||||||
- 'Migration to v3': migration.md
|
|
||||||
- changelog.md
|
|
||||||
copyright: Copyright © 2016 - 2026 Daniel Quinn, Jonas Winkler, and the Paperless-ngx team
|
|
||||||
extra:
|
|
||||||
social:
|
|
||||||
- icon: fontawesome/brands/github
|
|
||||||
link: https://github.com/paperless-ngx/paperless-ngx
|
|
||||||
- icon: fontawesome/brands/docker
|
|
||||||
link: https://hub.docker.com/r/paperlessngx/paperless-ngx
|
|
||||||
- icon: material/chat
|
|
||||||
link: https://matrix.to/#/#paperless:matrix.org
|
|
||||||
plugins:
|
|
||||||
- search
|
|
||||||
- glightbox:
|
|
||||||
skip_classes:
|
|
||||||
- no-lightbox
|
|
||||||
@@ -33,6 +33,8 @@
|
|||||||
"**/coverage.json": true
|
"**/coverage.json": true
|
||||||
},
|
},
|
||||||
"python.defaultInterpreterPath": ".venv/bin/python3",
|
"python.defaultInterpreterPath": ".venv/bin/python3",
|
||||||
|
"python.analysis.inlayHints.pytestParameters": true,
|
||||||
|
"python.testing.pytestEnabled": true,
|
||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"recommendations": ["ms-python.python", "charliermarsh.ruff", "editorconfig.editorconfig"],
|
"recommendations": ["ms-python.python", "charliermarsh.ruff", "editorconfig.editorconfig"],
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
#PAPERLESS_CONSUMER_BARCODE_DPI=300
|
#PAPERLESS_CONSUMER_BARCODE_DPI=300
|
||||||
#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE=false
|
#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE=false
|
||||||
#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING={"TAG:(.*)": "\\g<1>"}
|
#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING={"TAG:(.*)": "\\g<1>"}
|
||||||
|
#PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT=false
|
||||||
#PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=false
|
#PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=false
|
||||||
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME=double-sided
|
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME=double-sided
|
||||||
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT=false
|
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT=false
|
||||||
|
|||||||
123
pyproject.toml
123
pyproject.toml
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "2.20.5"
|
version = "2.20.6"
|
||||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
@@ -19,17 +19,17 @@ dependencies = [
|
|||||||
"azure-ai-documentintelligence>=1.0.2",
|
"azure-ai-documentintelligence>=1.0.2",
|
||||||
"babel>=2.17",
|
"babel>=2.17",
|
||||||
"bleach~=6.3.0",
|
"bleach~=6.3.0",
|
||||||
"celery[redis]~=5.5.1",
|
"celery[redis]~=5.6.2",
|
||||||
"channels~=4.2",
|
"channels~=4.2",
|
||||||
"channels-redis~=4.2",
|
"channels-redis~=4.2",
|
||||||
"concurrent-log-handler~=0.9.25",
|
"concurrent-log-handler~=0.9.25",
|
||||||
"dateparser~=1.2",
|
"dateparser~=1.2",
|
||||||
# WARNING: django does not use semver.
|
# WARNING: django does not use semver.
|
||||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||||
"django~=5.2.5",
|
"django~=5.2.10",
|
||||||
"django-allauth[mfa,socialaccount]~=65.13.1",
|
"django-allauth[mfa,socialaccount]~=65.14.0",
|
||||||
"django-auditlog~=3.4.1",
|
"django-auditlog~=3.4.1",
|
||||||
"django-cachalot~=2.8.0",
|
"django-cachalot~=2.9.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~=2025.10.1",
|
"drf-spectacular-sidecar~=2026.1.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,7 +68,6 @@ 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",
|
||||||
@@ -76,25 +75,25 @@ 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.9.1",
|
"torch~=2.10.0",
|
||||||
"tqdm~=4.67.1",
|
"tqdm~=4.67.1",
|
||||||
"watchfiles>=1.1.1",
|
"watchfiles>=1.1.1",
|
||||||
"whitenoise~=6.9",
|
"whitenoise~=6.11",
|
||||||
"whoosh-reloaded>=2.7.5",
|
"whoosh-reloaded>=2.7.5",
|
||||||
"zxing-cpp~=2.3.0",
|
"zxing-cpp~=3.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
optional-dependencies.mariadb = [
|
optional-dependencies.mariadb = [
|
||||||
"mysqlclient~=2.2.7",
|
"mysqlclient~=2.2.7",
|
||||||
]
|
]
|
||||||
optional-dependencies.postgres = [
|
optional-dependencies.postgres = [
|
||||||
"psycopg[c,pool]==3.2.12",
|
"psycopg[c,pool]==3.3",
|
||||||
# Direct dependency for proper resolution of the pre-built wheels
|
# Direct dependency for proper resolution of the pre-built wheels
|
||||||
"psycopg-c==3.2.12",
|
"psycopg-c==3.3",
|
||||||
"psycopg-pool==3.3",
|
"psycopg-pool==3.3",
|
||||||
]
|
]
|
||||||
optional-dependencies.webserver = [
|
optional-dependencies.webserver = [
|
||||||
"granian[uvloop]~=2.5.1",
|
"granian[uvloop]~=2.7.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -106,29 +105,28 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
docs = [
|
docs = [
|
||||||
"mkdocs-glightbox~=0.5.1",
|
"zensical>=0.0.21",
|
||||||
"mkdocs-material~=9.7.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
testing = [
|
testing = [
|
||||||
"daphne",
|
"daphne",
|
||||||
"factory-boy~=3.3.1",
|
"factory-boy~=3.3.1",
|
||||||
"imagehash",
|
"imagehash",
|
||||||
"pytest~=8.4.1",
|
"pytest~=9.0.0",
|
||||||
"pytest-cov~=7.0.0",
|
"pytest-cov~=7.0.0",
|
||||||
"pytest-django~=4.11.1",
|
"pytest-django~=4.11.1",
|
||||||
"pytest-env",
|
"pytest-env~=1.2.0",
|
||||||
"pytest-httpx",
|
"pytest-httpx",
|
||||||
"pytest-mock",
|
"pytest-mock~=3.15.1",
|
||||||
"pytest-rerunfailures",
|
#"pytest-randomly~=4.0.1",
|
||||||
|
"pytest-rerunfailures~=16.1",
|
||||||
"pytest-sugar",
|
"pytest-sugar",
|
||||||
"pytest-xdist",
|
"pytest-xdist~=3.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
lint = [
|
lint = [
|
||||||
"pre-commit~=4.5.1",
|
"prek~=0.3.0",
|
||||||
"pre-commit-uv~=4.2.0",
|
"ruff~=0.15.0",
|
||||||
"ruff~=0.14.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
typing = [
|
typing = [
|
||||||
@@ -137,8 +135,12 @@ 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",
|
||||||
@@ -151,33 +153,29 @@ typing = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
required-version = ">=0.5.14"
|
required-version = ">=0.9.0"
|
||||||
package = false
|
package = false
|
||||||
environments = [
|
environments = [
|
||||||
"sys_platform == 'darwin'",
|
"sys_platform == 'darwin'",
|
||||||
"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-bookworm-3.2.12/psycopg_c-3.2.12-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-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-bookworm-3.2.12/psycopg_c-3.2.12-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
{ 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
|
||||||
@@ -260,11 +258,15 @@ write-changes = true
|
|||||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
||||||
skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json"
|
skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest]
|
||||||
minversion = "8.0"
|
minversion = "9.0"
|
||||||
pythonpath = [
|
pythonpath = [ "src" ]
|
||||||
"src",
|
|
||||||
]
|
strict_config = true
|
||||||
|
strict_markers = true
|
||||||
|
strict_parametrization_ids = true
|
||||||
|
strict_xfail = true
|
||||||
|
|
||||||
testpaths = [
|
testpaths = [
|
||||||
"src/documents/tests/",
|
"src/documents/tests/",
|
||||||
"src/paperless/tests/",
|
"src/paperless/tests/",
|
||||||
@@ -275,6 +277,7 @@ testpaths = [
|
|||||||
"src/paperless_remote/tests/",
|
"src/paperless_remote/tests/",
|
||||||
"src/paperless_ai/tests",
|
"src/paperless_ai/tests",
|
||||||
]
|
]
|
||||||
|
|
||||||
addopts = [
|
addopts = [
|
||||||
"--pythonwarnings=all",
|
"--pythonwarnings=all",
|
||||||
"--cov",
|
"--cov",
|
||||||
@@ -282,19 +285,38 @@ addopts = [
|
|||||||
"--cov-report=xml",
|
"--cov-report=xml",
|
||||||
"--numprocesses=auto",
|
"--numprocesses=auto",
|
||||||
"--maxprocesses=16",
|
"--maxprocesses=16",
|
||||||
"--quiet",
|
"--dist=loadscope",
|
||||||
"--durations=50",
|
"--durations=50",
|
||||||
|
"--durations-min=0.5",
|
||||||
"--junitxml=junit.xml",
|
"--junitxml=junit.xml",
|
||||||
"-o junit_family=legacy",
|
"-o",
|
||||||
|
"junit_family=legacy",
|
||||||
]
|
]
|
||||||
|
|
||||||
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
|
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
|
||||||
|
|
||||||
DJANGO_SETTINGS_MODULE = "paperless.settings"
|
DJANGO_SETTINGS_MODULE = "paperless.settings"
|
||||||
|
|
||||||
|
markers = [
|
||||||
|
"live: Integration tests requiring external services (Gotenberg, Tika, nginx, etc)",
|
||||||
|
"nginx: Tests that make HTTP requests to the local nginx service",
|
||||||
|
"gotenberg: Tests requiring Gotenberg service",
|
||||||
|
"tika: Tests requiring Tika service",
|
||||||
|
"greenmail: Tests requiring Greenmail service",
|
||||||
|
"date_parsing: Tests which cover date parsing from content or filename",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.pytest_env]
|
[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/",
|
||||||
@@ -306,13 +328,6 @@ 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 = [
|
||||||
@@ -326,5 +341,15 @@ 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,7 +86,6 @@
|
|||||||
],
|
],
|
||||||
"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('pdf-viewer')
|
await page.waitForSelector('pngx-pdf-viewer')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should show a list of notes', async ({ page }) => {
|
test('should show a list of notes', async ({ page }) => {
|
||||||
|
|||||||
@@ -180,6 +180,9 @@ test('bulk edit', async ({ page }) => {
|
|||||||
await page.locator('pngx-document-card-small').nth(2).click()
|
await page.locator('pngx-document-card-small').nth(2).click()
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Tags' }).click()
|
await page.getByRole('button', { name: 'Tags' }).click()
|
||||||
|
await page
|
||||||
|
.getByRole('textbox', { name: 'Filter tags' })
|
||||||
|
.fill('TagWithPartial')
|
||||||
await page.getByRole('menuitem', { name: 'TagWithPartial' }).click()
|
await page.getByRole('menuitem', { name: 'TagWithPartial' }).click()
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Apply' }).click()
|
await page.getByRole('button', { name: 'Apply' }).click()
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ 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: [
|
||||||
|
|||||||
1801
src-ui/messages.xlf
1801
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "2.20.5",
|
"version": "2.20.6",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
@@ -11,55 +11,55 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^21.0.6",
|
"@angular/cdk": "^21.1.3",
|
||||||
"@angular/common": "~21.0.8",
|
"@angular/common": "~21.1.3",
|
||||||
"@angular/compiler": "~21.0.8",
|
"@angular/compiler": "~21.1.3",
|
||||||
"@angular/core": "~21.0.8",
|
"@angular/core": "~21.1.3",
|
||||||
"@angular/forms": "~21.0.8",
|
"@angular/forms": "~21.1.3",
|
||||||
"@angular/localize": "~21.0.8",
|
"@angular/localize": "~21.1.3",
|
||||||
"@angular/platform-browser": "~21.0.8",
|
"@angular/platform-browser": "~21.1.3",
|
||||||
"@angular/platform-browser-dynamic": "~21.0.8",
|
"@angular/platform-browser-dynamic": "~21.1.3",
|
||||||
"@angular/router": "~21.0.8",
|
"@angular/router": "~21.1.3",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
||||||
"@ng-select/ng-select": "^21.1.4",
|
"@ng-select/ng-select": "^21.2.0",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"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": "^17.0.1",
|
"ngx-ui-tour-ng-bootstrap": "^18.0.0",
|
||||||
|
"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",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zone.js": "^0.15.1"
|
"zone.js": "^0.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "^21.0.0-beta.1",
|
"@angular-builders/custom-webpack": "^21.0.3",
|
||||||
"@angular-builders/jest": "^21.0.0-beta.1",
|
"@angular-builders/jest": "^21.0.3",
|
||||||
"@angular-devkit/core": "^21.0.5",
|
"@angular-devkit/core": "^21.1.3",
|
||||||
"@angular-devkit/schematics": "^21.0.5",
|
"@angular-devkit/schematics": "^21.1.3",
|
||||||
"@angular-eslint/builder": "21.1.0",
|
"@angular-eslint/builder": "21.2.0",
|
||||||
"@angular-eslint/eslint-plugin": "21.1.0",
|
"@angular-eslint/eslint-plugin": "21.2.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "21.1.0",
|
"@angular-eslint/eslint-plugin-template": "21.2.0",
|
||||||
"@angular-eslint/schematics": "21.1.0",
|
"@angular-eslint/schematics": "21.2.0",
|
||||||
"@angular-eslint/template-parser": "21.1.0",
|
"@angular-eslint/template-parser": "21.2.0",
|
||||||
"@angular/build": "^21.0.5",
|
"@angular/build": "^21.1.3",
|
||||||
"@angular/cli": "~21.0.5",
|
"@angular/cli": "~21.1.3",
|
||||||
"@angular/compiler-cli": "~21.0.8",
|
"@angular/compiler-cli": "~21.1.3",
|
||||||
"@codecov/webpack-plugin": "^1.9.1",
|
"@codecov/webpack-plugin": "^1.9.1",
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.58.2",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^25.2.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||||
"@typescript-eslint/parser": "^8.48.1",
|
"@typescript-eslint/parser": "^8.54.0",
|
||||||
"@typescript-eslint/utils": "^8.48.1",
|
"@typescript-eslint/utils": "^8.54.0",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.2",
|
||||||
"jest": "30.2.0",
|
"jest": "30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"jest-junit": "^16.0.0",
|
"jest-junit": "^16.0.0",
|
||||||
@@ -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.103.0"
|
"webpack": "^5.105.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.17.1",
|
"packageManager": "pnpm@10.17.1",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
|||||||
5572
src-ui/pnpm-lock.yaml
generated
5572
src-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -100,10 +100,10 @@ const mock = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.defineProperty(window, 'open', { value: jest.fn() })
|
Object.defineProperty(globalThis, 'open', { value: jest.fn() })
|
||||||
Object.defineProperty(window, 'localStorage', { value: mock() })
|
Object.defineProperty(globalThis, 'localStorage', { value: mock() })
|
||||||
Object.defineProperty(window, 'sessionStorage', { value: mock() })
|
Object.defineProperty(globalThis, 'sessionStorage', { value: mock() })
|
||||||
Object.defineProperty(window, 'getComputedStyle', {
|
Object.defineProperty(globalThis, 'getComputedStyle', {
|
||||||
value: () => ['-webkit-appearance'],
|
value: () => ['-webkit-appearance'],
|
||||||
})
|
})
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
@@ -115,13 +115,33 @@ 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 (!URL.createObjectURL) {
|
if (!globalThis.URL.createObjectURL) {
|
||||||
Object.defineProperty(window.URL, 'createObjectURL', { value: jest.fn() })
|
Object.defineProperty(globalThis.URL, 'createObjectURL', { value: jest.fn() })
|
||||||
}
|
}
|
||||||
if (!URL.revokeObjectURL) {
|
if (!globalThis.URL.revokeObjectURL) {
|
||||||
Object.defineProperty(window.URL, 'revokeObjectURL', { value: jest.fn() })
|
Object.defineProperty(globalThis.URL, 'revokeObjectURL', { value: jest.fn() })
|
||||||
}
|
}
|
||||||
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
|
class MockResizeObserver {
|
||||||
|
private readonly callback: ResizeObserverCallback
|
||||||
|
|
||||||
|
constructor(callback: ResizeObserverCallback) {
|
||||||
|
this.callback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
observe = jest.fn()
|
||||||
|
unobserve = jest.fn()
|
||||||
|
disconnect = jest.fn()
|
||||||
|
|
||||||
|
trigger = (entries: ResizeObserverEntry[] = []) => {
|
||||||
|
this.callback(entries, this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'ResizeObserver', {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: MockResizeObserver,
|
||||||
|
})
|
||||||
|
|
||||||
if (typeof IntersectionObserver === 'undefined') {
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
class MockIntersectionObserver {
|
class MockIntersectionObserver {
|
||||||
@@ -136,7 +156,7 @@ if (typeof IntersectionObserver === 'undefined') {
|
|||||||
takeRecords = jest.fn()
|
takeRecords = jest.fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.defineProperty(window, 'IntersectionObserver', {
|
Object.defineProperty(globalThis, 'IntersectionObserver', {
|
||||||
writable: true,
|
writable: true,
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: MockIntersectionObserver,
|
value: MockIntersectionObserver,
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ 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 { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
import {
|
||||||
|
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'
|
||||||
@@ -40,12 +44,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: [
|
||||||
@@ -53,6 +57,7 @@ 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 { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrap, 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,12 +21,7 @@ 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: [
|
imports: [FileDropComponent, ToastsComponent, TourNgBootstrap, RouterOutlet],
|
||||||
FileDropComponent,
|
|
||||||
ToastsComponent,
|
|
||||||
TourNgBootstrapModule,
|
|
||||||
RouterOutlet,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class AppComponent implements OnInit, OnDestroy {
|
export class AppComponent implements OnInit, OnDestroy {
|
||||||
private settings = inject(SettingsService)
|
private settings = inject(SettingsService)
|
||||||
@@ -167,108 +162,91 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevBtnTitle = $localize`Prev`
|
this.tourService.initialize([
|
||||||
const nextBtnTitle = $localize`Next`
|
|
||||||
const endBtnTitle = $localize`End`
|
|
||||||
|
|
||||||
this.tourService.initialize(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
anchorId: 'tour.dashboard',
|
|
||||||
content: $localize`The dashboard can be used to show saved views, such as an 'Inbox'. Views are found under Manage > Saved Views once you have created some.`,
|
|
||||||
route: '/dashboard',
|
|
||||||
delayAfterNavigation: 500,
|
|
||||||
isOptional: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
anchorId: 'tour.upload-widget',
|
|
||||||
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.`,
|
|
||||||
route: '/dashboard',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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.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,
|
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.`,
|
||||||
|
route: '/dashboard',
|
||||||
|
delayAfterNavigation: 500,
|
||||||
|
isOptional: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anchorId: 'tour.upload-widget',
|
||||||
|
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.`,
|
||||||
|
route: '/dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: {
|
backdropConfig: {
|
||||||
offset: 10,
|
offset: 0,
|
||||||
},
|
},
|
||||||
prevBtnTitle,
|
},
|
||||||
nextBtnTitle,
|
{
|
||||||
endBtnTitle,
|
anchorId: 'tour.mail',
|
||||||
isOptional: true,
|
content: $localize`Manage e-mail accounts and rules for automatically importing documents.`,
|
||||||
useLegacyTitle: true,
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
this.tourService.start$.subscribe(() => {
|
this.tourService.start$.subscribe(() => {
|
||||||
this.renderer.addClass(document.body, 'tour-active')
|
this.renderer.addClass(document.body, 'tour-active')
|
||||||
|
|||||||
@@ -103,22 +103,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-3 col-form-label pt-0">
|
|
||||||
<span i18n>Items per page</span>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
|
|
||||||
<select class="form-select" formControlName="documentListItemPerPage">
|
|
||||||
<option [ngValue]="10">10</option>
|
|
||||||
<option [ngValue]="25">25</option>
|
|
||||||
<option [ngValue]="50">50</option>
|
|
||||||
<option [ngValue]="100">100</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-3 col-form-label pt-0">
|
<div class="col-md-3 col-form-label pt-0">
|
||||||
<span i18n>Sidebar</span>
|
<span i18n>Sidebar</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,8 +137,28 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-6 ps-xl-5">
|
||||||
|
<h5 class="mt-3 mt-md-0" i18n>Global search</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h5 class="mt-3" id="update-checking" i18n>Update checking</h5>
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-3 col-form-label pt-0">
|
||||||
|
<span i18n>Full search links to</span>
|
||||||
|
</div>
|
||||||
|
<div class="col mb-3">
|
||||||
|
<select class="form-select" formControlName="searchLink">
|
||||||
|
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
|
||||||
|
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h5 class="mt-3 mt-md-0" id="update-checking" i18n>Update checking</h5>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col d-flex flex-row align-items-start">
|
<div class="col d-flex flex-row align-items-start">
|
||||||
<pngx-input-check i18n-title title="Enable update checking" formControlName="updateCheckingEnabled"></pngx-input-check>
|
<pngx-input-check i18n-title title="Enable update checking" formControlName="updateCheckingEnabled"></pngx-input-check>
|
||||||
@@ -179,11 +183,33 @@
|
|||||||
<pngx-input-check i18n-title title="Show document counts in sidebar saved views" formControlName="sidebarViewsShowCount"></pngx-input-check>
|
<pngx-input-check i18n-title title="Show document counts in sidebar saved views" formControlName="sidebarViewsShowCount"></pngx-input-check>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xl-6 ps-xl-5">
|
</div>
|
||||||
<h5 class="mt-3 mt-md-0" i18n>Document editing</h5>
|
|
||||||
|
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li [ngbNavItem]="SettingsNavIDs.Documents">
|
||||||
|
<a ngbNavLink i18n>Documents</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-6 pe-xl-5">
|
||||||
|
<h5 i18n>Documents</h5>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-3 col-form-label pt-0">
|
||||||
|
<span i18n>Items per page</span>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<select class="form-select" formControlName="documentListItemPerPage">
|
||||||
|
<option [ngValue]="10">10</option>
|
||||||
|
<option [ngValue]="25">25</option>
|
||||||
|
<option [ngValue]="50">50</option>
|
||||||
|
<option [ngValue]="100">100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h5 class="mt-3" i18n>Document editing</h5>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<pngx-input-check i18n-title title="Use PDF viewer provided by the browser" i18n-hint hint="This is usually faster for displaying large PDF documents, but it might not work on some browsers." formControlName="useNativePdfViewer"></pngx-input-check>
|
<pngx-input-check i18n-title title="Use PDF viewer provided by the browser" i18n-hint hint="This is usually faster for displaying large PDF documents, but it might not work on some browsers." formControlName="useNativePdfViewer"></pngx-input-check>
|
||||||
@@ -196,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]="ZoomSetting.PageWidth" i18n>Fit width</option>
|
<option [ngValue]="PdfZoomScale.PageWidth" i18n>Fit width</option>
|
||||||
<option [ngValue]="ZoomSetting.PageFit" i18n>Fit page</option>
|
<option [ngValue]="PdfZoomScale.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>
|
||||||
@@ -209,31 +235,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check>
|
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h5 class="mt-3" i18n>Global search</h5>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-3 col-form-label pt-0">
|
<div class="col">
|
||||||
<span i18n>Full search links to</span>
|
<p class="mb-2" i18n>Built-in fields to show:</p>
|
||||||
</div>
|
@for (option of documentDetailFieldOptions; track option.id) {
|
||||||
<div class="col mb-3">
|
<div class="form-check ms-3">
|
||||||
<select class="form-select" formControlName="searchLink">
|
<input class="form-check-input" type="checkbox"
|
||||||
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
|
[id]="'documentDetailField-' + option.id"
|
||||||
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
|
[checked]="isDocumentDetailFieldShown(option.id)"
|
||||||
</select>
|
(change)="toggleDocumentDetailField(option.id, $event.target.checked)" />
|
||||||
|
<label class="form-check-label" [for]="'documentDetailField-' + option.id">
|
||||||
|
{{ option.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<p class="small text-muted mt-1" i18n>Uncheck fields to hide them on the document details page.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-xl-6 ps-xl-5">
|
||||||
<h5 class="mt-3" i18n>Bulk editing</h5>
|
<h5 class="mt-3" i18n>Bulk editing</h5>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@@ -242,16 +269,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h5 class="mt-3" i18n>PDF Editor</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 col-form-label pt-0">
|
||||||
|
<span i18n>Default editing mode</span>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<select class="form-select" formControlName="pdfEditorDefaultEditMode">
|
||||||
|
<option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option>
|
||||||
|
<option [ngValue]="PdfEditorEditMode.Update" i18n>Update existing document</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h5 class="mt-3" i18n>Notes</h5>
|
<h5 class="mt-3" i18n>Notes</h5>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check>
|
<pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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 {
|
||||||
@@ -147,6 +148,7 @@ describe('SettingsComponent', () => {
|
|||||||
PermissionsGuard,
|
PermissionsGuard,
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
|
provideUiTour(),
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
@@ -201,9 +203,9 @@ describe('SettingsComponent', () => {
|
|||||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||||
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
|
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
|
||||||
tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click'))
|
tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
|
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'documents'])
|
||||||
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
|
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications'])
|
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
|
||||||
|
|
||||||
const initSpy = jest.spyOn(component, 'initialize')
|
const initSpy = jest.spyOn(component, 'initialize')
|
||||||
component.isDirty = true // mock dirty
|
component.isDirty = true // mock dirty
|
||||||
@@ -213,8 +215,8 @@ describe('SettingsComponent', () => {
|
|||||||
expect(initSpy).not.toHaveBeenCalled()
|
expect(initSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty
|
navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty
|
||||||
tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click'))
|
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications'])
|
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
|
||||||
expect(initSpy).toHaveBeenCalled()
|
expect(initSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -226,7 +228,7 @@ describe('SettingsComponent', () => {
|
|||||||
activatedRoute.snapshot.fragment = '#notifications'
|
activatedRoute.snapshot.fragment = '#notifications'
|
||||||
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
|
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.activeNavID).toEqual(3) // Notifications
|
expect(component.activeNavID).toEqual(4) // Notifications
|
||||||
component.ngAfterViewInit()
|
component.ngAfterViewInit()
|
||||||
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
|
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
|
||||||
})
|
})
|
||||||
@@ -251,7 +253,7 @@ describe('SettingsComponent', () => {
|
|||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(storeSpy).toHaveBeenCalled()
|
expect(storeSpy).toHaveBeenCalled()
|
||||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||||
expect(setSpy).toHaveBeenCalledTimes(30)
|
expect(setSpy).toHaveBeenCalledTimes(32)
|
||||||
|
|
||||||
// succeed
|
// succeed
|
||||||
storeSpy.mockReturnValueOnce(of(true))
|
storeSpy.mockReturnValueOnce(of(true))
|
||||||
@@ -366,4 +368,22 @@ describe('SettingsComponent', () => {
|
|||||||
settingsService.settingsSaved.emit(true)
|
settingsService.settingsSaved.emit(true)
|
||||||
expect(maybeRefreshSpy).toHaveBeenCalled()
|
expect(maybeRefreshSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should support toggling document detail fields', () => {
|
||||||
|
completeSetup()
|
||||||
|
const field = 'storage_path'
|
||||||
|
expect(
|
||||||
|
component.settingsForm.get('documentDetailsHiddenFields').value.length
|
||||||
|
).toEqual(0)
|
||||||
|
component.toggleDocumentDetailField(field, false)
|
||||||
|
expect(
|
||||||
|
component.settingsForm.get('documentDetailsHiddenFields').value.length
|
||||||
|
).toEqual(1)
|
||||||
|
expect(component.isDocumentDetailFieldShown(field)).toBeFalsy()
|
||||||
|
component.toggleDocumentDetailField(field, true)
|
||||||
|
expect(
|
||||||
|
component.settingsForm.get('documentDetailsHiddenFields').value.length
|
||||||
|
).toEqual(0)
|
||||||
|
expect(component.isDocumentDetailFieldShown(field)).toBeTruthy()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -64,15 +64,16 @@ import { PermissionsGroupComponent } from '../../common/input/permissions/permis
|
|||||||
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
|
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
|
||||||
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 { 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/document-detail.component'
|
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
|
|
||||||
enum SettingsNavIDs {
|
enum SettingsNavIDs {
|
||||||
General = 1,
|
General = 1,
|
||||||
Permissions = 2,
|
Documents = 2,
|
||||||
Notifications = 3,
|
Permissions = 3,
|
||||||
SavedViews = 4,
|
Notifications = 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemLanguage = { code: '', name: $localize`Use system language` }
|
const systemLanguage = { code: '', name: $localize`Use system language` }
|
||||||
@@ -81,6 +82,25 @@ const systemDateFormat = {
|
|||||||
name: $localize`Use date format of display language`,
|
name: $localize`Use date format of display language`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum DocumentDetailFieldID {
|
||||||
|
ArchiveSerialNumber = 'archive_serial_number',
|
||||||
|
Correspondent = 'correspondent',
|
||||||
|
DocumentType = 'document_type',
|
||||||
|
StoragePath = 'storage_path',
|
||||||
|
Tags = 'tags',
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentDetailFieldOptions = [
|
||||||
|
{
|
||||||
|
id: DocumentDetailFieldID.ArchiveSerialNumber,
|
||||||
|
label: $localize`Archive serial number`,
|
||||||
|
},
|
||||||
|
{ id: DocumentDetailFieldID.Correspondent, label: $localize`Correspondent` },
|
||||||
|
{ id: DocumentDetailFieldID.DocumentType, label: $localize`Document type` },
|
||||||
|
{ id: DocumentDetailFieldID.StoragePath, label: $localize`Storage path` },
|
||||||
|
{ id: DocumentDetailFieldID.Tags, label: $localize`Tags` },
|
||||||
|
]
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-settings',
|
selector: 'pngx-settings',
|
||||||
templateUrl: './settings.component.html',
|
templateUrl: './settings.component.html',
|
||||||
@@ -144,8 +164,10 @@ export class SettingsComponent
|
|||||||
defaultPermsEditGroups: new FormControl(null),
|
defaultPermsEditGroups: new FormControl(null),
|
||||||
useNativePdfViewer: new FormControl(null),
|
useNativePdfViewer: new FormControl(null),
|
||||||
pdfViewerDefaultZoom: new FormControl(null),
|
pdfViewerDefaultZoom: new FormControl(null),
|
||||||
|
pdfEditorDefaultEditMode: new FormControl(null),
|
||||||
documentEditingRemoveInboxTags: new FormControl(null),
|
documentEditingRemoveInboxTags: new FormControl(null),
|
||||||
documentEditingOverlayThumbnail: new FormControl(null),
|
documentEditingOverlayThumbnail: new FormControl(null),
|
||||||
|
documentDetailsHiddenFields: new FormControl([]),
|
||||||
searchDbOnly: new FormControl(null),
|
searchDbOnly: new FormControl(null),
|
||||||
searchLink: new FormControl(null),
|
searchLink: new FormControl(null),
|
||||||
|
|
||||||
@@ -174,7 +196,11 @@ export class SettingsComponent
|
|||||||
|
|
||||||
public readonly GlobalSearchType = GlobalSearchType
|
public readonly GlobalSearchType = GlobalSearchType
|
||||||
|
|
||||||
public readonly ZoomSetting = ZoomSetting
|
public readonly PdfZoomScale = PdfZoomScale
|
||||||
|
|
||||||
|
public readonly PdfEditorEditMode = PdfEditorEditMode
|
||||||
|
|
||||||
|
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
||||||
|
|
||||||
get systemStatusHasErrors(): boolean {
|
get systemStatusHasErrors(): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -292,6 +318,9 @@ export class SettingsComponent
|
|||||||
pdfViewerDefaultZoom: this.settings.get(
|
pdfViewerDefaultZoom: this.settings.get(
|
||||||
SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING
|
SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING
|
||||||
),
|
),
|
||||||
|
pdfEditorDefaultEditMode: this.settings.get(
|
||||||
|
SETTINGS_KEYS.PDF_EDITOR_DEFAULT_EDIT_MODE
|
||||||
|
),
|
||||||
displayLanguage: this.settings.getLanguage(),
|
displayLanguage: this.settings.getLanguage(),
|
||||||
dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE),
|
dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE),
|
||||||
dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT),
|
dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT),
|
||||||
@@ -336,6 +365,9 @@ export class SettingsComponent
|
|||||||
documentEditingOverlayThumbnail: this.settings.get(
|
documentEditingOverlayThumbnail: this.settings.get(
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
||||||
),
|
),
|
||||||
|
documentDetailsHiddenFields: this.settings.get(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
|
||||||
|
),
|
||||||
searchDbOnly: this.settings.get(SETTINGS_KEYS.SEARCH_DB_ONLY),
|
searchDbOnly: this.settings.get(SETTINGS_KEYS.SEARCH_DB_ONLY),
|
||||||
searchLink: this.settings.get(SETTINGS_KEYS.SEARCH_FULL_TYPE),
|
searchLink: this.settings.get(SETTINGS_KEYS.SEARCH_FULL_TYPE),
|
||||||
}
|
}
|
||||||
@@ -458,6 +490,10 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
|
SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
|
||||||
this.settingsForm.value.pdfViewerDefaultZoom
|
this.settingsForm.value.pdfViewerDefaultZoom
|
||||||
)
|
)
|
||||||
|
this.settings.set(
|
||||||
|
SETTINGS_KEYS.PDF_EDITOR_DEFAULT_EDIT_MODE,
|
||||||
|
this.settingsForm.value.pdfEditorDefaultEditMode
|
||||||
|
)
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.DATE_LOCALE,
|
SETTINGS_KEYS.DATE_LOCALE,
|
||||||
this.settingsForm.value.dateLocale
|
this.settingsForm.value.dateLocale
|
||||||
@@ -526,6 +562,10 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL,
|
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL,
|
||||||
this.settingsForm.value.documentEditingOverlayThumbnail
|
this.settingsForm.value.documentEditingOverlayThumbnail
|
||||||
)
|
)
|
||||||
|
this.settings.set(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
||||||
|
this.settingsForm.value.documentDetailsHiddenFields
|
||||||
|
)
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.SEARCH_DB_ONLY,
|
SETTINGS_KEYS.SEARCH_DB_ONLY,
|
||||||
this.settingsForm.value.searchDbOnly
|
this.settingsForm.value.searchDbOnly
|
||||||
@@ -587,6 +627,26 @@ export class SettingsComponent
|
|||||||
this.settingsForm.get('themeColor').patchValue('')
|
this.settingsForm.get('themeColor').patchValue('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isDocumentDetailFieldShown(fieldId: string): boolean {
|
||||||
|
const hiddenFields =
|
||||||
|
this.settingsForm.value.documentDetailsHiddenFields || []
|
||||||
|
return !hiddenFields.includes(fieldId)
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleDocumentDetailField(fieldId: string, checked: boolean) {
|
||||||
|
const hiddenFields = new Set(
|
||||||
|
this.settingsForm.value.documentDetailsHiddenFields || []
|
||||||
|
)
|
||||||
|
if (checked) {
|
||||||
|
hiddenFields.delete(fieldId)
|
||||||
|
} else {
|
||||||
|
hiddenFields.add(fieldId)
|
||||||
|
}
|
||||||
|
this.settingsForm
|
||||||
|
.get('documentDetailsHiddenFields')
|
||||||
|
.setValue(Array.from(hiddenFields))
|
||||||
|
}
|
||||||
|
|
||||||
showSystemStatus() {
|
showSystemStatus() {
|
||||||
const modal: NgbModalRef = this.modalService.open(
|
const modal: NgbModalRef = this.modalService.open(
|
||||||
SystemStatusDialogComponent,
|
SystemStatusDialogComponent,
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 366px) and (max-width: 768px) {
|
@media screen and (min-width: 376px) and (max-width: 768px) {
|
||||||
.navbar-toggler {
|
.navbar-toggler {
|
||||||
// compensate for 2 buttons on the right
|
// compensate for 2 buttons on the right
|
||||||
margin-right: 45px;
|
margin-right: 45px;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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'
|
||||||
@@ -157,6 +158,7 @@ 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 { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrap } 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,
|
||||||
TourNgBootstrapModule,
|
TourNgBootstrap,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppFrameComponent
|
export class AppFrameComponent
|
||||||
|
|||||||
@@ -430,6 +430,24 @@
|
|||||||
</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,6 +3,7 @@ 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,
|
||||||
@@ -994,4 +995,32 @@ 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,6 +139,10 @@ 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 {
|
||||||
@@ -1202,11 +1206,25 @@ 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) => {
|
||||||
@@ -1331,6 +1349,7 @@ 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)
|
||||||
@@ -1367,6 +1386,7 @@ 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (selectionModel.items) {
|
@if (selectionModel.items) {
|
||||||
<div class="items" #buttonItems>
|
<cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight">
|
||||||
@for (item of selectionModel.items | filter: filterText:'name'; track item; let i = $index) {
|
<div *cdkVirtualFor="let item of selectionModel.items | filter: filterText:'name'; trackBy: trackByItem; let i = index">
|
||||||
@if (allowSelectNone || item.id) {
|
@if (allowSelectNone || item.id) {
|
||||||
<pngx-toggleable-dropdown-button
|
<pngx-toggleable-dropdown-button
|
||||||
[item]="item"
|
[item]="item"
|
||||||
@@ -45,12 +45,11 @@
|
|||||||
[count]="getUpdatedDocumentCount(item.id)"
|
[count]="getUpdatedDocumentCount(item.id)"
|
||||||
(toggled)="selectionModel.toggle(item.id)"
|
(toggled)="selectionModel.toggle(item.id)"
|
||||||
(exclude)="excludeClicked(item.id)"
|
(exclude)="excludeClicked(item.id)"
|
||||||
(click)="setButtonItemIndex(i - 1)"
|
|
||||||
[disabled]="disabled">
|
[disabled]="disabled">
|
||||||
</pngx-toggleable-dropdown-button>
|
</pngx-toggleable-dropdown-button>
|
||||||
}
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
</cdk-virtual-scroll-viewport>
|
||||||
}
|
}
|
||||||
@if (editing) {
|
@if (editing) {
|
||||||
@if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {
|
@if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ScrollingModule } from '@angular/cdk/scrolling'
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import {
|
import {
|
||||||
@@ -64,7 +65,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
],
|
],
|
||||||
imports: [NgxBootstrapIconsModule.pick(allIcons)],
|
imports: [NgxBootstrapIconsModule.pick(allIcons), ScrollingModule],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
hotkeyService = TestBed.inject(HotKeyService)
|
hotkeyService = TestBed.inject(HotKeyService)
|
||||||
@@ -265,18 +266,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(document.activeElement).toEqual(
|
expect(document.activeElement).toEqual(
|
||||||
component.listFilterTextInput.nativeElement
|
component.listFilterTextInput.nativeElement
|
||||||
)
|
)
|
||||||
expect(
|
expect(component.buttonsViewport.getRenderedRange().end).toEqual(3) // all items shown
|
||||||
Array.from(
|
|
||||||
(fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
|
|
||||||
).filter((b) => b.textContent.includes('Tag'))
|
|
||||||
).toHaveLength(2)
|
|
||||||
component.filterText = 'Tag2'
|
component.filterText = 'Tag2'
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(
|
expect(component.buttonsViewport.getRenderedRange().end).toEqual(1) // filtered
|
||||||
Array.from(
|
|
||||||
(fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
|
|
||||||
).filter((b) => b.textContent.includes('Tag'))
|
|
||||||
).toHaveLength(1)
|
|
||||||
component.dropdown.close()
|
component.dropdown.close()
|
||||||
expect(component.filterText).toHaveLength(0)
|
expect(component.filterText).toHaveLength(0)
|
||||||
}))
|
}))
|
||||||
@@ -331,6 +325,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(100)
|
tick(100)
|
||||||
|
component.buttonsViewport?.checkViewportSize()
|
||||||
|
fixture.detectChanges()
|
||||||
const filterInputEl: HTMLInputElement =
|
const filterInputEl: HTMLInputElement =
|
||||||
component.listFilterTextInput.nativeElement
|
component.listFilterTextInput.nativeElement
|
||||||
expect(document.activeElement).toEqual(filterInputEl)
|
expect(document.activeElement).toEqual(filterInputEl)
|
||||||
@@ -376,6 +372,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(100)
|
tick(100)
|
||||||
|
component.buttonsViewport?.checkViewportSize()
|
||||||
|
fixture.detectChanges()
|
||||||
const filterInputEl: HTMLInputElement =
|
const filterInputEl: HTMLInputElement =
|
||||||
component.listFilterTextInput.nativeElement
|
component.listFilterTextInput.nativeElement
|
||||||
expect(document.activeElement).toEqual(filterInputEl)
|
expect(document.activeElement).toEqual(filterInputEl)
|
||||||
@@ -412,6 +410,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(100)
|
tick(100)
|
||||||
|
component.buttonsViewport?.checkViewportSize()
|
||||||
|
fixture.detectChanges()
|
||||||
const filterInputEl: HTMLInputElement =
|
const filterInputEl: HTMLInputElement =
|
||||||
component.listFilterTextInput.nativeElement
|
component.listFilterTextInput.nativeElement
|
||||||
expect(document.activeElement).toEqual(filterInputEl)
|
expect(document.activeElement).toEqual(filterInputEl)
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
CdkVirtualScrollViewport,
|
||||||
|
ScrollingModule,
|
||||||
|
} from '@angular/cdk/scrolling'
|
||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
@@ -627,18 +631,27 @@ export class FilterableDropdownSelectionModel {
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
|
ScrollingModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FilterableDropdownComponent
|
export class FilterableDropdownComponent
|
||||||
extends LoadingComponentWithPermissions
|
extends LoadingComponentWithPermissions
|
||||||
implements OnInit
|
implements OnInit
|
||||||
{
|
{
|
||||||
|
public readonly FILTERABLE_BUTTON_HEIGHT_PX = 42
|
||||||
|
|
||||||
private filterPipe = inject(FilterPipe)
|
private filterPipe = inject(FilterPipe)
|
||||||
private hotkeyService = inject(HotKeyService)
|
private hotkeyService = inject(HotKeyService)
|
||||||
|
|
||||||
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
|
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
|
||||||
@ViewChild('dropdown') dropdown: NgbDropdown
|
@ViewChild('dropdown') dropdown: NgbDropdown
|
||||||
@ViewChild('buttonItems') buttonItems: ElementRef
|
@ViewChild('buttonsViewport') buttonsViewport: CdkVirtualScrollViewport
|
||||||
|
|
||||||
|
private get renderedButtons(): Array<HTMLButtonElement> {
|
||||||
|
return Array.from(
|
||||||
|
this.buttonsViewport.elementRef.nativeElement.querySelectorAll('button')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
public popperOptions = pngxPopperOptions
|
public popperOptions = pngxPopperOptions
|
||||||
|
|
||||||
@@ -752,6 +765,14 @@ export class FilterableDropdownComponent
|
|||||||
|
|
||||||
private keyboardIndex: number
|
private keyboardIndex: number
|
||||||
|
|
||||||
|
public get scrollViewportHeight(): number {
|
||||||
|
const filteredLength = this.filterPipe.transform(
|
||||||
|
this.items,
|
||||||
|
this.filterText
|
||||||
|
).length
|
||||||
|
return Math.min(filteredLength * this.FILTERABLE_BUTTON_HEIGHT_PX, 400)
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.selectionModelChange.subscribe((updatedModel) => {
|
this.selectionModelChange.subscribe((updatedModel) => {
|
||||||
@@ -776,6 +797,10 @@ export class FilterableDropdownComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public trackByItem(index: number, item: MatchingModel) {
|
||||||
|
return item?.id ?? index
|
||||||
|
}
|
||||||
|
|
||||||
applyClicked() {
|
applyClicked() {
|
||||||
if (this.selectionModel.isDirty()) {
|
if (this.selectionModel.isDirty()) {
|
||||||
this.dropdown.close()
|
this.dropdown.close()
|
||||||
@@ -794,6 +819,7 @@ export class FilterableDropdownComponent
|
|||||||
if (open) {
|
if (open) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.listFilterTextInput?.nativeElement.focus()
|
this.listFilterTextInput?.nativeElement.focus()
|
||||||
|
this.buttonsViewport?.checkViewportSize()
|
||||||
}, 0)
|
}, 0)
|
||||||
if (this.editing) {
|
if (this.editing) {
|
||||||
this.selectionModel.reset()
|
this.selectionModel.reset()
|
||||||
@@ -861,12 +887,14 @@ export class FilterableDropdownComponent
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
} else if (event.target instanceof HTMLButtonElement) {
|
} else if (event.target instanceof HTMLButtonElement) {
|
||||||
|
this.syncKeyboardIndexFromButton(event.target)
|
||||||
this.focusNextButtonItem()
|
this.focusNextButtonItem()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
if (event.target instanceof HTMLButtonElement) {
|
if (event.target instanceof HTMLButtonElement) {
|
||||||
|
this.syncKeyboardIndexFromButton(event.target)
|
||||||
if (this.keyboardIndex === 0) {
|
if (this.keyboardIndex === 0) {
|
||||||
this.listFilterTextInput.nativeElement.focus()
|
this.listFilterTextInput.nativeElement.focus()
|
||||||
} else {
|
} else {
|
||||||
@@ -903,15 +931,18 @@ export class FilterableDropdownComponent
|
|||||||
if (setFocus) this.setButtonItemFocus()
|
if (setFocus) this.setButtonItemFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
setButtonItemFocus() {
|
private syncKeyboardIndexFromButton(button: HTMLButtonElement) {
|
||||||
this.buttonItems.nativeElement.children[
|
// because of virtual scrolling, re-calculate the index
|
||||||
this.keyboardIndex
|
const idx = this.renderedButtons.indexOf(button)
|
||||||
]?.children[0].focus()
|
if (idx >= 0) {
|
||||||
|
this.keyboardIndex = this.buttonsViewport.getRenderedRange().start + idx
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setButtonItemIndex(index: number) {
|
setButtonItemFocus() {
|
||||||
// just track the index in case user uses arrows
|
const offset =
|
||||||
this.keyboardIndex = index
|
this.keyboardIndex - this.buttonsViewport.getRenderedRange().start
|
||||||
|
this.renderedButtons[offset]?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
hideCount(item: ObjectWithPermissions) {
|
hideCount(item: ObjectWithPermissions) {
|
||||||
|
|||||||
@@ -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 me-auto p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
<button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
||||||
<i-bs name="question-circle"></i-bs>
|
<i-bs name="question-circle"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<ng-template #infoPopover>
|
<ng-template #infoPopover>
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
}
|
}
|
||||||
</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 { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrap } 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, TourNgBootstrapModule],
|
imports: [NgbPopoverModule, NgxBootstrapIconsModule, TourNgBootstrap],
|
||||||
})
|
})
|
||||||
export class PageHeaderComponent {
|
export class PageHeaderComponent {
|
||||||
private titleService = inject(Title)
|
private titleService = inject(Title)
|
||||||
@@ -42,6 +42,9 @@ 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)
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export enum PdfEditorEditMode {
|
||||||
|
Update = 'update',
|
||||||
|
Create = 'create',
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer>
|
<pngx-pdf-viewer class="visually-hidden" [src]="pdfSrc" [renderMode]="PdfRenderMode.Single" [page]="1" [selectable]="false" (afterLoadComplete)="pdfLoaded($event)"></pngx-pdf-viewer>
|
||||||
<div class="modal-header">
|
<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>
|
||||||
}
|
}
|
||||||
<pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [original-size]="false" [show-all]="false" [render-text]="false" (page-rendered)="p.loaded = true"></pdf-viewer>
|
<pngx-pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [renderMode]="PdfRenderMode.Single" (rendered)="p.loaded = true"></pngx-pdf-viewer>
|
||||||
} @placeholder {
|
} @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;
|
||||||
|
|
||||||
pdf-viewer {
|
pngx-pdf-viewer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep .ng2-pdf-viewer-container {
|
::ng-deep .pngx-pdf-viewer-container {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,17 @@ 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 { 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 { 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'
|
||||||
|
|
||||||
interface PageOperation {
|
interface PageOperation {
|
||||||
page: number
|
page: number
|
||||||
@@ -19,11 +26,6 @@ interface PageOperation {
|
|||||||
loaded?: boolean
|
loaded?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PdfEditorEditMode {
|
|
||||||
Update = 'update',
|
|
||||||
Create = 'create',
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-pdf-editor',
|
selector: 'pngx-pdf-editor',
|
||||||
templateUrl: './pdf-editor.component.html',
|
templateUrl: './pdf-editor.component.html',
|
||||||
@@ -31,20 +33,24 @@ export enum PdfEditorEditMode {
|
|||||||
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)
|
||||||
|
private readonly settingsService = inject(SettingsService)
|
||||||
activeModal: NgbActiveModal = inject(NgbActiveModal)
|
activeModal: NgbActiveModal = inject(NgbActiveModal)
|
||||||
|
|
||||||
documentID: number
|
documentID: number
|
||||||
pages: PageOperation[] = []
|
pages: PageOperation[] = []
|
||||||
totalPages = 0
|
totalPages = 0
|
||||||
editMode: PdfEditorEditMode = PdfEditorEditMode.Create
|
editMode: PdfEditorEditMode = this.settingsService.get(
|
||||||
|
SETTINGS_KEYS.PDF_EDITOR_DEFAULT_EDIT_MODE
|
||||||
|
)
|
||||||
deleteOriginal: boolean = false
|
deleteOriginal: boolean = false
|
||||||
includeMetadata: boolean = true
|
includeMetadata: boolean = true
|
||||||
|
|
||||||
@@ -52,7 +58,7 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
|
|||||||
return this.documentService.getPreviewUrl(this.documentID)
|
return this.documentService.getPreviewUrl(this.documentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
pdfLoaded(pdf: PDFDocumentProxy) {
|
pdfLoaded(pdf: PngxPdfDocumentProxy) {
|
||||||
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,
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<div #container class="pngx-pdf-viewer-container">
|
||||||
|
<div #viewer class="pdfViewer"></div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .pngx-pdf-viewer-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .pdfViewer {
|
||||||
|
--scale-factor: 1;
|
||||||
|
--page-bg-color: unset;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .pdfViewer .page {
|
||||||
|
--user-unit: 1;
|
||||||
|
--total-scale-factor: calc(var(--scale-factor) * var(--user-unit));
|
||||||
|
--scale-round-x: 1px;
|
||||||
|
--scale-round-y: 1px;
|
||||||
|
direction: ltr;
|
||||||
|
margin: 0 auto 10px;
|
||||||
|
border: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
background-clip: content-box;
|
||||||
|
background-color: var(--page-bg-color, rgb(255 255 255));
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .pdfViewer > .page:last-of-type {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .pdfViewer.singlePageView {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .pdfViewer.singlePageView .page {
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .pdfViewer .canvasWrapper {
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .pdfViewer .canvasWrapper canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
contain: content;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .textLayer {
|
||||||
|
position: absolute;
|
||||||
|
text-align: initial;
|
||||||
|
inset: 0;
|
||||||
|
overflow: clip;
|
||||||
|
opacity: 1;
|
||||||
|
line-height: 1;
|
||||||
|
text-size-adjust: none;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
caret-color: CanvasText;
|
||||||
|
z-index: 0;
|
||||||
|
user-select: text;
|
||||||
|
--min-font-size: 1;
|
||||||
|
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
|
||||||
|
--min-font-size-inv: calc(1 / var(--min-font-size));
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .textLayer.highlighting {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .textLayer :is(span, br) {
|
||||||
|
position: absolute;
|
||||||
|
white-space: pre;
|
||||||
|
color: transparent;
|
||||||
|
cursor: text;
|
||||||
|
transform-origin: 0% 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .textLayer > :not(.markedContent),
|
||||||
|
:host ::ng-deep .textLayer .markedContent span:not(.markedContent) {
|
||||||
|
z-index: 1;
|
||||||
|
--font-height: 0;
|
||||||
|
font-size: calc(var(--text-scale-factor) * var(--font-height));
|
||||||
|
--scale-x: 1;
|
||||||
|
--rotate: 0deg;
|
||||||
|
transform: rotate(var(--rotate)) scaleX(var(--scale-x))
|
||||||
|
scale(var(--min-font-size-inv));
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .textLayer .markedContent {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .textLayer span[role='img'] {
|
||||||
|
user-select: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .textLayer .highlight {
|
||||||
|
--highlight-bg-color: rgb(180 0 170 / 0.25);
|
||||||
|
--highlight-selected-bg-color: rgb(0 100 0 / 0.25);
|
||||||
|
--highlight-backdrop-filter: none;
|
||||||
|
--highlight-selected-backdrop-filter: none;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 1px;
|
||||||
|
background-color: var(--highlight-bg-color);
|
||||||
|
backdrop-filter: var(--highlight-backdrop-filter);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .appended:is(.textLayer .highlight) {
|
||||||
|
position: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .begin:is(.textLayer .highlight) {
|
||||||
|
border-radius: 4px 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .end:is(.textLayer .highlight) {
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .middle:is(.textLayer .highlight) {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .selected:is(.textLayer .highlight) {
|
||||||
|
background-color: var(--highlight-selected-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .textLayer ::selection {
|
||||||
|
background: rgba(30, 100, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .annotationLayer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { SimpleChange } from '@angular/core'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||||
|
import { PDFSinglePageViewer, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'
|
||||||
|
import { PngxPdfViewerComponent } from './pdf-viewer.component'
|
||||||
|
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
|
||||||
|
|
||||||
|
describe('PngxPdfViewerComponent', () => {
|
||||||
|
let fixture: ComponentFixture<PngxPdfViewerComponent>
|
||||||
|
let component: PngxPdfViewerComponent
|
||||||
|
|
||||||
|
const initComponent = async (src = 'test.pdf') => {
|
||||||
|
component.src = src
|
||||||
|
fixture.detectChanges()
|
||||||
|
await fixture.whenStable()
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [PngxPdfViewerComponent],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(PngxPdfViewerComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads a document and emits events', async () => {
|
||||||
|
const loadSpy = jest.fn()
|
||||||
|
const renderedSpy = jest.fn()
|
||||||
|
component.afterLoadComplete.subscribe(loadSpy)
|
||||||
|
component.rendered.subscribe(renderedSpy)
|
||||||
|
|
||||||
|
await initComponent()
|
||||||
|
|
||||||
|
expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe(
|
||||||
|
'/assets/js/pdf.worker.min.mjs'
|
||||||
|
)
|
||||||
|
const isVisible = (component as any).findController.onIsPageVisible as
|
||||||
|
| (() => boolean)
|
||||||
|
| undefined
|
||||||
|
expect(isVisible?.()).toBe(true)
|
||||||
|
expect(loadSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ numPages: 1 })
|
||||||
|
)
|
||||||
|
expect(renderedSpy).toHaveBeenCalled()
|
||||||
|
expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('initializes single-page viewer and disables text layer', async () => {
|
||||||
|
component.renderMode = PdfRenderMode.Single
|
||||||
|
component.selectable = false
|
||||||
|
|
||||||
|
await initComponent()
|
||||||
|
|
||||||
|
const viewer = (component as any).pdfViewer as PDFSinglePageViewer & {
|
||||||
|
options: Record<string, unknown>
|
||||||
|
}
|
||||||
|
expect(viewer).toBeInstanceOf(PDFSinglePageViewer)
|
||||||
|
expect(viewer.options.textLayerMode).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies zoom, rotation, and page changes', async () => {
|
||||||
|
await initComponent()
|
||||||
|
|
||||||
|
const pageSpy = jest.fn()
|
||||||
|
component.pageChange.subscribe(pageSpy)
|
||||||
|
|
||||||
|
component.zoomScale = PdfZoomScale.PageFit
|
||||||
|
component.zoom = PdfZoomLevel.Two
|
||||||
|
component.rotation = 90
|
||||||
|
component.page = 2
|
||||||
|
|
||||||
|
component.ngOnChanges({
|
||||||
|
zoomScale: new SimpleChange(
|
||||||
|
PdfZoomScale.PageWidth,
|
||||||
|
PdfZoomScale.PageFit,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
zoom: new SimpleChange(PdfZoomLevel.One, PdfZoomLevel.Two, false),
|
||||||
|
rotation: new SimpleChange(undefined, 90, false),
|
||||||
|
page: new SimpleChange(undefined, 2, false),
|
||||||
|
})
|
||||||
|
|
||||||
|
const viewer = (component as any).pdfViewer as PDFViewer
|
||||||
|
expect(viewer.pagesRotation).toBe(90)
|
||||||
|
expect(viewer.currentPageNumber).toBe(2)
|
||||||
|
expect(pageSpy).toHaveBeenCalledWith(2)
|
||||||
|
|
||||||
|
viewer.currentScale = 1
|
||||||
|
;(component as any).applyScale()
|
||||||
|
expect(viewer.currentScaleValue).toBe(PdfZoomScale.PageFit)
|
||||||
|
expect(viewer.currentScale).toBe(2)
|
||||||
|
|
||||||
|
const applyScaleSpy = jest.spyOn(component as any, 'applyScale')
|
||||||
|
component.page = 2
|
||||||
|
;(component as any).lastViewerPage = 2
|
||||||
|
;(component as any).applyViewerState()
|
||||||
|
expect((component as any).lastViewerPage).toBeUndefined()
|
||||||
|
expect(applyScaleSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dispatches find when search query changes after render', async () => {
|
||||||
|
await initComponent()
|
||||||
|
|
||||||
|
const eventBus = (component as any).eventBus as { dispatch: jest.Mock }
|
||||||
|
const dispatchSpy = jest.spyOn(eventBus, 'dispatch')
|
||||||
|
|
||||||
|
;(component as any).hasRenderedPage = true
|
||||||
|
component.searchQuery = 'needle'
|
||||||
|
component.ngOnChanges({
|
||||||
|
searchQuery: new SimpleChange('', 'needle', false),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(dispatchSpy).toHaveBeenCalledWith('find', {
|
||||||
|
query: 'needle',
|
||||||
|
caseSensitive: false,
|
||||||
|
highlightAll: true,
|
||||||
|
phraseSearch: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
component.ngOnChanges({
|
||||||
|
searchQuery: new SimpleChange('needle', 'needle', false),
|
||||||
|
})
|
||||||
|
expect(dispatchSpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits error when document load fails', async () => {
|
||||||
|
const errorSpy = jest.fn()
|
||||||
|
component.loadError.subscribe(errorSpy)
|
||||||
|
|
||||||
|
jest.spyOn(pdfjs, 'getDocument').mockImplementationOnce(() => {
|
||||||
|
return {
|
||||||
|
promise: Promise.reject(new Error('boom')),
|
||||||
|
destroy: jest.fn(),
|
||||||
|
} as any
|
||||||
|
})
|
||||||
|
|
||||||
|
await initComponent('bad.pdf')
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cleans up resources on destroy', async () => {
|
||||||
|
await initComponent()
|
||||||
|
|
||||||
|
const viewer = (component as any).pdfViewer as { cleanup: jest.Mock }
|
||||||
|
const loadingTask = (component as any).loadingTask as unknown as {
|
||||||
|
destroy: jest.Mock
|
||||||
|
}
|
||||||
|
const resizeObserver = (component as any).resizeObserver as unknown as {
|
||||||
|
disconnect: jest.Mock
|
||||||
|
}
|
||||||
|
const eventBus = (component as any).eventBus as { off: jest.Mock }
|
||||||
|
|
||||||
|
jest.spyOn(viewer, 'cleanup')
|
||||||
|
jest.spyOn(loadingTask, 'destroy')
|
||||||
|
jest.spyOn(resizeObserver, 'disconnect')
|
||||||
|
jest.spyOn(eventBus, 'off')
|
||||||
|
|
||||||
|
component.ngOnDestroy()
|
||||||
|
|
||||||
|
expect(eventBus.off).toHaveBeenCalledWith(
|
||||||
|
'pagerendered',
|
||||||
|
expect.any(Function)
|
||||||
|
)
|
||||||
|
expect(eventBus.off).toHaveBeenCalledWith('pagesinit', expect.any(Function))
|
||||||
|
expect(eventBus.off).toHaveBeenCalledWith(
|
||||||
|
'pagechanging',
|
||||||
|
expect.any(Function)
|
||||||
|
)
|
||||||
|
expect(resizeObserver.disconnect).toHaveBeenCalled()
|
||||||
|
expect(loadingTask.destroy).toHaveBeenCalled()
|
||||||
|
expect(viewer.cleanup).toHaveBeenCalled()
|
||||||
|
expect((component as any).pdfViewer).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips work when viewer is missing or has no pages', () => {
|
||||||
|
const eventBus = (component as any).eventBus as { dispatch: jest.Mock }
|
||||||
|
const dispatchSpy = jest.spyOn(eventBus, 'dispatch')
|
||||||
|
;(component as any).dispatchFindIfReady()
|
||||||
|
expect(dispatchSpy).not.toHaveBeenCalled()
|
||||||
|
;(component as any).applyViewerState()
|
||||||
|
;(component as any).applyScale()
|
||||||
|
|
||||||
|
const viewer = new PDFViewer({ eventBus: undefined })
|
||||||
|
viewer.pagesCount = 0
|
||||||
|
;(component as any).pdfViewer = viewer
|
||||||
|
viewer.currentScale = 5
|
||||||
|
;(component as any).applyScale()
|
||||||
|
expect(viewer.currentScale).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns early on src change in ngOnChanges', () => {
|
||||||
|
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||||
|
const initSpy = jest.spyOn(component as any, 'initViewer')
|
||||||
|
const scaleSpy = jest.spyOn(component as any, 'applyViewerState')
|
||||||
|
const resizeSpy = jest.spyOn(component as any, 'setupResizeObserver')
|
||||||
|
|
||||||
|
component.ngOnChanges({
|
||||||
|
src: new SimpleChange(undefined, 'test.pdf', true),
|
||||||
|
zoomScale: new SimpleChange(
|
||||||
|
PdfZoomScale.PageWidth,
|
||||||
|
PdfZoomScale.PageFit,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(loadSpy).toHaveBeenCalled()
|
||||||
|
expect(resizeSpy).not.toHaveBeenCalled()
|
||||||
|
expect(initSpy).not.toHaveBeenCalled()
|
||||||
|
expect(scaleSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies viewer state after view init when already loaded', () => {
|
||||||
|
const applySpy = jest.spyOn(component as any, 'applyViewerState')
|
||||||
|
;(component as any).hasLoaded = true
|
||||||
|
;(component as any).pdf = { numPages: 1 }
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(applySpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips viewer state after view init when no pdf is available', () => {
|
||||||
|
const applySpy = jest.spyOn(component as any, 'applyViewerState')
|
||||||
|
;(component as any).hasLoaded = true
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(applySpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not reload when already loaded', async () => {
|
||||||
|
await initComponent()
|
||||||
|
|
||||||
|
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
|
||||||
|
const callCount = getDocumentSpy.mock.calls.length
|
||||||
|
await (component as any).loadDocument()
|
||||||
|
|
||||||
|
expect(getDocumentSpy).toHaveBeenCalledTimes(callCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('runs applyScale on resize observer notifications', async () => {
|
||||||
|
await initComponent()
|
||||||
|
|
||||||
|
const applySpy = jest.spyOn(component as any, 'applyScale')
|
||||||
|
const resizeObserver = (component as any).resizeObserver as {
|
||||||
|
trigger: () => void
|
||||||
|
}
|
||||||
|
resizeObserver.trigger()
|
||||||
|
|
||||||
|
expect(applySpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips page work when no pages are available', async () => {
|
||||||
|
await initComponent()
|
||||||
|
|
||||||
|
const viewer = (component as any).pdfViewer as PDFViewer
|
||||||
|
viewer.pagesCount = 0
|
||||||
|
const applyScaleSpy = jest.spyOn(component as any, 'applyScale')
|
||||||
|
|
||||||
|
component.page = undefined
|
||||||
|
;(component as any).lastViewerPage = 1
|
||||||
|
;(component as any).applyViewerState()
|
||||||
|
|
||||||
|
expect(applyScaleSpy).not.toHaveBeenCalled()
|
||||||
|
expect((component as any).lastViewerPage).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to a default zoom when input is invalid', async () => {
|
||||||
|
await initComponent()
|
||||||
|
|
||||||
|
const viewer = (component as any).pdfViewer as PDFViewer
|
||||||
|
viewer.currentScale = 3
|
||||||
|
component.zoom = 'not-a-number' as PdfZoomLevel
|
||||||
|
;(component as any).applyScale()
|
||||||
|
|
||||||
|
expect(viewer.currentScale).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-initializes viewer on selectable or render mode changes', async () => {
|
||||||
|
await initComponent()
|
||||||
|
|
||||||
|
const initSpy = jest.spyOn(component as any, 'initViewer')
|
||||||
|
component.selectable = false
|
||||||
|
component.renderMode = PdfRenderMode.Single
|
||||||
|
|
||||||
|
component.ngOnChanges({
|
||||||
|
selectable: new SimpleChange(true, false, false),
|
||||||
|
renderMode: new SimpleChange(
|
||||||
|
PdfRenderMode.All,
|
||||||
|
PdfRenderMode.Single,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(initSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import {
|
||||||
|
AfterViewInit,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
OnChanges,
|
||||||
|
OnDestroy,
|
||||||
|
Output,
|
||||||
|
SimpleChanges,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core'
|
||||||
|
import {
|
||||||
|
getDocument,
|
||||||
|
GlobalWorkerOptions,
|
||||||
|
PDFDocumentLoadingTask,
|
||||||
|
PDFDocumentProxy,
|
||||||
|
} from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||||
|
import {
|
||||||
|
EventBus,
|
||||||
|
PDFFindController,
|
||||||
|
PDFLinkService,
|
||||||
|
PDFSinglePageViewer,
|
||||||
|
PDFViewer,
|
||||||
|
} from 'pdfjs-dist/web/pdf_viewer.mjs'
|
||||||
|
import {
|
||||||
|
PdfRenderMode,
|
||||||
|
PdfSource,
|
||||||
|
PdfZoomLevel,
|
||||||
|
PdfZoomScale,
|
||||||
|
PngxPdfDocumentProxy,
|
||||||
|
} from './pdf-viewer.types'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-pdf-viewer',
|
||||||
|
templateUrl: './pdf-viewer.component.html',
|
||||||
|
styleUrl: './pdf-viewer.component.scss',
|
||||||
|
})
|
||||||
|
export class PngxPdfViewerComponent
|
||||||
|
implements AfterViewInit, OnChanges, OnDestroy
|
||||||
|
{
|
||||||
|
@Input() src!: PdfSource
|
||||||
|
@Input() page?: number
|
||||||
|
@Output() pageChange = new EventEmitter<number>()
|
||||||
|
@Input() rotation?: number
|
||||||
|
@Input() renderMode: PdfRenderMode = PdfRenderMode.All
|
||||||
|
@Input() selectable = true
|
||||||
|
@Input() searchQuery = ''
|
||||||
|
@Input() zoom: PdfZoomLevel = PdfZoomLevel.One
|
||||||
|
@Input() zoomScale: PdfZoomScale = PdfZoomScale.PageWidth
|
||||||
|
|
||||||
|
@Output() afterLoadComplete = new EventEmitter<PngxPdfDocumentProxy>()
|
||||||
|
@Output() rendered = new EventEmitter<void>()
|
||||||
|
@Output() loadError = new EventEmitter<unknown>()
|
||||||
|
|
||||||
|
@ViewChild('container', { static: true })
|
||||||
|
private readonly container!: ElementRef<HTMLDivElement>
|
||||||
|
|
||||||
|
@ViewChild('viewer', { static: true })
|
||||||
|
private readonly viewer!: ElementRef<HTMLDivElement>
|
||||||
|
|
||||||
|
private hasLoaded = false
|
||||||
|
private loadingTask?: PDFDocumentLoadingTask
|
||||||
|
private resizeObserver?: ResizeObserver
|
||||||
|
private pdf?: PDFDocumentProxy
|
||||||
|
private pdfViewer?: PDFViewer | PDFSinglePageViewer
|
||||||
|
private hasRenderedPage = false
|
||||||
|
private lastFindQuery = ''
|
||||||
|
private lastViewerPage?: number
|
||||||
|
|
||||||
|
private readonly eventBus = new EventBus()
|
||||||
|
private readonly linkService = new PDFLinkService({ eventBus: this.eventBus })
|
||||||
|
private readonly findController = new PDFFindController({
|
||||||
|
eventBus: this.eventBus,
|
||||||
|
linkService: this.linkService,
|
||||||
|
updateMatchesCountOnProgress: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
private readonly onPageRendered = () => {
|
||||||
|
this.hasRenderedPage = true
|
||||||
|
this.dispatchFindIfReady()
|
||||||
|
this.rendered.emit()
|
||||||
|
}
|
||||||
|
private readonly onPagesInit = () => this.applyScale()
|
||||||
|
private readonly onPageChanging = (evt: { pageNumber: number }) => {
|
||||||
|
// Avoid [(page)] two-way binding re-triggers navigation
|
||||||
|
this.lastViewerPage = evt.pageNumber
|
||||||
|
this.pageChange.emit(evt.pageNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes['src']) {
|
||||||
|
this.hasLoaded = false
|
||||||
|
this.loadDocument()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes['zoomScale']) {
|
||||||
|
this.setupResizeObserver()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes['selectable'] || changes['renderMode']) {
|
||||||
|
this.initViewer()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
changes['page'] ||
|
||||||
|
changes['zoom'] ||
|
||||||
|
changes['zoomScale'] ||
|
||||||
|
changes['rotation']
|
||||||
|
) {
|
||||||
|
this.applyViewerState()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes['searchQuery']) {
|
||||||
|
this.dispatchFindIfReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.setupResizeObserver()
|
||||||
|
this.initViewer()
|
||||||
|
if (!this.hasLoaded) {
|
||||||
|
this.loadDocument()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.pdf) {
|
||||||
|
this.applyViewerState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.eventBus.off('pagerendered', this.onPageRendered)
|
||||||
|
this.eventBus.off('pagesinit', this.onPagesInit)
|
||||||
|
this.eventBus.off('pagechanging', this.onPageChanging)
|
||||||
|
this.resizeObserver?.disconnect()
|
||||||
|
this.loadingTask?.destroy()
|
||||||
|
this.pdfViewer?.cleanup()
|
||||||
|
this.pdfViewer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadDocument(): Promise<void> {
|
||||||
|
if (this.hasLoaded) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hasLoaded = true
|
||||||
|
this.hasRenderedPage = false
|
||||||
|
this.lastFindQuery = ''
|
||||||
|
this.loadingTask?.destroy()
|
||||||
|
|
||||||
|
GlobalWorkerOptions.workerSrc = '/assets/js/pdf.worker.min.mjs'
|
||||||
|
this.loadingTask = getDocument(this.src)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdf = await this.loadingTask.promise
|
||||||
|
this.pdf = pdf
|
||||||
|
this.linkService.setDocument(pdf)
|
||||||
|
this.findController.onIsPageVisible = () => true
|
||||||
|
this.pdfViewer?.setDocument(pdf)
|
||||||
|
this.applyViewerState()
|
||||||
|
this.afterLoadComplete.emit(pdf)
|
||||||
|
} catch (err) {
|
||||||
|
this.loadError.emit(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupResizeObserver(): void {
|
||||||
|
this.resizeObserver?.disconnect()
|
||||||
|
this.resizeObserver = new ResizeObserver(() => {
|
||||||
|
this.applyScale()
|
||||||
|
})
|
||||||
|
this.resizeObserver.observe(this.container.nativeElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
private initViewer(): void {
|
||||||
|
this.viewer.nativeElement.innerHTML = ''
|
||||||
|
this.pdfViewer?.cleanup()
|
||||||
|
this.hasRenderedPage = false
|
||||||
|
this.lastFindQuery = ''
|
||||||
|
|
||||||
|
const textLayerMode = this.selectable === false ? 0 : 1
|
||||||
|
const options = {
|
||||||
|
container: this.container.nativeElement,
|
||||||
|
viewer: this.viewer.nativeElement,
|
||||||
|
eventBus: this.eventBus,
|
||||||
|
linkService: this.linkService,
|
||||||
|
findController: this.findController,
|
||||||
|
textLayerMode,
|
||||||
|
removePageBorders: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pdfViewer =
|
||||||
|
this.renderMode === PdfRenderMode.Single
|
||||||
|
? new PDFSinglePageViewer(options)
|
||||||
|
: new PDFViewer(options)
|
||||||
|
this.linkService.setViewer(this.pdfViewer)
|
||||||
|
|
||||||
|
this.eventBus.off('pagerendered', this.onPageRendered)
|
||||||
|
this.eventBus.off('pagesinit', this.onPagesInit)
|
||||||
|
this.eventBus.off('pagechanging', this.onPageChanging)
|
||||||
|
this.eventBus.on('pagerendered', this.onPageRendered)
|
||||||
|
this.eventBus.on('pagesinit', this.onPagesInit)
|
||||||
|
this.eventBus.on('pagechanging', this.onPageChanging)
|
||||||
|
|
||||||
|
if (this.pdf) {
|
||||||
|
this.pdfViewer.setDocument(this.pdf)
|
||||||
|
this.applyViewerState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyViewerState(): void {
|
||||||
|
if (!this.pdfViewer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const hasPages = this.pdfViewer.pagesCount > 0
|
||||||
|
if (typeof this.rotation === 'number' && hasPages) {
|
||||||
|
this.pdfViewer.pagesRotation = this.rotation
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof this.page === 'number' &&
|
||||||
|
hasPages &&
|
||||||
|
this.page !== this.lastViewerPage
|
||||||
|
) {
|
||||||
|
this.pdfViewer.currentPageNumber = this.page
|
||||||
|
}
|
||||||
|
if (this.page === this.lastViewerPage) {
|
||||||
|
this.lastViewerPage = undefined
|
||||||
|
}
|
||||||
|
if (hasPages) {
|
||||||
|
this.applyScale()
|
||||||
|
}
|
||||||
|
this.dispatchFindIfReady()
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyScale(): void {
|
||||||
|
if (!this.pdfViewer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.pdfViewer.pagesCount === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const zoomFactor = Number(this.zoom) || 1
|
||||||
|
this.pdfViewer.currentScaleValue = this.zoomScale
|
||||||
|
if (zoomFactor !== 1) {
|
||||||
|
this.pdfViewer.currentScale = this.pdfViewer.currentScale * zoomFactor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchFindIfReady(): void {
|
||||||
|
if (!this.hasRenderedPage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const query = this.searchQuery.trim()
|
||||||
|
if (query === this.lastFindQuery) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.lastFindQuery = query
|
||||||
|
this.eventBus.dispatch('find', {
|
||||||
|
query,
|
||||||
|
caseSensitive: false,
|
||||||
|
highlightAll: query.length > 0,
|
||||||
|
phraseSearch: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export type PngxPdfDocumentProxy = {
|
||||||
|
numPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PdfSource = string | { url: string; password?: string }
|
||||||
|
|
||||||
|
export enum PdfRenderMode {
|
||||||
|
Single = 'single',
|
||||||
|
All = 'all',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PdfZoomScale {
|
||||||
|
PageFit = 'page-fit',
|
||||||
|
PageWidth = 'page-width',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PdfZoomLevel {
|
||||||
|
Quarter = '.25',
|
||||||
|
Half = '.5',
|
||||||
|
ThreeQuarters = '.75',
|
||||||
|
One = '1',
|
||||||
|
OneAndHalf = '1.5',
|
||||||
|
Two = '2',
|
||||||
|
Three = '3',
|
||||||
|
}
|
||||||
@@ -23,14 +23,12 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!requiresPassword) {
|
@if (!requiresPassword) {
|
||||||
<pdf-viewer
|
<pngx-pdf-viewer
|
||||||
[src]="previewUrl"
|
[src]="previewUrl"
|
||||||
[original-size]="false"
|
[renderMode]="PdfRenderMode.All"
|
||||||
[show-borders]="false"
|
[searchQuery]="documentService.searchQuery"
|
||||||
[show-all]="true"
|
(loadError)="onError($event)">
|
||||||
(text-layer-rendered)="onPageRendered()"
|
</pngx-pdf-viewer>
|
||||||
(error)="onError($event)" #pdfViewer>
|
|
||||||
</pdf-viewer>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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 = {
|
||||||
@@ -78,7 +79,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('pdf-viewer'))).not.toBeNull()
|
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show lock icon on password error', () => {
|
it('should show lock icon on password error', () => {
|
||||||
@@ -159,23 +160,15 @@ describe('PreviewPopupComponent', () => {
|
|||||||
expect(component.popover.isOpen()).toBeFalsy()
|
expect(component.popover.isOpen()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should dispatch find event on viewer loaded if searchQuery set', () => {
|
it('should pass searchQuery to viewer', () => {
|
||||||
documentService.searchQuery = 'test'
|
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()
|
||||||
// normally setup by pdf-viewer
|
const viewer = fixture.debugElement.query(
|
||||||
jest.replaceProperty(component.pdfViewer, 'eventBus', {
|
By.directive(PngxPdfViewerComponent)
|
||||||
dispatch: jest.fn(),
|
)
|
||||||
} as any)
|
expect(viewer).not.toBeNull()
|
||||||
const dispatchSpy = jest.spyOn(component.pdfViewer.eventBus, 'dispatch')
|
expect(viewer.componentInstance.searchQuery).toBe('test')
|
||||||
component.onPageRendered()
|
|
||||||
expect(dispatchSpy).toHaveBeenCalledWith('find', {
|
|
||||||
query: 'test',
|
|
||||||
caseSensitive: false,
|
|
||||||
highlightAll: true,
|
|
||||||
phraseSearch: true,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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'
|
||||||
@@ -10,6 +9,8 @@ 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',
|
||||||
@@ -18,14 +19,15 @@ import { SettingsService } from 'src/app/services/settings.service'
|
|||||||
imports: [
|
imports: [
|
||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
DocumentTitlePipe,
|
DocumentTitlePipe,
|
||||||
PdfViewerModule,
|
PngxPdfViewerComponent,
|
||||||
SafeUrlPipe,
|
SafeUrlPipe,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class PreviewPopupComponent implements OnDestroy {
|
export class PreviewPopupComponent implements OnDestroy {
|
||||||
|
PdfRenderMode = PdfRenderMode
|
||||||
private settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
private documentService = inject(DocumentService)
|
public readonly documentService = inject(DocumentService)
|
||||||
private http = inject(HttpClient)
|
private http = inject(HttpClient)
|
||||||
|
|
||||||
private _document: Document
|
private _document: Document
|
||||||
@@ -61,8 +63,6 @@ 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,18 +114,6 @@ export class PreviewPopupComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onPageRendered() {
|
|
||||||
// Only triggered by the pngx pdf viewer
|
|
||||||
if (this.documentService.searchQuery) {
|
|
||||||
this.pdfViewer.eventBus.dispatch('find', {
|
|
||||||
query: this.documentService.searchQuery,
|
|
||||||
caseSensitive: false,
|
|
||||||
highlightAll: true,
|
|
||||||
phraseSearch: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mouseEnterPreview() {
|
mouseEnterPreview() {
|
||||||
this.mouseOnPreview = true
|
this.mouseOnPreview = true
|
||||||
if (!this.popover.isOpen()) {
|
if (!this.popover.isOpen()) {
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title">{{ title }}</h4>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (!createdBundle) {
|
||||||
|
<form [formGroup]="form" class="d-flex flex-column gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="mb-1">
|
||||||
|
<ng-container i18n>Selected documents:</ng-container>
|
||||||
|
{{ selectionCount }}
|
||||||
|
</p>
|
||||||
|
@if (documentPreview.length > 0) {
|
||||||
|
<ul class="list-unstyled small mb-0">
|
||||||
|
@for (doc of documentPreview; track doc.id) {
|
||||||
|
<li>
|
||||||
|
<strong>{{ doc.title | documentTitle }}</strong>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
@if (selectionCount > documentPreview.length) {
|
||||||
|
<li>
|
||||||
|
<ng-container i18n>+ {{ selectionCount - documentPreview.length }} more…</ng-container>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<div class="input-group">
|
||||||
|
<label class="input-group-text" for="expirationDays"><ng-container i18n>Expires</ng-container>:</label>
|
||||||
|
<select class="form-select" id="expirationDays" formControlName="expirationDays">
|
||||||
|
@for (option of expirationOptions; track option.value) {
|
||||||
|
<option [ngValue]="option.value">{{ option.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch w-100 ms-3">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
id="shareArchiveSwitch"
|
||||||
|
formControlName="shareArchiveVersion"
|
||||||
|
aria-checked="{{ shareArchiveVersion }}"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="shareArchiveSwitch" i18n>Share archive version (if available)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
} @else {
|
||||||
|
<div class="d-flex flex-column gap-3">
|
||||||
|
<div class="alert alert-success mb-0" role="status">
|
||||||
|
<h6 class="alert-heading mb-1" i18n>Share link bundle requested</h6>
|
||||||
|
<p class="mb-0 small" i18n>
|
||||||
|
You can copy the share link below or open the manager to monitor progress. The link will start working once the bundle is ready.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<dl class="row mb-0 small">
|
||||||
|
<dt class="col-sm-4" i18n>Status</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
<span class="badge text-bg-secondary text-uppercase">{{ statusLabel(createdBundle.status) }}</span>
|
||||||
|
</dd>
|
||||||
|
<dt class="col-sm-4" i18n>Slug</dt>
|
||||||
|
<dd class="col-sm-8"><code>{{ createdBundle.slug }}</code></dd>
|
||||||
|
<dt class="col-sm-4" i18n>Link</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-primary"
|
||||||
|
type="button"
|
||||||
|
(click)="copy(createdBundle)"
|
||||||
|
>
|
||||||
|
@if (copied) {
|
||||||
|
<i-bs name="clipboard-check"></i-bs>
|
||||||
|
}
|
||||||
|
@if (!copied) {
|
||||||
|
<i-bs name="clipboard"></i-bs>
|
||||||
|
}
|
||||||
|
<span class="visually-hidden" i18n>Copy link</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
<dt class="col-sm-4" i18n>Documents</dt>
|
||||||
|
<dd class="col-sm-8">{{ createdBundle.document_count }}</dd>
|
||||||
|
<dt class="col-sm-4" i18n>Expires</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
@if (createdBundle.expiration) {
|
||||||
|
{{ createdBundle.expiration | date: 'short' }}
|
||||||
|
}
|
||||||
|
@if (!createdBundle.expiration) {
|
||||||
|
<span i18n>Never</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
<dt class="col-sm-4" i18n>File version</dt>
|
||||||
|
<dd class="col-sm-8">{{ fileVersionLabel(createdBundle.file_version) }}</dd>
|
||||||
|
@if (createdBundle.size_bytes !== undefined && createdBundle.size_bytes !== null) {
|
||||||
|
<dt class="col-sm-4" i18n>Size</dt>
|
||||||
|
<dd class="col-sm-8">{{ createdBundle.size_bytes | fileSize }}</dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div class="d-flex align-items-center gap-2 w-100">
|
||||||
|
<div class="text-light fst-italic small">
|
||||||
|
<ng-container i18n>A zip file containing the selected documents will be created for this share link bundle. This process happens in the background and may take some time, especially for large bundles.</ng-container>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm ms-auto" (click)="cancel()">{{ cancelBtnCaption }}</button>
|
||||||
|
@if (createdBundle) {
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm text-nowrap" (click)="openManage()" i18n>Manage share link bundles</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!createdBundle) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap"
|
||||||
|
(click)="submit()"
|
||||||
|
[disabled]="loading || !buttonsEnabled">
|
||||||
|
@if (loading) {
|
||||||
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
|
}
|
||||||
|
{{ btnCaption }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { FileVersion } from 'src/app/data/share-link'
|
||||||
|
import {
|
||||||
|
ShareLinkBundleStatus,
|
||||||
|
ShareLinkBundleSummary,
|
||||||
|
} from 'src/app/data/share-link-bundle'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { environment } from 'src/environments/environment'
|
||||||
|
import { ShareLinkBundleDialogComponent } from './share-link-bundle-dialog.component'
|
||||||
|
|
||||||
|
class MockToastService {
|
||||||
|
showInfo = jest.fn()
|
||||||
|
showError = jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ShareLinkBundleDialogComponent', () => {
|
||||||
|
let component: ShareLinkBundleDialogComponent
|
||||||
|
let fixture: ComponentFixture<ShareLinkBundleDialogComponent>
|
||||||
|
let clipboard: Clipboard
|
||||||
|
let toastService: MockToastService
|
||||||
|
let activeModal: NgbActiveModal
|
||||||
|
let originalApiBaseUrl: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalApiBaseUrl = environment.apiBaseUrl
|
||||||
|
toastService = new MockToastService()
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
ShareLinkBundleDialogComponent,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
NgbActiveModal,
|
||||||
|
{ provide: ToastService, useValue: toastService },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ShareLinkBundleDialogComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
clipboard = TestBed.inject(Clipboard)
|
||||||
|
activeModal = TestBed.inject(NgbActiveModal)
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllTimers()
|
||||||
|
environment.apiBaseUrl = originalApiBaseUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds payload and emits confirm on submit', () => {
|
||||||
|
const confirmSpy = jest.spyOn(component.confirmClicked, 'emit')
|
||||||
|
component.documents = [
|
||||||
|
{ id: 1, title: 'Doc 1' } as any,
|
||||||
|
{ id: 2, title: 'Doc 2' } as any,
|
||||||
|
]
|
||||||
|
component.form.setValue({
|
||||||
|
shareArchiveVersion: false,
|
||||||
|
expirationDays: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
component.submit()
|
||||||
|
|
||||||
|
expect(component.payload).toEqual({
|
||||||
|
document_ids: [1, 2],
|
||||||
|
file_version: FileVersion.Original,
|
||||||
|
expiration_days: 3,
|
||||||
|
})
|
||||||
|
expect(component.buttonsEnabled).toBe(false)
|
||||||
|
expect(confirmSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.form.setValue({
|
||||||
|
shareArchiveVersion: true,
|
||||||
|
expirationDays: 7,
|
||||||
|
})
|
||||||
|
component.submit()
|
||||||
|
|
||||||
|
expect(component.payload).toEqual({
|
||||||
|
document_ids: [1, 2],
|
||||||
|
file_version: FileVersion.Archive,
|
||||||
|
expiration_days: 7,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores submit when bundle already created', () => {
|
||||||
|
component.createdBundle = { id: 1 } as ShareLinkBundleSummary
|
||||||
|
const confirmSpy = jest.spyOn(component, 'confirm')
|
||||||
|
component.submit()
|
||||||
|
expect(confirmSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('limits preview to ten documents', () => {
|
||||||
|
const docs = Array.from({ length: 12 }).map((_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
}))
|
||||||
|
component.documents = docs as any
|
||||||
|
|
||||||
|
expect(component.selectionCount).toBe(12)
|
||||||
|
expect(component.documentPreview).toHaveLength(10)
|
||||||
|
expect(component.documentPreview[0].id).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copies share link and resets state after timeout', fakeAsync(() => {
|
||||||
|
const copySpy = jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
||||||
|
const bundle = {
|
||||||
|
slug: 'bundle-slug',
|
||||||
|
status: ShareLinkBundleStatus.Ready,
|
||||||
|
} as ShareLinkBundleSummary
|
||||||
|
|
||||||
|
component.copy(bundle)
|
||||||
|
|
||||||
|
expect(copySpy).toHaveBeenCalledWith(component.getShareUrl(bundle))
|
||||||
|
expect(component.copied).toBe(true)
|
||||||
|
expect(toastService.showInfo).toHaveBeenCalled()
|
||||||
|
|
||||||
|
tick(3000)
|
||||||
|
expect(component.copied).toBe(false)
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('generates share URLs based on API base URL', () => {
|
||||||
|
environment.apiBaseUrl = 'https://example.com/api/'
|
||||||
|
expect(
|
||||||
|
component.getShareUrl({ slug: 'abc' } as ShareLinkBundleSummary)
|
||||||
|
).toBe('https://example.com/share/abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens manage dialog when callback provided', () => {
|
||||||
|
const manageSpy = jest.fn()
|
||||||
|
component.onOpenManage = manageSpy
|
||||||
|
component.openManage()
|
||||||
|
expect(manageSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to cancel when manage callback missing', () => {
|
||||||
|
const cancelSpy = jest.spyOn(component, 'cancel')
|
||||||
|
component.onOpenManage = undefined
|
||||||
|
component.openManage()
|
||||||
|
expect(cancelSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps status and file version labels', () => {
|
||||||
|
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
|
||||||
|
'Processing'
|
||||||
|
)
|
||||||
|
expect(component.fileVersionLabel(FileVersion.Archive)).toContain('Archive')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes dialog when cancel invoked', () => {
|
||||||
|
const closeSpy = jest.spyOn(activeModal, 'close')
|
||||||
|
component.cancel()
|
||||||
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { Component, Input, inject } from '@angular/core'
|
||||||
|
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { Document } from 'src/app/data/document'
|
||||||
|
import {
|
||||||
|
FileVersion,
|
||||||
|
SHARE_LINK_EXPIRATION_OPTIONS,
|
||||||
|
} from 'src/app/data/share-link'
|
||||||
|
import {
|
||||||
|
SHARE_LINK_BUNDLE_FILE_VERSION_LABELS,
|
||||||
|
SHARE_LINK_BUNDLE_STATUS_LABELS,
|
||||||
|
ShareLinkBundleCreatePayload,
|
||||||
|
ShareLinkBundleStatus,
|
||||||
|
ShareLinkBundleSummary,
|
||||||
|
} from 'src/app/data/share-link-bundle'
|
||||||
|
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||||
|
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { environment } from 'src/environments/environment'
|
||||||
|
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-share-link-bundle-dialog',
|
||||||
|
templateUrl: './share-link-bundle-dialog.component.html',
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
FileSizePipe,
|
||||||
|
DocumentTitlePipe,
|
||||||
|
],
|
||||||
|
providers: [],
|
||||||
|
})
|
||||||
|
export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
|
||||||
|
private readonly formBuilder = inject(FormBuilder)
|
||||||
|
private readonly clipboard = inject(Clipboard)
|
||||||
|
private readonly toastService = inject(ToastService)
|
||||||
|
|
||||||
|
private _documents: Document[] = []
|
||||||
|
|
||||||
|
selectionCount = 0
|
||||||
|
documentPreview: Document[] = []
|
||||||
|
form: FormGroup = this.formBuilder.group({
|
||||||
|
shareArchiveVersion: true,
|
||||||
|
expirationDays: [7],
|
||||||
|
})
|
||||||
|
payload: ShareLinkBundleCreatePayload | null = null
|
||||||
|
|
||||||
|
readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
|
||||||
|
|
||||||
|
createdBundle: ShareLinkBundleSummary | null = null
|
||||||
|
copied = false
|
||||||
|
onOpenManage?: () => void
|
||||||
|
readonly statuses = ShareLinkBundleStatus
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.loading = false
|
||||||
|
this.title = $localize`Create share link bundle`
|
||||||
|
this.btnCaption = $localize`Create link`
|
||||||
|
}
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
set documents(docs: Document[]) {
|
||||||
|
this._documents = docs.concat()
|
||||||
|
this.selectionCount = this._documents.length
|
||||||
|
this.documentPreview = this._documents.slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
if (this.createdBundle) return
|
||||||
|
this.payload = {
|
||||||
|
document_ids: this._documents.map((doc) => doc.id),
|
||||||
|
file_version: this.form.value.shareArchiveVersion
|
||||||
|
? FileVersion.Archive
|
||||||
|
: FileVersion.Original,
|
||||||
|
expiration_days: this.form.value.expirationDays,
|
||||||
|
}
|
||||||
|
this.buttonsEnabled = false
|
||||||
|
super.confirm()
|
||||||
|
}
|
||||||
|
|
||||||
|
getShareUrl(bundle: ShareLinkBundleSummary): string {
|
||||||
|
const apiURL = new URL(environment.apiBaseUrl)
|
||||||
|
return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
|
||||||
|
bundle.slug
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(bundle: ShareLinkBundleSummary): void {
|
||||||
|
const success = this.clipboard.copy(this.getShareUrl(bundle))
|
||||||
|
if (success) {
|
||||||
|
this.copied = true
|
||||||
|
this.toastService.showInfo($localize`Share link copied to clipboard.`)
|
||||||
|
setTimeout(() => {
|
||||||
|
this.copied = false
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openManage(): void {
|
||||||
|
if (this.onOpenManage) {
|
||||||
|
this.onOpenManage()
|
||||||
|
} else {
|
||||||
|
this.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(status: ShareLinkBundleSummary['status']): string {
|
||||||
|
return SHARE_LINK_BUNDLE_STATUS_LABELS[status] ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
fileVersionLabel(version: FileVersion): string {
|
||||||
|
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title">{{ title }}</h4>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (loading) {
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
|
<span i18n>Loading share link bundles…</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!loading && error) {
|
||||||
|
<div class="alert alert-danger mb-0" role="alert">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!loading && !error) {
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<p class="mb-0 text-muted small">
|
||||||
|
<ng-container i18n>Status updates every few seconds while bundles are being prepared.</ng-container>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@if (bundles.length === 0) {
|
||||||
|
<p class="mb-0 text-muted fst-italic" i18n>No share link bundles currently exist.</p>
|
||||||
|
}
|
||||||
|
@if (bundles.length > 0) {
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" i18n>Created</th>
|
||||||
|
<th scope="col" i18n>Status</th>
|
||||||
|
<th scope="col" i18n>Size</th>
|
||||||
|
<th scope="col" i18n>Expires</th>
|
||||||
|
<th scope="col" i18n>Documents</th>
|
||||||
|
<th scope="col" i18n>File version</th>
|
||||||
|
<th scope="col" class="text-end" i18n>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (bundle of bundles; track bundle.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div>{{ bundle.created | date: 'short' }}</div>
|
||||||
|
@if (bundle.built_at) {
|
||||||
|
<div class="small text-muted">
|
||||||
|
<ng-container i18n>Built:</ng-container> {{ bundle.built_at | date: 'short' }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
@if (bundle.status === statuses.Failed && bundle.last_error) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-link p-0 text-danger"
|
||||||
|
[ngbPopover]="errorDetail"
|
||||||
|
popoverClass="popover-sm"
|
||||||
|
triggers="mouseover:mouseleave"
|
||||||
|
placement="auto"
|
||||||
|
aria-label="View error details"
|
||||||
|
i18n-aria-label
|
||||||
|
>
|
||||||
|
<span class="badge text-bg-warning text-uppercase me-2">{{ statusLabel(bundle.status) }}</span>
|
||||||
|
<i-bs name="exclamation-triangle-fill" class="text-warning"></i-bs>
|
||||||
|
</button>
|
||||||
|
<ng-template #errorDetail>
|
||||||
|
@if (bundle.last_error.timestamp) {
|
||||||
|
<div class="text-muted small mb-1">
|
||||||
|
{{ bundle.last_error.timestamp | date: 'short' }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<h6>{{ bundle.last_error.exception_type || ($localize`Unknown error`) }}</h6>
|
||||||
|
@if (bundle.last_error.message) {
|
||||||
|
<pre class="text-muted small"><code>{{ bundle.last_error.message }}</code></pre>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
}
|
||||||
|
@if (bundle.status === statuses.Processing || bundle.status === statuses.Pending) {
|
||||||
|
<span class="spinner-border spinner-border-sm" role="status"></span>
|
||||||
|
}
|
||||||
|
@if (bundle.status !== statuses.Failed) {
|
||||||
|
<span class="badge text-bg-secondary text-uppercase">{{ statusLabel(bundle.status) }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (bundle.size_bytes !== undefined && bundle.size_bytes !== null) {
|
||||||
|
{{ bundle.size_bytes | fileSize }}
|
||||||
|
}
|
||||||
|
@if (bundle.size_bytes === undefined || bundle.size_bytes === null) {
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (bundle.expiration) {
|
||||||
|
{{ bundle.expiration | date: 'short' }}
|
||||||
|
}
|
||||||
|
@if (!bundle.expiration) {
|
||||||
|
<span i18n>Never</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ bundle.document_count }}</td>
|
||||||
|
<td>{{ fileVersionLabel(bundle.file_version) }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-primary"
|
||||||
|
[disabled]="bundle.status !== statuses.Ready"
|
||||||
|
(click)="copy(bundle)"
|
||||||
|
title="Copy share link"
|
||||||
|
i18n-title
|
||||||
|
>
|
||||||
|
@if (copiedSlug === bundle.slug) {
|
||||||
|
<i-bs name="clipboard-check"></i-bs>
|
||||||
|
}
|
||||||
|
@if (copiedSlug !== bundle.slug) {
|
||||||
|
<i-bs name="clipboard"></i-bs>
|
||||||
|
}
|
||||||
|
<span class="visually-hidden" i18n>Copy share link</span>
|
||||||
|
</button>
|
||||||
|
@if (bundle.status === statuses.Failed) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-warning"
|
||||||
|
[disabled]="loading"
|
||||||
|
(click)="retry(bundle)"
|
||||||
|
>
|
||||||
|
<i-bs name="arrow-clockwise"></i-bs>
|
||||||
|
<span class="visually-hidden" i18n>Retry</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<pngx-confirm-button
|
||||||
|
buttonClasses="btn btn-sm btn-outline-danger"
|
||||||
|
[disabled]="loading"
|
||||||
|
(confirm)="delete(bundle)"
|
||||||
|
iconName="trash"
|
||||||
|
>
|
||||||
|
<span class="visually-hidden" i18n>Delete share link bundle</span>
|
||||||
|
</pngx-confirm-button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="close()" i18n>Close</button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
:host ::ng-deep .popover {
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { of, throwError } from 'rxjs'
|
||||||
|
import { FileVersion } from 'src/app/data/share-link'
|
||||||
|
import {
|
||||||
|
ShareLinkBundleStatus,
|
||||||
|
ShareLinkBundleSummary,
|
||||||
|
} from 'src/app/data/share-link-bundle'
|
||||||
|
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { environment } from 'src/environments/environment'
|
||||||
|
import { ShareLinkBundleManageDialogComponent } from './share-link-bundle-manage-dialog.component'
|
||||||
|
|
||||||
|
class MockShareLinkBundleService {
|
||||||
|
listAllBundles = jest.fn()
|
||||||
|
delete = jest.fn()
|
||||||
|
rebuildBundle = jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockToastService {
|
||||||
|
showInfo = jest.fn()
|
||||||
|
showError = jest.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ShareLinkBundleManageDialogComponent', () => {
|
||||||
|
let component: ShareLinkBundleManageDialogComponent
|
||||||
|
let fixture: ComponentFixture<ShareLinkBundleManageDialogComponent>
|
||||||
|
let service: MockShareLinkBundleService
|
||||||
|
let toastService: MockToastService
|
||||||
|
let clipboard: Clipboard
|
||||||
|
let activeModal: NgbActiveModal
|
||||||
|
let originalApiBaseUrl: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new MockShareLinkBundleService()
|
||||||
|
toastService = new MockToastService()
|
||||||
|
originalApiBaseUrl = environment.apiBaseUrl
|
||||||
|
|
||||||
|
service.listAllBundles.mockReturnValue(of([]))
|
||||||
|
service.delete.mockReturnValue(of(true))
|
||||||
|
service.rebuildBundle.mockReturnValue(of(sampleBundle()))
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
ShareLinkBundleManageDialogComponent,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
NgbActiveModal,
|
||||||
|
{ provide: ShareLinkBundleService, useValue: service },
|
||||||
|
{ provide: ToastService, useValue: toastService },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ShareLinkBundleManageDialogComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
clipboard = TestBed.inject(Clipboard)
|
||||||
|
activeModal = TestBed.inject(NgbActiveModal)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
component.ngOnDestroy()
|
||||||
|
fixture.destroy()
|
||||||
|
environment.apiBaseUrl = originalApiBaseUrl
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const sampleBundle = (overrides: Partial<ShareLinkBundleSummary> = {}) =>
|
||||||
|
({
|
||||||
|
id: 1,
|
||||||
|
slug: 'bundle-slug',
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
document_count: 1,
|
||||||
|
documents: [1],
|
||||||
|
status: ShareLinkBundleStatus.Pending,
|
||||||
|
file_version: FileVersion.Archive,
|
||||||
|
last_error: undefined,
|
||||||
|
...overrides,
|
||||||
|
}) as ShareLinkBundleSummary
|
||||||
|
|
||||||
|
it('loads bundles on init and polls periodically', fakeAsync(() => {
|
||||||
|
const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })]
|
||||||
|
service.listAllBundles.mockReset()
|
||||||
|
service.listAllBundles
|
||||||
|
.mockReturnValueOnce(of(bundles))
|
||||||
|
.mockReturnValue(of(bundles))
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
tick()
|
||||||
|
|
||||||
|
expect(service.listAllBundles).toHaveBeenCalledTimes(1)
|
||||||
|
expect(component.bundles).toEqual(bundles)
|
||||||
|
expect(component.loading).toBe(false)
|
||||||
|
expect(component.error).toBeNull()
|
||||||
|
|
||||||
|
tick(5000)
|
||||||
|
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('handles errors when loading bundles', fakeAsync(() => {
|
||||||
|
service.listAllBundles.mockReset()
|
||||||
|
service.listAllBundles
|
||||||
|
.mockReturnValueOnce(throwError(() => new Error('load fail')))
|
||||||
|
.mockReturnValue(of([]))
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
tick()
|
||||||
|
|
||||||
|
expect(component.error).toContain('Failed to load share link bundles.')
|
||||||
|
expect(toastService.showError).toHaveBeenCalled()
|
||||||
|
expect(component.loading).toBe(false)
|
||||||
|
|
||||||
|
tick(5000)
|
||||||
|
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('copies bundle links when ready', fakeAsync(() => {
|
||||||
|
jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
tick()
|
||||||
|
|
||||||
|
const readyBundle = sampleBundle({
|
||||||
|
slug: 'ready-slug',
|
||||||
|
status: ShareLinkBundleStatus.Ready,
|
||||||
|
})
|
||||||
|
component.copy(readyBundle)
|
||||||
|
|
||||||
|
expect(clipboard.copy).toHaveBeenCalledWith(
|
||||||
|
component.getShareUrl(readyBundle)
|
||||||
|
)
|
||||||
|
expect(component.copiedSlug).toBe('ready-slug')
|
||||||
|
expect(toastService.showInfo).toHaveBeenCalled()
|
||||||
|
|
||||||
|
tick(3000)
|
||||||
|
expect(component.copiedSlug).toBeNull()
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('ignores copy requests for non-ready bundles', fakeAsync(() => {
|
||||||
|
const copySpy = jest.spyOn(clipboard, 'copy')
|
||||||
|
fixture.detectChanges()
|
||||||
|
tick()
|
||||||
|
component.copy(sampleBundle({ status: ShareLinkBundleStatus.Pending }))
|
||||||
|
expect(copySpy).not.toHaveBeenCalled()
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('deletes bundles and refreshes list', fakeAsync(() => {
|
||||||
|
service.listAllBundles.mockReturnValue(of([]))
|
||||||
|
service.delete.mockReturnValue(of(true))
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
tick()
|
||||||
|
|
||||||
|
component.delete(sampleBundle())
|
||||||
|
tick()
|
||||||
|
|
||||||
|
expect(service.delete).toHaveBeenCalled()
|
||||||
|
expect(toastService.showInfo).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('deleted.')
|
||||||
|
)
|
||||||
|
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
||||||
|
expect(component.loading).toBe(false)
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('handles delete errors gracefully', fakeAsync(() => {
|
||||||
|
service.listAllBundles.mockReturnValue(of([]))
|
||||||
|
service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
tick()
|
||||||
|
|
||||||
|
component.delete(sampleBundle())
|
||||||
|
tick()
|
||||||
|
|
||||||
|
expect(toastService.showError).toHaveBeenCalled()
|
||||||
|
expect(component.loading).toBe(false)
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('retries bundle build and replaces existing entry', fakeAsync(() => {
|
||||||
|
service.listAllBundles.mockReturnValue(of([]))
|
||||||
|
const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready })
|
||||||
|
service.rebuildBundle.mockReturnValue(of(updated))
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
tick()
|
||||||
|
|
||||||
|
component.bundles = [sampleBundle()]
|
||||||
|
component.retry(component.bundles[0])
|
||||||
|
tick()
|
||||||
|
|
||||||
|
expect(service.rebuildBundle).toHaveBeenCalledWith(updated.id)
|
||||||
|
expect(component.bundles[0].status).toBe(ShareLinkBundleStatus.Ready)
|
||||||
|
expect(toastService.showInfo).toHaveBeenCalled()
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('adds new bundle when retry returns unknown entry', fakeAsync(() => {
|
||||||
|
service.listAllBundles.mockReturnValue(of([]))
|
||||||
|
service.rebuildBundle.mockReturnValue(
|
||||||
|
of(sampleBundle({ id: 99, slug: 'new-slug' }))
|
||||||
|
)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
tick()
|
||||||
|
|
||||||
|
component.bundles = [sampleBundle()]
|
||||||
|
component.retry({ id: 99 } as ShareLinkBundleSummary)
|
||||||
|
tick()
|
||||||
|
|
||||||
|
expect(component.bundles.find((bundle) => bundle.id === 99)).toBeTruthy()
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('handles retry errors', fakeAsync(() => {
|
||||||
|
service.listAllBundles.mockReturnValue(of([]))
|
||||||
|
service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
tick()
|
||||||
|
|
||||||
|
component.retry(sampleBundle())
|
||||||
|
tick()
|
||||||
|
|
||||||
|
expect(toastService.showError).toHaveBeenCalled()
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('maps helpers and closes dialog', fakeAsync(() => {
|
||||||
|
service.listAllBundles.mockReturnValue(of([]))
|
||||||
|
fixture.detectChanges()
|
||||||
|
tick()
|
||||||
|
|
||||||
|
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
|
||||||
|
'Processing'
|
||||||
|
)
|
||||||
|
expect(component.fileVersionLabel(FileVersion.Original)).toContain(
|
||||||
|
'Original'
|
||||||
|
)
|
||||||
|
|
||||||
|
environment.apiBaseUrl = 'https://example.com/api/'
|
||||||
|
const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' }))
|
||||||
|
expect(url).toBe('https://example.com/share/sluggy')
|
||||||
|
|
||||||
|
const closeSpy = jest.spyOn(activeModal, 'close')
|
||||||
|
component.close()
|
||||||
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
}))
|
||||||
|
})
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||||
|
import { NgbActiveModal, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
|
||||||
|
import { FileVersion } from 'src/app/data/share-link'
|
||||||
|
import {
|
||||||
|
SHARE_LINK_BUNDLE_FILE_VERSION_LABELS,
|
||||||
|
SHARE_LINK_BUNDLE_STATUS_LABELS,
|
||||||
|
ShareLinkBundleStatus,
|
||||||
|
ShareLinkBundleSummary,
|
||||||
|
} from 'src/app/data/share-link-bundle'
|
||||||
|
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
|
||||||
|
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { environment } from 'src/environments/environment'
|
||||||
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
|
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-share-link-bundle-manage-dialog',
|
||||||
|
templateUrl: './share-link-bundle-manage-dialog.component.html',
|
||||||
|
styleUrls: ['./share-link-bundle-manage-dialog.component.scss'],
|
||||||
|
imports: [
|
||||||
|
ConfirmButtonComponent,
|
||||||
|
CommonModule,
|
||||||
|
NgbPopoverModule,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
FileSizePipe,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ShareLinkBundleManageDialogComponent
|
||||||
|
extends LoadingComponentWithPermissions
|
||||||
|
implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
private readonly activeModal = inject(NgbActiveModal)
|
||||||
|
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||||
|
private readonly toastService = inject(ToastService)
|
||||||
|
private readonly clipboard = inject(Clipboard)
|
||||||
|
|
||||||
|
title = $localize`Share link bundles`
|
||||||
|
|
||||||
|
bundles: ShareLinkBundleSummary[] = []
|
||||||
|
error: string | null = null
|
||||||
|
copiedSlug: string | null = null
|
||||||
|
|
||||||
|
readonly statuses = ShareLinkBundleStatus
|
||||||
|
readonly fileVersions = FileVersion
|
||||||
|
|
||||||
|
private readonly refresh$ = new Subject<boolean>()
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.refresh$
|
||||||
|
.pipe(
|
||||||
|
switchMap((silent) => {
|
||||||
|
if (!silent) {
|
||||||
|
this.loading = true
|
||||||
|
}
|
||||||
|
this.error = null
|
||||||
|
return this.shareLinkBundleService.listAllBundles().pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
if (!silent) {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
this.error = $localize`Failed to load share link bundles.`
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error retrieving share link bundles.`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return of(null)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
takeUntil(this.unsubscribeNotifier)
|
||||||
|
)
|
||||||
|
.subscribe((results) => {
|
||||||
|
if (results) {
|
||||||
|
this.bundles = results
|
||||||
|
this.copiedSlug = null
|
||||||
|
}
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
|
||||||
|
this.triggerRefresh(false)
|
||||||
|
timer(5000, 5000)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => this.triggerRefresh(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
super.ngOnDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
getShareUrl(bundle: ShareLinkBundleSummary): string {
|
||||||
|
const apiURL = new URL(environment.apiBaseUrl)
|
||||||
|
return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
|
||||||
|
bundle.slug
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(bundle: ShareLinkBundleSummary): void {
|
||||||
|
if (bundle.status !== ShareLinkBundleStatus.Ready) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const success = this.clipboard.copy(this.getShareUrl(bundle))
|
||||||
|
if (success) {
|
||||||
|
this.copiedSlug = bundle.slug
|
||||||
|
setTimeout(() => {
|
||||||
|
this.copiedSlug = null
|
||||||
|
}, 3000)
|
||||||
|
this.toastService.showInfo($localize`Share link copied to clipboard.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(bundle: ShareLinkBundleSummary): void {
|
||||||
|
this.error = null
|
||||||
|
this.loading = true
|
||||||
|
this.shareLinkBundleService.delete(bundle).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toastService.showInfo($localize`Share link bundle deleted.`)
|
||||||
|
this.triggerRefresh(false)
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.loading = false
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error deleting share link bundle.`,
|
||||||
|
e
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
retry(bundle: ShareLinkBundleSummary): void {
|
||||||
|
this.error = null
|
||||||
|
this.shareLinkBundleService.rebuildBundle(bundle.id).subscribe({
|
||||||
|
next: (updated) => {
|
||||||
|
this.toastService.showInfo(
|
||||||
|
$localize`Share link bundle rebuild requested.`
|
||||||
|
)
|
||||||
|
this.replaceBundle(updated)
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.toastService.showError($localize`Error requesting rebuild.`, e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(status: ShareLinkBundleStatus): string {
|
||||||
|
return SHARE_LINK_BUNDLE_STATUS_LABELS[status] ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
fileVersionLabel(version: FileVersion): string {
|
||||||
|
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.activeModal.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private replaceBundle(updated: ShareLinkBundleSummary): void {
|
||||||
|
const index = this.bundles.findIndex((bundle) => bundle.id === updated.id)
|
||||||
|
if (index >= 0) {
|
||||||
|
this.bundles = [
|
||||||
|
...this.bundles.slice(0, index),
|
||||||
|
updated,
|
||||||
|
...this.bundles.slice(index + 1),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
this.bundles = [updated, ...this.bundles]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private triggerRefresh(silent: boolean): void {
|
||||||
|
this.refresh$.next(silent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
<div class="input-group w-100 mt-2">
|
<div class="input-group w-100 mt-2">
|
||||||
<label class="input-group-text" for="addLink"><ng-container i18n>Expires</ng-container>:</label>
|
<label class="input-group-text" for="addLink"><ng-container i18n>Expires</ng-container>:</label>
|
||||||
<select class="form-select fs-6" [(ngModel)]="expirationDays">
|
<select class="form-select fs-6" [(ngModel)]="expirationDays">
|
||||||
@for (option of EXPIRATION_OPTIONS; track option) {
|
@for (option of expirationOptions; track option) {
|
||||||
<option [ngValue]="option.value">{{ option.label }}</option>
|
<option [ngValue]="option.value">{{ option.label }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user