Compare commits

..

1 Commits

288 changed files with 8897 additions and 36564 deletions

View File

@@ -89,18 +89,6 @@ Additional tasks are available for common maintenance operations:
- **Migrate Database**: To apply database migrations.
- **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!
Follow the steps above to get your development environment up and running. Happy coding!

View File

@@ -3,31 +3,26 @@
"dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml",
"service": "paperless-development",
"workspaceFolder": "/usr/src/paperless/paperless-ngx",
"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'",
"postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run pre-commit install'",
"customizations": {
"vscode": {
"extensions": [
"mhutchie.git-graph",
"ms-python.python",
"ms-vscode.js-debug-nightly",
"eamodio.gitlens",
"yzhang.markdown-all-in-one",
"pnpm.pnpm"
],
"settings": {
"python.defaultInterpreterPath": "/usr/src/paperless/paperless-ngx/.venv/bin/python",
"python.pythonPath": "/usr/src/paperless/paperless-ngx/.venv/bin/python",
"python.terminal.activateEnvInCurrentTerminal": true,
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,
"files.trimTrailingWhitespace": true
}
"extensions": [
"mhutchie.git-graph",
"ms-python.python",
"ms-vscode.js-debug-nightly",
"eamodio.gitlens",
"yzhang.markdown-all-in-one"
],
"settings": {
"python.defaultInterpreterPath": "/usr/src/paperless/paperless-ngx/.venv/bin/python",
"python.pythonPath": "/usr/src/paperless/paperless-ngx/.venv/bin/python",
"python.terminal.activateEnvInCurrentTerminal": true,
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,
"files.trimTrailingWhitespace": true
}
}
},
"remoteUser": "paperless"
}
},
"remoteUser": "paperless"
}

View File

@@ -33,7 +33,7 @@
"label": "Start: Frontend Angular",
"description": "Start the Frontend Angular Dev Server",
"type": "shell",
"command": "pnpm exec ng serve --host 0.0.0.0",
"command": "pnpm start",
"isBackground": true,
"options": {
"cwd": "${workspaceFolder}/src-ui"
@@ -116,9 +116,9 @@
},
{
"label": "Maintenance: Build Documentation",
"description": "Build the documentation with Zensical",
"description": "Build the documentation with MkDocs",
"type": "shell",
"command": "uv run zensical build && uv run zensical serve",
"command": "uv run mkdocs build --config-file mkdocs.yml && uv run mkdocs serve",
"group": "none",
"presentation": {
"echo": true,
@@ -174,22 +174,12 @@
{
"label": "Maintenance: Install Frontend Dependencies",
"description": "Install frontend (pnpm) dependencies",
"type": "shell",
"command": "pnpm install",
"type": "pnpm",
"script": "install",
"path": "src-ui",
"group": "clean",
"problemMatcher": [],
"options": {
"cwd": "${workspaceFolder}/src-ui"
},
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "shared",
"showReuseMessage": false,
"clear": true,
"revealProblems": "onProblem"
}
"detail": "install dependencies from package"
},
{
"description": "Clean install frontend dependencies and build the frontend for production",

View File

@@ -28,4 +28,3 @@
./resources
# Other stuff
**/*.drawio.png
.mypy_baseline

View File

@@ -37,6 +37,6 @@ NOTE: PRs that do not address the following will not be merged, please do not sk
- [ ] If applicable, I have included testing coverage for new code in this PR, for [backend](https://docs.paperless-ngx.com/development/#testing) and / or [front-end](https://docs.paperless-ngx.com/development/#testing-and-code-style) changes.
- [ ] If applicable, I have tested my code for breaking changes & regressions on both mobile & desktop devices, using the latest version of major browsers.
- [ ] If applicable, I have checked that all tests pass, see [documentation](https://docs.paperless-ngx.com/development/#back-end-development).
- [ ] I have run all Git `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks).
- [ ] I have run all `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks).
- [ ] I have made corresponding changes to the documentation as needed.
- [ ] In the description of the PR above I have disclosed the use of AI tools in the coding of this PR.

View File

@@ -46,8 +46,8 @@ updates:
patterns:
- "*pytest*"
- "ruff"
- "zensical"
- "prek*"
- "mkdocs-material"
- "pre-commit*"
# Django & DRF Ecosystem
django-ecosystem:
patterns:

View File

@@ -75,6 +75,9 @@ jobs:
env:
NLTK_DATA: ${{ env.NLTK_DATA }}
PAPERLESS_CI_TEST: 1
PAPERLESS_MAIL_TEST_HOST: ${{ secrets.TEST_MAIL_HOST }}
PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }}
PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }}
run: |
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
@@ -99,52 +102,3 @@ jobs:
run: |
docker compose --file docker/compose/docker-compose.ci-test.yml logs
docker compose --file docker/compose/docker-compose.ci-test.yml down
typing:
name: Check project typing
runs-on: ubuntu-24.04
env:
DEFAULT_PYTHON: "3.12"
steps:
- name: Checkout
uses: actions/checkout@v6.0.1
- name: Set up Python
id: setup-python
uses: actions/setup-python@v6.2.0
with:
python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv
uses: astral-sh/setup-uv@v7.2.1
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ steps.setup-python.outputs.python-version }}
- name: Install Python dependencies
run: |
uv sync \
--python ${{ steps.setup-python.outputs.python-version }} \
--group testing \
--group typing \
--frozen
- name: List installed Python dependencies
run: |
uv pip list
- name: Check typing (pyrefly)
run: |
uv run pyrefly \
check \
src/
- name: Cache Mypy
uses: actions/cache@v5.0.3
with:
path: .mypy_cache
# Keyed by OS, Python version, and dependency hashes
key: ${{ runner.os }}-mypy-py${{ env.DEFAULT_PYTHON }}-${{ hashFiles('pyproject.toml', 'uv.lock') }}
restore-keys: |
${{ runner.os }}-mypy-py${{ env.DEFAULT_PYTHON }}-
${{ runner.os }}-mypy-
- name: Check typing (mypy)
run: |
uv run mypy \
--show-error-codes \
--warn-unused-configs \
src/ | uv run mypy-baseline filter

View File

@@ -46,13 +46,14 @@ jobs:
id: ref
run: |
ref_name="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}"
# Sanitize by replacing / with - for use in tags and cache keys
sanitized_ref="${ref_name//\//-}"
# Sanitize by replacing / with - for cache keys
cache_ref="${ref_name//\//-}"
echo "ref_name=${ref_name}"
echo "sanitized_ref=${sanitized_ref}"
echo "cache_ref=${cache_ref}"
echo "name=${sanitized_ref}" >> $GITHUB_OUTPUT
echo "name=${ref_name}" >> $GITHUB_OUTPUT
echo "cache-ref=${cache_ref}" >> $GITHUB_OUTPUT
- name: Check push permissions
id: check-push
env:
@@ -61,14 +62,12 @@ jobs:
# should-push: Should we push to GHCR?
# True for:
# 1. Pushes (tags/dev/beta) - filtered via the workflow triggers
# 2. Manual dispatch - always push to GHCR
# 3. Internal PRs where the branch name starts with 'feature-' or 'fix-'
# 2. Internal PRs where the branch name starts with 'feature-' - filtered here when a PR is synced
should_push="false"
if [[ "${{ github.event_name }}" == "push" ]]; then
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
if [[ "${REF_NAME}" == feature-* || "${REF_NAME}" == fix-* ]]; then
should_push="true"
@@ -106,7 +105,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.12.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.7.0
uses: docker/login-action@v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -140,9 +139,9 @@ jobs:
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 }}
cache-from: |
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:${{ steps.ref.outputs.cache-ref }}-${{ matrix.arch }}
type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}/cache/app:dev-${{ matrix.arch }}
cache-to: ${{ steps.check-push.outputs.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) || '' }}
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) || '' }}
- name: Export digest
if: steps.check-push.outputs.should-push == 'true'
run: |
@@ -180,20 +179,20 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.12.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.7.0
uses: docker/login-action@v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@v3.7.0
uses: docker/login-action@v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Quay.io
if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@v3.7.0
uses: docker/login-action@v3.6.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}

View File

@@ -6,34 +6,25 @@ on:
- dev
paths:
- 'docs/**'
- 'zensical.toml'
- 'pyproject.toml'
- 'uv.lock'
- 'mkdocs.yml'
- '.github/workflows/ci-docs.yml'
pull_request:
paths:
- 'docs/**'
- 'zensical.toml'
- 'pyproject.toml'
- 'uv.lock'
- 'mkdocs.yml'
- '.github/workflows/ci-docs.yml'
workflow_dispatch:
concurrency:
group: docs-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
pages: write
id-token: write
env:
DEFAULT_UV_VERSION: "0.9.x"
DEFAULT_PYTHON_VERSION: "3.11"
jobs:
build:
name: Build Documentation
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
steps:
- uses: actions/configure-pages@v5
- name: Checkout
uses: actions/checkout@v6
- name: Set up Python
@@ -56,23 +47,42 @@ jobs:
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
zensical build --clean
- name: Upload GitHub Pages artifact
uses: actions/upload-pages-artifact@v4
mkdocs build --config-file ./mkdocs.yml
- name: Upload artifact
uses: actions/upload-artifact@v6
with:
path: site
name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
name: documentation
path: site/
retention-days: 7
deploy:
name: Deploy Documentation
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-slim
steps:
- name: Deploy GitHub Pages
uses: actions/deploy-pages@v4
id: deployment
- name: Checkout
uses: actions/checkout@v6
- name: Set up Python
id: setup-python
uses: actions/setup-python@v6
with:
artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install Python dependencies
run: |
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
- name: Deploy documentation
run: |
echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME"
git config --global user.name "${{ github.actor }}"
git config --global user.email "${{ github.actor }}@users.noreply.github.com"
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
mkdocs gh-deploy --force --no-history

View File

@@ -121,7 +121,7 @@ jobs:
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
needs: install-dependencies
runs-on: ubuntu-24.04
container: mcr.microsoft.com/playwright:v1.58.2-noble
container: mcr.microsoft.com/playwright:v1.57.0-noble
env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1

View File

@@ -10,15 +10,15 @@ concurrency:
group: lint-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Linting via prek
pre-commit:
name: Pre-commit Checks
runs-on: ubuntu-slim
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v6.2.0
uses: actions/setup-python@v6
with:
python-version: "3.14"
- name: Run prek
uses: j178/prek-action@v1.1.0
python-version: "3.11"
- name: Run pre-commit
uses: pre-commit/action@v3.0.1

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Wait for Docker build
uses: lewagon/wait-on-check-action@v1.5.0
uses: lewagon/wait-on-check-action@v1.4.1
with:
ref: ${{ github.sha }}
check-name: 'Build Docker Image'
@@ -70,7 +70,7 @@ jobs:
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
zensical build --clean
mkdocs build --config-file ./mkdocs.yml
# ---- Prepare Release ----
- name: Generate requirements file
run: |
@@ -155,7 +155,6 @@ jobs:
version: ${{ steps.get-version.outputs.version }}
prerelease: ${{ steps.get-version.outputs.prerelease }}
publish: true
commitish: main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload release archive
@@ -211,7 +210,7 @@ jobs:
uv run \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
prek run --files changelog.md || true
pre-commit run --files changelog.md || true
git config --global user.name "github-actions"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"

View File

@@ -10,7 +10,7 @@ jobs:
synchronize-with-crowdin:
name: Crowdin Sync
if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
steps:
- name: Checkout
uses: actions/checkout@v6

View File

@@ -8,7 +8,7 @@ permissions:
jobs:
pr-bot:
name: Automated PR Bot
runs-on: ubuntu-latest
runs-on: ubuntu-slim
steps:
- name: Label PR by file path or branch name
# see .github/labeler.yml for the labeler config

View File

@@ -12,7 +12,7 @@ permissions:
jobs:
pr_opened_or_reopened:
name: pr_opened_or_reopened
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
permissions:
# write permission is required for autolabeler
pull-requests: write

View File

@@ -13,7 +13,7 @@ jobs:
stale:
name: 'Stale'
if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
steps:
- uses: actions/stale@v10
with:
@@ -35,7 +35,7 @@ jobs:
lock-threads:
name: 'Lock Old Threads'
if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
steps:
- uses: dessant/lock-threads@v6
with:
@@ -55,7 +55,7 @@ jobs:
close-answered-discussions:
name: 'Close Answered Discussions'
if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
steps:
- uses: actions/github-script@v8
with:
@@ -112,7 +112,7 @@ jobs:
close-outdated-discussions:
name: 'Close Outdated Discussions'
if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
steps:
- uses: actions/github-script@v8
with:
@@ -204,7 +204,7 @@ jobs:
close-unsupported-feature-requests:
name: 'Close Unsupported Feature Requests'
if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
steps:
- uses: actions/github-script@v8
with:

View File

@@ -6,7 +6,7 @@ on:
jobs:
generate-translate-strings:
name: Generate Translation Strings
runs-on: ubuntu-latest
runs-on: ubuntu-slim
permissions:
contents: write
steps:

3
.gitignore vendored
View File

@@ -40,7 +40,6 @@ htmlcov/
.coverage
.coverage.*
.cache
.uv-cache
nosetests.xml
coverage.xml
*,cover
@@ -54,7 +53,7 @@ junit.xml
# Django stuff:
*.log
# Zensical documentation
# MkDocs documentation
site/
# PyBuilder

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
# This file configures pre-commit hooks.
# See https://pre-commit.com/ for general information
# See https://pre-commit.com/hooks.html for a listing of possible hooks
# We actually run via https://github.com/j178/prek which is compatible
repos:
# General hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
@@ -38,7 +37,7 @@ repos:
- json
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
- repo: https://github.com/rbubley/mirrors-prettier
rev: 'v3.8.1'
rev: 'v3.6.2'
hooks:
- id: prettier
types_or:
@@ -50,12 +49,12 @@ repos:
- 'prettier-plugin-organize-imports@4.1.0'
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.0
rev: v0.14.5
hooks:
- id: ruff-check
- id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.12.1"
rev: "v2.11.1"
hooks:
- id: pyproject-fmt
# Dockerfile hooks
@@ -77,7 +76,7 @@ repos:
hooks:
- id: shellcheck
- repo: https://github.com/google/yamlfmt
rev: v0.21.0
rev: v0.20.0
hooks:
- id: yamlfmt
exclude: "^src-ui/pnpm-lock.yaml"

File diff suppressed because one or more lines are too long

View File

@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs
# Comments:
# - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.9.29-python3.12-trixie-slim AS s6-overlay-base
FROM ghcr.io/astral-sh/uv:0.9.26-python3.12-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6

View File

@@ -4,7 +4,7 @@
# correct networking for the tests
services:
gotenberg:
image: docker.io/gotenberg/gotenberg:8.26
image: docker.io/gotenberg/gotenberg:8.25
hostname: gotenberg
container_name: gotenberg
network_mode: host
@@ -23,24 +23,3 @@ services:
container_name: tika
network_mode: host
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

View File

@@ -72,7 +72,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.26
image: docker.io/gotenberg/gotenberg:8.25
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.

View File

@@ -66,7 +66,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.26
image: docker.io/gotenberg/gotenberg:8.25
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.

View File

@@ -55,7 +55,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.26
image: docker.io/gotenberg/gotenberg:8.25
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.

View File

@@ -1,14 +0,0 @@
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;
}
}

View File

@@ -7,11 +7,6 @@ cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py management_command "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py management_command "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py management_command "$@"
else
echo "Unknown user."
exit 1
s6-setuidgid paperless python3 manage.py management_command "$@"
fi

View File

@@ -6,12 +6,7 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py convert_mariadb_uuid "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py convert_mariadb_uuid "$@"
python3 manage.py convert_mariadb_uuid "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py convert_mariadb_uuid "$@"
else
echo "Unknown user."
exit 1
s6-setuidgid paperless python3 manage.py convert_mariadb_uuid "$@"
fi

View File

@@ -6,12 +6,7 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py createsuperuser "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py createsuperuser "$@"
python3 manage.py createsuperuser "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py createsuperuser "$@"
else
echo "Unknown user."
exit 1
s6-setuidgid paperless python3 manage.py createsuperuser "$@"
fi

View File

@@ -6,12 +6,7 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_archiver "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_archiver "$@"
python3 manage.py document_archiver "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_archiver "$@"
else
echo "Unknown user."
exit 1
s6-setuidgid paperless python3 manage.py document_archiver "$@"
fi

View File

@@ -6,12 +6,7 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_create_classifier "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_create_classifier "$@"
python3 manage.py document_create_classifier "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_create_classifier "$@"
else
echo "Unknown user."
exit 1
s6-setuidgid paperless python3 manage.py document_create_classifier "$@"
fi

View File

@@ -6,12 +6,7 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_exporter "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_exporter "$@"
python3 manage.py document_exporter "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_exporter "$@"
else
echo "Unknown user."
exit 1
s6-setuidgid paperless python3 manage.py document_exporter "$@"
fi

View File

@@ -6,12 +6,7 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_fuzzy_match "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_fuzzy_match "$@"
python3 manage.py document_fuzzy_match "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_fuzzy_match "$@"
else
echo "Unknown user."
exit 1
s6-setuidgid paperless python3 manage.py document_fuzzy_match "$@"
fi

View File

@@ -6,12 +6,7 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_importer "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_importer "$@"
python3 manage.py document_importer "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_importer "$@"
else
echo "Unknown user."
exit 1
s6-setuidgid paperless python3 manage.py document_importer "$@"
fi

View File

@@ -6,12 +6,7 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_index "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_index "$@"
python3 manage.py document_index "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_index "$@"
else
echo "Unknown user."
exit 1
s6-setuidgid paperless python3 manage.py document_index "$@"
fi

View File

@@ -6,12 +6,7 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_renamer "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_renamer "$@"
python3 manage.py document_renamer "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_renamer "$@"
else
echo "Unknown user."
exit 1
s6-setuidgid paperless python3 manage.py document_renamer "$@"
fi

View File

@@ -6,12 +6,7 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_retagger "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_retagger "$@"
python3 manage.py document_retagger "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_retagger "$@"
else
echo "Unknown user."
exit 1
s6-setuidgid paperless python3 manage.py document_retagger "$@"
fi

View File

@@ -6,12 +6,7 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_sanity_checker "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_sanity_checker "$@"
python3 manage.py document_sanity_checker "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_sanity_checker "$@"
else
echo "Unknown user."
exit 1
s6-setuidgid paperless python3 manage.py document_sanity_checker "$@"
fi

View File

@@ -6,12 +6,7 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py document_thumbnails "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py document_thumbnails "$@"
python3 manage.py document_thumbnails "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py document_thumbnails "$@"
else
echo "Unknown user."
exit 1
s6-setuidgid paperless python3 manage.py document_thumbnails "$@"
fi

View File

@@ -6,12 +6,7 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py mail_fetcher "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py mail_fetcher "$@"
python3 manage.py mail_fetcher "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py mail_fetcher "$@"
else
echo "Unknown user."
exit 1
s6-setuidgid paperless python3 manage.py mail_fetcher "$@"
fi

View File

@@ -6,12 +6,7 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py manage_superuser "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py manage_superuser "$@"
python3 manage.py manage_superuser "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py manage_superuser "$@"
else
echo "Unknown user."
exit 1
s6-setuidgid paperless python3 manage.py manage_superuser "$@"
fi

View File

@@ -6,12 +6,7 @@ set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
python3 manage.py prune_audit_logs "$@"
elif [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py prune_audit_logs "$@"
python3 manage.py prune_audit_logs "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py prune_audit_logs "$@"
else
echo "Unknown user."
exit 1
s6-setuidgid paperless python3 manage.py prune_audit_logs "$@"
fi

View File

@@ -582,7 +582,7 @@ document.
### Detecting duplicates {#fuzzy_duplicate}
Paperless-ngx already catches and warns of exactly matching documents,
Paperless already catches and prevents upload of exactly matching documents,
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.

View File

@@ -805,27 +805,6 @@ See the relevant settings [`PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE`](configuratio
and [`PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING`](configuration.md#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING)
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}
!!! note

View File

@@ -1,4 +1,4 @@
# REST API
# The REST API
Paperless-ngx now ships with a fully-documented REST API and a browsable
web interface to explore it. The API browsable interface is available at

View File

@@ -1,31 +1,13 @@
:root>* {
--paperless-green: #17541f;
--paperless-green-accent: #2b8a38;
--md-primary-fg-color: var(--paperless-green);
--md-primary-fg-color--dark: var(--paperless-green);
--md-primary-fg-color--light: var(--paperless-green-accent);
--md-accent-fg-color: var(--paperless-green-accent);
:root > * {
--md-primary-fg-color: #17541f;
--md-primary-fg-color--dark: #17541f;
--md-primary-fg-color--light: #17541f;
--md-accent-fg-color: #2b8a38;
--md-typeset-a-color: #21652a;
}
.md-header,
.md-tabs {
background-color: var(--paperless-green);
color: #fff;
}
.md-tabs__link {
color: rgba(255, 255, 255, 0.82);
}
.md-tabs__link:hover,
.md-tabs__link--active {
color: #fff;
}
[data-md-color-scheme="slate"] {
--md-hue: 222;
--md-default-bg-color: hsla(var(--md-hue), 15%, 10%, 1);
}
@media (min-width: 768px) {
@@ -87,8 +69,8 @@ h4 code {
}
/* Hide config vars from sidebar, toc and move the border on mobile case their hidden */
.md-nav.md-nav--secondary .md-nav__item:has(> .md-nav__link[href*="PAPERLESS_"]),
.md-nav.md-nav--secondary .md-nav__item:has(> .md-nav__link[href*="USERMAP_"]) {
.md-nav.md-nav--secondary .md-nav__item .md-nav__link[href*="PAPERLESS_"],
.md-nav.md-nav--secondary .md-nav__item .md-nav__link[href*="USERMAP_"] {
display: none;
}
@@ -101,3 +83,18 @@ h4 code {
border-top: .05rem solid var(--md-default-fg-color--lightest);
}
}
/* Show search shortcut key */
[data-md-toggle="search"]:not(:checked) ~ .md-header .md-search__form::after {
position: absolute;
top: .3rem;
right: .3rem;
display: block;
padding: .1rem .4rem;
color: var(--md-default-fg-color--lighter);
font-weight: bold;
font-size: .8rem;
border: .05rem solid var(--md-default-fg-color--lighter);
border-radius: .1rem;
content: "/";
}

View File

@@ -1,24 +1,5 @@
# 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
### Bug Fixes

View File

@@ -1152,9 +1152,8 @@ via the consumption directory, you can disable the consumer to save resources.
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}
: As of version 3.0 Paperless-ngx allows duplicate documents to be consumed by default, _except_ when
this setting is enabled. When enabled, Paperless will check if a document with the same hash already
exists in the system and delete the duplicate file from the consumption directory without consuming it.
: When the consumer detects a duplicate document, it will not touch
the original document. This default behavior can be changed here.
Defaults to false.
@@ -1557,20 +1556,6 @@ assigns or creates tags if a properly formatted barcode is detected.
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
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
@@ -1631,16 +1616,6 @@ processing. This only has an effect if
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
There are a few external software packages that Paperless expects to

View File

@@ -81,7 +81,7 @@ first-time setup.
5. Install pre-commit hooks:
```bash
$ uv run prek install
$ uv run pre-commit install
```
6. Apply migrations and create a superuser (also can be done via the web UI) for your development instance:
@@ -217,7 +217,7 @@ commit. See [above](#code-formatting-with-pre-commit-hooks) for installation ins
command such as
```bash
$ git ls-files -- '*.ts' | xargs prek run prettier --files
$ git ls-files -- '*.ts' | xargs pre-commit run prettier --files
```
Front end testing uses Jest and Playwright. Unit tests and e2e tests,
@@ -338,13 +338,13 @@ LANGUAGES = [
## Building the documentation
The documentation is built using Zensical, see their [documentation](https://zensical.org/docs/).
The documentation is built using material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/).
If you want to build the documentation locally, this is how you do it:
1. Build the documentation
```bash
$ uv run zensical build
$ uv run mkdocs build --config-file mkdocs.yml
```
_alternatively..._
@@ -355,7 +355,7 @@ If you want to build the documentation locally, this is how you do it:
something.
```bash
$ uv run zensical serve
$ uv run mkdocs serve
```
## Building the Docker image
@@ -481,147 +481,3 @@ To get started:
5. The project is ready for debugging, start either run the fullstack debug or individual debug
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
```

View File

@@ -1,7 +1,3 @@
---
title: FAQs
---
# Frequently Asked Questions
## _What's the general plan for Paperless-ngx?_
@@ -67,10 +63,8 @@ elsewhere. Here are a couple notes about that.
Paperless also supports various Office documents (.docx, .doc, odt,
.ppt, .pptx, .odp, .xls, .xlsx, .ods).
Paperless-ngx determines the type of a file by inspecting its content
rather than its file extensions. However, files processed via the
consumption directory will be rejected if they have a file extension that
not supported by any of the available parsers.
Paperless-ngx determines the type of a file by inspecting its content.
The file extensions do not matter.
## _Will paperless-ngx run on Raspberry Pi?_

View File

@@ -1,7 +1,3 @@
---
title: Home
---
<div class="grid-left" markdown>
![image](assets/logo_full_black.svg#only-light){.index-logo}
![image](assets/logo_full_white.svg#only-dark){.index-logo}

View File

@@ -1,8 +1,4 @@
---
title: Setup
---
# Installation
## Installation
You can go multiple routes to setup and run Paperless:

View File

@@ -1,8 +1,4 @@
---
title: Basic Usage
---
# Usage
# Usage Overview
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
@@ -312,14 +308,12 @@ or using [email](#workflow-action-email) or [webhook](#workflow-action-webhook)
### Share Links
"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" are shareable public links to files and can be created and managed under the 'Send' button on the document detail screen.
- Share links do not require a user to login and thus link directly to a file or bundled download.
- Share links do not require a user to login and thus link directly to a file.
- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
- 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.
- 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
@@ -566,8 +560,8 @@ you may want to adjust these settings to prevent abuse.
#### Workflow placeholders
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
This allows for complex logic to be used, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
Titles can be assigned by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
This allows for complex logic to be used to generate the title, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
The template is provided as a string.
@@ -590,7 +584,7 @@ applied. You can use the following placeholders in the template with any trigger
- `{{added_time}}`: added time in HH:MM format
- `{{original_filename}}`: original file name without extension
- `{{filename}}`: current file name without extension
- `{{doc_title}}`: current document title (cannot be used in title assignment)
- `{{doc_title}}`: current document title
The following placeholders are only available for "added" or "updated" triggers

87
mkdocs.yml Normal file
View File

@@ -0,0 +1,87 @@
site_name: Paperless-ngx
theme:
name: material
logo: assets/logo.svg
font:
text: Roboto
code: Roboto Mono
palette:
# Palette toggle for automatic mode
- media: "(prefers-color-scheme)"
toggle:
icon: material/brightness-auto
name: Switch to light mode
# Palette toggle for light mode
- media: "(prefers-color-scheme: light)"
scheme: default
toggle:
icon: material/brightness-7
name: Switch to dark mode
# Palette toggle for dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
toggle:
icon: material/brightness-4
name: Switch to system preference
features:
- navigation.tabs
- navigation.top
- toc.integrate
- content.code.annotate
icon:
repo: fontawesome/brands/github
favicon: assets/favicon.png
repo_url: https://github.com/paperless-ngx/paperless-ngx
repo_name: paperless-ngx/paperless-ngx
edit_uri: blob/main/docs/
extra_css:
- assets/extra.css
markdown_extensions:
- attr_list
- md_in_html
- def_list
- admonition
- tables
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.superfences
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.tilde
- footnotes
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
strict: true
nav:
- index.md
- setup.md
- 'Basic Usage': usage.md
- configuration.md
- administration.md
- advanced_usage.md
- 'REST API': api.md
- development.md
- 'FAQs': faq.md
- troubleshooting.md
- 'Migration to v3': migration.md
- changelog.md
copyright: Copyright &copy; 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

View File

@@ -33,8 +33,6 @@
"**/coverage.json": true
},
"python.defaultInterpreterPath": ".venv/bin/python3",
"python.analysis.inlayHints.pytestParameters": true,
"python.testing.pytestEnabled": true,
},
"extensions": {
"recommendations": ["ms-python.python", "charliermarsh.ruff", "editorconfig.editorconfig"],

View File

@@ -66,7 +66,6 @@
#PAPERLESS_CONSUMER_BARCODE_DPI=300
#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE=false
#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING={"TAG:(.*)": "\\g<1>"}
#PAPERLESS_CONSUMER_TAG_BARCODE_SPLIT=false
#PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=false
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME=double-sided
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT=false

View File

@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
version = "2.20.6"
version = "2.20.5"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.10"
@@ -19,17 +19,17 @@ dependencies = [
"azure-ai-documentintelligence>=1.0.2",
"babel>=2.17",
"bleach~=6.3.0",
"celery[redis]~=5.6.2",
"celery[redis]~=5.5.1",
"channels~=4.2",
"channels-redis~=4.2",
"concurrent-log-handler~=0.9.25",
"dateparser~=1.2",
# WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes.
"django~=5.2.10",
"django-allauth[mfa,socialaccount]~=65.14.0",
"django~=5.2.5",
"django-allauth[mfa,socialaccount]~=65.13.1",
"django-auditlog~=3.4.1",
"django-cachalot~=2.9.0",
"django-cachalot~=2.8.0",
"django-celery-results~=2.6.0",
"django-compression-middleware~=0.5.0",
"django-cors-headers~=4.9.0",
@@ -42,7 +42,7 @@ dependencies = [
"djangorestframework~=3.16",
"djangorestframework-guardian~=0.4.0",
"drf-spectacular~=0.28",
"drf-spectacular-sidecar~=2026.1.1",
"drf-spectacular-sidecar~=2025.10.1",
"drf-writable-nested~=0.7.1",
"faiss-cpu>=1.10",
"filelock~=3.20.0",
@@ -76,10 +76,10 @@ dependencies = [
"sentence-transformers>=4.1",
"setproctitle~=1.3.4",
"tika-client~=0.10.0",
"torch~=2.10.0",
"torch~=2.9.1",
"tqdm~=4.67.1",
"watchfiles>=1.1.1",
"whitenoise~=6.11",
"whitenoise~=6.9",
"whoosh-reloaded>=2.7.5",
"zxing-cpp~=2.3.0",
]
@@ -88,13 +88,13 @@ optional-dependencies.mariadb = [
"mysqlclient~=2.2.7",
]
optional-dependencies.postgres = [
"psycopg[c,pool]==3.3",
"psycopg[c,pool]==3.2.12",
# Direct dependency for proper resolution of the pre-built wheels
"psycopg-c==3.3",
"psycopg-c==3.2.12",
"psycopg-pool==3.3",
]
optional-dependencies.webserver = [
"granian[uvloop]~=2.7.0",
"granian[uvloop]~=2.5.1",
]
[dependency-groups]
@@ -106,28 +106,29 @@ dev = [
]
docs = [
"zensical>=0.0.21",
"mkdocs-glightbox~=0.5.1",
"mkdocs-material~=9.7.0",
]
testing = [
"daphne",
"factory-boy~=3.3.1",
"imagehash",
"pytest~=9.0.0",
"pytest~=8.4.1",
"pytest-cov~=7.0.0",
"pytest-django~=4.11.1",
"pytest-env~=1.2.0",
"pytest-env",
"pytest-httpx",
"pytest-mock~=3.15.1",
#"pytest-randomly~=4.0.1",
"pytest-rerunfailures~=16.1",
"pytest-mock",
"pytest-rerunfailures",
"pytest-sugar",
"pytest-xdist~=3.8.0",
"pytest-xdist",
]
lint = [
"prek~=0.3.0",
"ruff~=0.15.0",
"pre-commit~=4.5.1",
"pre-commit-uv~=4.2.0",
"ruff~=0.14.0",
]
typing = [
@@ -136,12 +137,8 @@ typing = [
"django-stubs[compatible-mypy]",
"djangorestframework-stubs[compatible-mypy]",
"lxml-stubs",
"microsoft-python-type-stubs @ git+https://github.com/microsoft/python-type-stubs.git",
"mypy",
"mypy-baseline",
"pyrefly",
"types-bleach",
"types-channels",
"types-colorama",
"types-dateparser",
"types-markdown",
@@ -154,23 +151,18 @@ typing = [
]
[tool.uv]
required-version = ">=0.9.0"
required-version = ">=0.5.14"
package = false
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.uv.sources]
# Markers are chosen to select these almost exclusively when building the Docker image
psycopg-c = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-bookworm-3.2.12/psycopg_c-3.2.12-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-bookworm-3.2.12/psycopg_c-3.2.12-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
]
zxing-cpp = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
@@ -181,6 +173,11 @@ torch = [
{ index = "pytorch-cpu" },
]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.ruff]
target-version = "py310"
line-length = 88
@@ -263,15 +260,11 @@ write-changes = true
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"
[tool.pytest]
minversion = "9.0"
pythonpath = [ "src" ]
strict_config = true
strict_markers = true
strict_parametrization_ids = true
strict_xfail = true
[tool.pytest.ini_options]
minversion = "8.0"
pythonpath = [
"src",
]
testpaths = [
"src/documents/tests/",
"src/paperless/tests/",
@@ -282,7 +275,6 @@ testpaths = [
"src/paperless_remote/tests/",
"src/paperless_ai/tests",
]
addopts = [
"--pythonwarnings=all",
"--cov",
@@ -290,38 +282,19 @@ addopts = [
"--cov-report=xml",
"--numprocesses=auto",
"--maxprocesses=16",
"--dist=loadscope",
"--quiet",
"--durations=50",
"--durations-min=0.5",
"--junitxml=junit.xml",
"-o",
"junit_family=legacy",
"-o junit_family=legacy",
]
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
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]
PAPERLESS_DISABLE_DBHANDLER = "true"
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
[tool.coverage.report]
exclude_also = [
"if settings.AUDIT_LOG_ENABLED:",
"if AUDIT_LOG_ENABLED:",
"if TYPE_CHECKING:",
]
[tool.coverage.run]
source = [
"src/",
@@ -333,6 +306,13 @@ omit = [
"paperless/auth.py",
]
[tool.coverage.report]
exclude_also = [
"if settings.AUDIT_LOG_ENABLED:",
"if AUDIT_LOG_ENABLED:",
"if TYPE_CHECKING:",
]
[tool.mypy]
mypy_path = "src"
plugins = [
@@ -346,15 +326,5 @@ disallow_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
[tool.pyrefly]
search-path = [ "src" ]
baseline = ".pyrefly-baseline.json"
python-platform = "linux"
[tool.django-stubs]
django_settings_module = "paperless.settings"
[tool.mypy-baseline]
baseline_path = ".mypy-baseline.txt"
sort_baseline = true
ignore_categories = [ "note" ]

View File

@@ -180,9 +180,6 @@ test('bulk edit', async ({ page }) => {
await page.locator('pngx-document-card-small').nth(2).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('button', { name: 'Apply' }).click()

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
"version": "2.20.6",
"version": "2.20.5",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",
@@ -11,17 +11,17 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^21.1.3",
"@angular/common": "~21.1.3",
"@angular/compiler": "~21.1.3",
"@angular/core": "~21.1.3",
"@angular/forms": "~21.1.3",
"@angular/localize": "~21.1.3",
"@angular/platform-browser": "~21.1.3",
"@angular/platform-browser-dynamic": "~21.1.3",
"@angular/router": "~21.1.3",
"@angular/cdk": "^21.0.6",
"@angular/common": "~21.0.8",
"@angular/compiler": "~21.0.8",
"@angular/core": "~21.0.8",
"@angular/forms": "~21.0.8",
"@angular/localize": "~21.0.8",
"@angular/platform-browser": "~21.0.8",
"@angular/platform-browser-dynamic": "~21.0.8",
"@angular/router": "~21.0.8",
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@ng-select/ng-select": "^21.2.0",
"@ng-select/ng-select": "^21.1.4",
"@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
@@ -32,34 +32,34 @@
"ngx-color": "^10.1.0",
"ngx-cookie-service": "^21.1.0",
"ngx-device-detector": "^11.0.0",
"ngx-ui-tour-ng-bootstrap": "^18.0.0",
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
"rxjs": "^7.8.2",
"tslib": "^2.8.1",
"utif": "^3.1.0",
"uuid": "^13.0.0",
"zone.js": "^0.16.0"
"zone.js": "^0.15.1"
},
"devDependencies": {
"@angular-builders/custom-webpack": "^21.0.3",
"@angular-builders/jest": "^21.0.3",
"@angular-devkit/core": "^21.1.3",
"@angular-devkit/schematics": "^21.1.3",
"@angular-eslint/builder": "21.2.0",
"@angular-eslint/eslint-plugin": "21.2.0",
"@angular-eslint/eslint-plugin-template": "21.2.0",
"@angular-eslint/schematics": "21.2.0",
"@angular-eslint/template-parser": "21.2.0",
"@angular/build": "^21.1.3",
"@angular/cli": "~21.1.3",
"@angular/compiler-cli": "~21.1.3",
"@angular-builders/custom-webpack": "^21.0.0-beta.1",
"@angular-builders/jest": "^21.0.0-beta.1",
"@angular-devkit/core": "^21.0.5",
"@angular-devkit/schematics": "^21.0.5",
"@angular-eslint/builder": "21.1.0",
"@angular-eslint/eslint-plugin": "21.1.0",
"@angular-eslint/eslint-plugin-template": "21.1.0",
"@angular-eslint/schematics": "21.1.0",
"@angular-eslint/template-parser": "21.1.0",
"@angular/build": "^21.0.5",
"@angular/cli": "~21.0.5",
"@angular/compiler-cli": "~21.0.8",
"@codecov/webpack-plugin": "^1.9.1",
"@playwright/test": "^1.58.2",
"@playwright/test": "^1.57.0",
"@types/jest": "^30.0.0",
"@types/node": "^25.2.1",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"@typescript-eslint/utils": "^8.54.0",
"eslint": "^9.39.2",
"@types/node": "^24.10.1",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"@typescript-eslint/utils": "^8.48.1",
"eslint": "^9.39.1",
"jest": "30.2.0",
"jest-environment-jsdom": "^30.2.0",
"jest-junit": "^16.0.0",
@@ -68,7 +68,7 @@
"prettier-plugin-organize-imports": "^4.3.0",
"ts-node": "~10.9.1",
"typescript": "^5.9.3",
"webpack": "^5.105.0"
"webpack": "^5.103.0"
},
"packageManager": "pnpm@10.17.1",
"pnpm": {

5485
src-ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,11 +9,7 @@ import {
import { Router, RouterModule } from '@angular/router'
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import {
provideUiTour,
TourNgBootstrap,
TourService,
} from 'ngx-ui-tour-ng-bootstrap'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { Subject } from 'rxjs'
import { routes } from './app-routing.module'
import { AppComponent } from './app.component'
@@ -44,12 +40,12 @@ describe('AppComponent', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [
TourNgBootstrapModule,
RouterModule.forRoot(routes),
NgbModalModule,
AppComponent,
ToastsComponent,
FileDropComponent,
TourNgBootstrap,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [
@@ -57,7 +53,6 @@ describe('AppComponent', () => {
DirtySavedViewGuard,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
provideUiTour(),
],
}).compileComponents()

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnDestroy, OnInit, Renderer2 } from '@angular/core'
import { Router, RouterOutlet } from '@angular/router'
import { TourNgBootstrap, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { first, Subscription } from 'rxjs'
import { ToastsComponent } from './components/common/toasts/toasts.component'
import { FileDropComponent } from './components/file-drop/file-drop.component'
@@ -21,7 +21,12 @@ import { WebsocketStatusService } from './services/websocket-status.service'
selector: 'pngx-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
imports: [FileDropComponent, ToastsComponent, TourNgBootstrap, RouterOutlet],
imports: [
FileDropComponent,
ToastsComponent,
TourNgBootstrapModule,
RouterOutlet,
],
})
export class AppComponent implements OnInit, OnDestroy {
private settings = inject(SettingsService)
@@ -162,91 +167,108 @@ export class AppComponent implements OnInit, OnDestroy {
})
}
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,
const prevBtnTitle = $localize`Prev`
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.mail',
content: $localize`Manage e-mail accounts and rules for automatically importing documents.`,
route: '/mail',
backdropConfig: {
offset: 0,
{
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.workflows',
content: $localize`Workflows give you more control over the document pipeline.`,
route: '/workflows',
backdropConfig: {
offset: 0,
{
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.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.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.settings',
content: $localize`Check out the settings for various tweaks to the web app.`,
route: '/settings',
backdropConfig: {
offset: 0,
{
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.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,
{
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,
backdropConfig: {
offset: 10,
},
prevBtnTitle,
nextBtnTitle,
endBtnTitle,
isOptional: true,
useLegacyTitle: true,
}
)
this.tourService.start$.subscribe(() => {
this.renderer.addClass(document.body, 'tour-active')

View File

@@ -103,6 +103,22 @@
</div>
<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">
<span i18n>Sidebar</span>
</div>
@@ -137,28 +153,8 @@
</button>
</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>
<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>
<h5 class="mt-3" id="update-checking" i18n>Update checking</h5>
<div class="row mb-3">
<div class="col d-flex flex-row align-items-start">
<pngx-input-check i18n-title title="Enable update checking" formControlName="updateCheckingEnabled"></pngx-input-check>
@@ -183,33 +179,11 @@
<pngx-input-check i18n-title title="Show document counts in sidebar saved views" formControlName="sidebarViewsShowCount"></pngx-input-check>
</div>
</div>
</div>
</div>
<div class="col-xl-6 ps-xl-5">
<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="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>
@@ -235,32 +209,31 @@
</div>
</div>
<div class="row">
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check>
</div>
</div>
<div class="row mb-3">
<h5 class="mt-3" i18n>Global search</h5>
<div class="row">
<div class="col">
<p class="mb-2" i18n>Built-in fields to show:</p>
@for (option of documentDetailFieldOptions; track option.id) {
<div class="form-check ms-3">
<input class="form-check-input" type="checkbox"
[id]="'documentDetailField-' + option.id"
[checked]="isDocumentDetailFieldShown(option.id)"
(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>
<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="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>
</div>
<div class="col-xl-6 ps-xl-5">
<h5 class="mt-3" i18n>Bulk editing</h5>
<div class="row mb-3">
<div class="col">
@@ -269,27 +242,16 @@
</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>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check>
</div>
</div>
</div>
</div>
</ng-template>
</li>

View File

@@ -16,7 +16,6 @@ import {
} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import {
@@ -148,7 +147,6 @@ describe('SettingsComponent', () => {
PermissionsGuard,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
provideUiTour(),
],
}).compileComponents()
@@ -203,9 +201,9 @@ describe('SettingsComponent', () => {
const navigateSpy = jest.spyOn(router, 'navigate')
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click'))
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'documents'])
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications'])
const initSpy = jest.spyOn(component, 'initialize')
component.isDirty = true // mock dirty
@@ -215,8 +213,8 @@ describe('SettingsComponent', () => {
expect(initSpy).not.toHaveBeenCalled()
navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click'))
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications'])
expect(initSpy).toHaveBeenCalled()
})
@@ -228,7 +226,7 @@ describe('SettingsComponent', () => {
activatedRoute.snapshot.fragment = '#notifications'
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
component.ngOnInit()
expect(component.activeNavID).toEqual(4) // Notifications
expect(component.activeNavID).toEqual(3) // Notifications
component.ngAfterViewInit()
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
})
@@ -253,7 +251,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(32)
expect(setSpy).toHaveBeenCalledTimes(30)
// succeed
storeSpy.mockReturnValueOnce(of(true))
@@ -368,22 +366,4 @@ describe('SettingsComponent', () => {
settingsService.settingsSaved.emit(true)
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()
})
})

View File

@@ -64,16 +64,15 @@ import { PermissionsGroupComponent } from '../../common/input/permissions/permis
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
import { SelectComponent } from '../../common/input/select/select.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { PdfEditorEditMode } from '../../common/pdf-editor/pdf-editor-edit-mode'
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
import { ZoomSetting } from '../../document-detail/zoom-setting'
import { ZoomSetting } from '../../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
enum SettingsNavIDs {
General = 1,
Documents = 2,
Permissions = 3,
Notifications = 4,
Permissions = 2,
Notifications = 3,
SavedViews = 4,
}
const systemLanguage = { code: '', name: $localize`Use system language` }
@@ -82,25 +81,6 @@ const systemDateFormat = {
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({
selector: 'pngx-settings',
templateUrl: './settings.component.html',
@@ -164,10 +144,8 @@ export class SettingsComponent
defaultPermsEditGroups: new FormControl(null),
useNativePdfViewer: new FormControl(null),
pdfViewerDefaultZoom: new FormControl(null),
pdfEditorDefaultEditMode: new FormControl(null),
documentEditingRemoveInboxTags: new FormControl(null),
documentEditingOverlayThumbnail: new FormControl(null),
documentDetailsHiddenFields: new FormControl([]),
searchDbOnly: new FormControl(null),
searchLink: new FormControl(null),
@@ -198,10 +176,6 @@ export class SettingsComponent
public readonly ZoomSetting = ZoomSetting
public readonly PdfEditorEditMode = PdfEditorEditMode
public readonly documentDetailFieldOptions = documentDetailFieldOptions
get systemStatusHasErrors(): boolean {
return (
this.systemStatus.database.status === SystemStatusItemStatus.ERROR ||
@@ -318,9 +292,6 @@ export class SettingsComponent
pdfViewerDefaultZoom: this.settings.get(
SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING
),
pdfEditorDefaultEditMode: this.settings.get(
SETTINGS_KEYS.PDF_EDITOR_DEFAULT_EDIT_MODE
),
displayLanguage: this.settings.getLanguage(),
dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE),
dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT),
@@ -365,9 +336,6 @@ export class SettingsComponent
documentEditingOverlayThumbnail: this.settings.get(
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),
searchLink: this.settings.get(SETTINGS_KEYS.SEARCH_FULL_TYPE),
}
@@ -490,10 +458,6 @@ export class SettingsComponent
SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
this.settingsForm.value.pdfViewerDefaultZoom
)
this.settings.set(
SETTINGS_KEYS.PDF_EDITOR_DEFAULT_EDIT_MODE,
this.settingsForm.value.pdfEditorDefaultEditMode
)
this.settings.set(
SETTINGS_KEYS.DATE_LOCALE,
this.settingsForm.value.dateLocale
@@ -562,10 +526,6 @@ export class SettingsComponent
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL,
this.settingsForm.value.documentEditingOverlayThumbnail
)
this.settings.set(
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
this.settingsForm.value.documentDetailsHiddenFields
)
this.settings.set(
SETTINGS_KEYS.SEARCH_DB_ONLY,
this.settingsForm.value.searchDbOnly
@@ -627,26 +587,6 @@ export class SettingsComponent
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() {
const modal: NgbModalRef = this.modalService.open(
SystemStatusDialogComponent,

View File

@@ -97,12 +97,6 @@
<br/><em>(<ng-container i18n>click for full output</ng-container>)</em>
}
</ng-template>
@if (task.duplicate_documents?.length > 0) {
<div class="small text-warning-emphasis d-flex align-items-center gap-1">
<i-bs class="lh-1" width="1em" height="1em" name="exclamation-triangle"></i-bs>
<span i18n>Duplicate(s) detected</span>
</div>
}
</td>
}
<td class="d-lg-none">

View File

@@ -248,7 +248,7 @@ main {
}
}
@media screen and (min-width: 376px) and (max-width: 768px) {
@media screen and (min-width: 366px) and (max-width: 768px) {
.navbar-toggler {
// compensate for 2 buttons on the right
margin-right: 45px;

View File

@@ -16,7 +16,6 @@ import { ActivatedRoute, Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import { SavedView } from 'src/app/data/saved-view'
@@ -158,7 +157,6 @@ describe('AppFrameComponent', () => {
PermissionsGuard,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
provideUiTour(),
],
}).compileComponents()

View File

@@ -16,7 +16,7 @@ import {
NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { Observable } from 'rxjs'
import { first } from 'rxjs/operators'
import { Document } from 'src/app/data/document'
@@ -69,7 +69,7 @@ import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.compo
NgbNavModule,
NgxBootstrapIconsModule,
DragDropModule,
TourNgBootstrap,
TourNgBootstrapModule,
],
})
export class AppFrameComponent

View File

@@ -164,11 +164,9 @@
{{ item.name }}
<span class="ms-auto text-muted small">
@if (item.dateEnd) {
{{ item.date | customDate:'mediumDate' }} &ndash; {{ item.dateEnd | customDate:'mediumDate' }}
} @else if (item.dateTilNow) {
{{ item.dateTilNow | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container>
{{ item.date | customDate:'MMM d' }} &ndash; {{ item.dateEnd | customDate:'mediumDate' }}
} @else {
{{ item.date | customDate:'mediumDate' }}
{{ item.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container>
}
</span>
</div>

View File

@@ -79,34 +79,32 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
{
id: RelativeDate.WITHIN_1_WEEK,
name: $localize`Within 1 week`,
dateTilNow: new Date().setDate(new Date().getDate() - 7),
date: new Date().setDate(new Date().getDate() - 7),
},
{
id: RelativeDate.WITHIN_1_MONTH,
name: $localize`Within 1 month`,
dateTilNow: new Date().setMonth(new Date().getMonth() - 1),
date: new Date().setMonth(new Date().getMonth() - 1),
},
{
id: RelativeDate.WITHIN_3_MONTHS,
name: $localize`Within 3 months`,
dateTilNow: new Date().setMonth(new Date().getMonth() - 3),
date: new Date().setMonth(new Date().getMonth() - 3),
},
{
id: RelativeDate.WITHIN_1_YEAR,
name: $localize`Within 1 year`,
dateTilNow: new Date().setFullYear(new Date().getFullYear() - 1),
date: new Date().setFullYear(new Date().getFullYear() - 1),
},
{
id: RelativeDate.THIS_YEAR,
name: $localize`This year`,
date: new Date('1/1/' + new Date().getFullYear()),
dateEnd: new Date('12/31/' + new Date().getFullYear()),
},
{
id: RelativeDate.THIS_MONTH,
name: $localize`This month`,
date: new Date().setDate(1),
dateEnd: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0),
},
{
id: RelativeDate.TODAY,

View File

@@ -430,24 +430,6 @@
</div>
</div>
}
@case (WorkflowActionType.PasswordRemoval) {
<div class="row">
<div class="col">
<p class="small" i18n>
One password per line. The workflow will try them in order until one succeeds.
</p>
<pngx-input-textarea
i18n-title
title="Passwords"
formControlName="passwords"
rows="4"
[error]="error?.actions?.[i]?.passwords"
hint="Passwords are stored in plain text. Use with caution."
i18n-hint
></pngx-input-textarea>
</div>
</div>
}
}
</div>
</ng-template>

View File

@@ -3,7 +3,6 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
FormArray,
FormControl,
FormGroup,
FormsModule,
@@ -995,32 +994,4 @@ describe('WorkflowEditDialogComponent', () => {
component.removeSelectedCustomField(3, formGroup)
expect(formGroup.get('assign_custom_fields').value).toEqual([])
})
it('should handle parsing of passwords from array to string and back on save', () => {
const passwordAction: WorkflowAction = {
id: 1,
type: WorkflowActionType.PasswordRemoval,
passwords: ['pass1', 'pass2'],
}
component.object = {
name: 'Workflow with Passwords',
id: 1,
order: 1,
enabled: true,
triggers: [],
actions: [passwordAction],
}
component.ngOnInit()
const formActions = component.objectForm.get('actions') as FormArray
expect(formActions.value[0].passwords).toBe('pass1\npass2')
formActions.at(0).get('passwords').setValue('pass1\npass2\npass3')
component.save()
expect(component.objectForm.get('actions').value[0].passwords).toEqual([
'pass1',
'pass2',
'pass3',
])
})
})

View File

@@ -139,10 +139,6 @@ export const WORKFLOW_ACTION_OPTIONS = [
id: WorkflowActionType.Webhook,
name: $localize`Webhook`,
},
{
id: WorkflowActionType.PasswordRemoval,
name: $localize`Password removal`,
},
]
export enum TriggerFilterType {
@@ -1206,25 +1202,11 @@ export class WorkflowEditDialogComponent
headers: new FormControl(action.webhook?.headers),
include_document: new FormControl(!!action.webhook?.include_document),
}),
passwords: new FormControl(
this.formatPasswords(action.passwords ?? [])
),
}),
{ emitEvent }
)
}
private formatPasswords(passwords: string[] = []): string {
return passwords.join('\n')
}
private parsePasswords(value: string = ''): string[] {
return value
.split(/[\n,]+/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
}
private updateAllTriggerActionFields(emitEvent: boolean = false) {
this.triggerFields.clear({ emitEvent: false })
this.object?.triggers.forEach((trigger) => {
@@ -1349,7 +1331,6 @@ export class WorkflowEditDialogComponent
headers: null,
include_document: false,
},
passwords: [],
}
this.object.actions.push(action)
this.createActionField(action)
@@ -1386,7 +1367,6 @@ export class WorkflowEditDialogComponent
if (action.type !== WorkflowActionType.Email) {
action.email = null
}
action.passwords = this.parsePasswords(action.passwords as any)
})
super.save()
}

View File

@@ -34,8 +34,8 @@
</div>
</div>
@if (selectionModel.items) {
<cdk-virtual-scroll-viewport class="items" [itemSize]="FILTERABLE_BUTTON_HEIGHT_PX" #buttonsViewport [style.height.px]="scrollViewportHeight">
<div *cdkVirtualFor="let item of selectionModel.items | filter: filterText:'name'; trackBy: trackByItem; let i = index">
<div class="items" #buttonItems>
@for (item of selectionModel.items | filter: filterText:'name'; track item; let i = $index) {
@if (allowSelectNone || item.id) {
<pngx-toggleable-dropdown-button
[item]="item"
@@ -45,11 +45,12 @@
[count]="getUpdatedDocumentCount(item.id)"
(toggled)="selectionModel.toggle(item.id)"
(exclude)="excludeClicked(item.id)"
(click)="setButtonItemIndex(i - 1)"
[disabled]="disabled">
</pngx-toggleable-dropdown-button>
}
</div>
</cdk-virtual-scroll-viewport>
}
</div>
}
@if (editing) {
@if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {

View File

@@ -1,4 +1,3 @@
import { ScrollingModule } from '@angular/cdk/scrolling'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import {
@@ -65,7 +64,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
imports: [NgxBootstrapIconsModule.pick(allIcons), ScrollingModule],
imports: [NgxBootstrapIconsModule.pick(allIcons)],
}).compileComponents()
hotkeyService = TestBed.inject(HotKeyService)
@@ -266,11 +265,18 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(document.activeElement).toEqual(
component.listFilterTextInput.nativeElement
)
expect(component.buttonsViewport.getRenderedRange().end).toEqual(3) // all items shown
expect(
Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
).filter((b) => b.textContent.includes('Tag'))
).toHaveLength(2)
component.filterText = 'Tag2'
fixture.detectChanges()
expect(component.buttonsViewport.getRenderedRange().end).toEqual(1) // filtered
expect(
Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
).filter((b) => b.textContent.includes('Tag'))
).toHaveLength(1)
component.dropdown.close()
expect(component.filterText).toHaveLength(0)
}))
@@ -325,8 +331,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)
@@ -372,8 +376,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)
@@ -410,8 +412,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
component.buttonsViewport?.checkViewportSize()
fixture.detectChanges()
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)

View File

@@ -1,7 +1,3 @@
import {
CdkVirtualScrollViewport,
ScrollingModule,
} from '@angular/cdk/scrolling'
import { NgClass } from '@angular/common'
import {
Component,
@@ -631,27 +627,18 @@ export class FilterableDropdownSelectionModel {
NgxBootstrapIconsModule,
NgbDropdownModule,
NgClass,
ScrollingModule,
],
})
export class FilterableDropdownComponent
extends LoadingComponentWithPermissions
implements OnInit
{
public readonly FILTERABLE_BUTTON_HEIGHT_PX = 42
private filterPipe = inject(FilterPipe)
private hotkeyService = inject(HotKeyService)
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
@ViewChild('dropdown') dropdown: NgbDropdown
@ViewChild('buttonsViewport') buttonsViewport: CdkVirtualScrollViewport
private get renderedButtons(): Array<HTMLButtonElement> {
return Array.from(
this.buttonsViewport.elementRef.nativeElement.querySelectorAll('button')
)
}
@ViewChild('buttonItems') buttonItems: ElementRef
public popperOptions = pngxPopperOptions
@@ -765,14 +752,6 @@ export class FilterableDropdownComponent
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() {
super()
this.selectionModelChange.subscribe((updatedModel) => {
@@ -797,10 +776,6 @@ export class FilterableDropdownComponent
}
}
public trackByItem(index: number, item: MatchingModel) {
return item?.id ?? index
}
applyClicked() {
if (this.selectionModel.isDirty()) {
this.dropdown.close()
@@ -819,7 +794,6 @@ export class FilterableDropdownComponent
if (open) {
setTimeout(() => {
this.listFilterTextInput?.nativeElement.focus()
this.buttonsViewport?.checkViewportSize()
}, 0)
if (this.editing) {
this.selectionModel.reset()
@@ -887,14 +861,12 @@ export class FilterableDropdownComponent
event.preventDefault()
}
} else if (event.target instanceof HTMLButtonElement) {
this.syncKeyboardIndexFromButton(event.target)
this.focusNextButtonItem()
event.preventDefault()
}
break
case 'ArrowUp':
if (event.target instanceof HTMLButtonElement) {
this.syncKeyboardIndexFromButton(event.target)
if (this.keyboardIndex === 0) {
this.listFilterTextInput.nativeElement.focus()
} else {
@@ -931,18 +903,15 @@ export class FilterableDropdownComponent
if (setFocus) this.setButtonItemFocus()
}
private syncKeyboardIndexFromButton(button: HTMLButtonElement) {
// because of virtual scrolling, re-calculate the index
const idx = this.renderedButtons.indexOf(button)
if (idx >= 0) {
this.keyboardIndex = this.buttonsViewport.getRenderedRange().start + idx
}
setButtonItemFocus() {
this.buttonItems.nativeElement.children[
this.keyboardIndex
]?.children[0].focus()
}
setButtonItemFocus() {
const offset =
this.keyboardIndex - this.buttonsViewport.getRenderedRange().start
this.renderedButtons[offset]?.focus()
setButtonItemIndex(index: number) {
// just track the index in case user uses arrows
this.keyboardIndex = index
}
hideCount(item: ObjectWithPermissions) {

View File

@@ -15,7 +15,7 @@
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
}
@if (info) {
<button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
<button class="btn btn-sm btn-link text-muted me-auto p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
<i-bs name="question-circle"></i-bs>
</button>
<ng-template #infoPopover>
@@ -26,9 +26,6 @@
}
</ng-template>
}
@if (loading) {
<output class="spinner-border spinner-border-sm fs-6 fw-normal" aria-hidden="true"><span class="visually-hidden" i18n>Loading...</span></output>
}
</h3>
</div>
<div class="btn-toolbar col col-md-auto gap-2">

View File

@@ -3,14 +3,14 @@ import { Component, Input, inject } from '@angular/core'
import { Title } from '@angular/platform-browser'
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { environment } from 'src/environments/environment'
@Component({
selector: 'pngx-page-header',
templateUrl: './page-header.component.html',
styleUrls: ['./page-header.component.scss'],
imports: [NgbPopoverModule, NgxBootstrapIconsModule, TourNgBootstrap],
imports: [NgbPopoverModule, NgxBootstrapIconsModule, TourNgBootstrapModule],
})
export class PageHeaderComponent {
private titleService = inject(Title)
@@ -42,9 +42,6 @@ export class PageHeaderComponent {
@Input()
infoLink: string
@Input()
loading: boolean = false
public copyID() {
this.copied = this.clipboard.copy(this.id.toString())
clearTimeout(this.copyTimeout)

View File

@@ -1,4 +0,0 @@
export enum PdfEditorEditMode {
Update = 'update',
Create = 'create',
}

View File

@@ -8,11 +8,8 @@ import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
import { PdfEditorEditMode } from './pdf-editor-edit-mode'
interface PageOperation {
page: number
@@ -22,6 +19,11 @@ interface PageOperation {
loaded?: boolean
}
export enum PdfEditorEditMode {
Update = 'update',
Create = 'create',
}
@Component({
selector: 'pngx-pdf-editor',
templateUrl: './pdf-editor.component.html',
@@ -37,15 +39,12 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
public PdfEditorEditMode = PdfEditorEditMode
private documentService = inject(DocumentService)
private readonly settingsService = inject(SettingsService)
activeModal: NgbActiveModal = inject(NgbActiveModal)
documentID: number
pages: PageOperation[] = []
totalPages = 0
editMode: PdfEditorEditMode = this.settingsService.get(
SETTINGS_KEYS.PDF_EDITOR_DEFAULT_EDIT_MODE
)
editMode: PdfEditorEditMode = PdfEditorEditMode.Create
deleteOriginal: boolean = false
includeMetadata: boolean = true

View File

@@ -1,129 +0,0 @@
<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>

View File

@@ -1,161 +0,0 @@
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()
})
})

View File

@@ -1,118 +0,0 @@
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
}
}

View File

@@ -1,156 +0,0 @@
<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">&mdash;</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>

View File

@@ -1,4 +0,0 @@
:host ::ng-deep .popover {
min-width: 300px;
max-width: 400px;
}

View File

@@ -1,251 +0,0 @@
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()
}))
})

View File

@@ -1,177 +0,0 @@
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)
}
}

View File

@@ -51,7 +51,7 @@
<div class="input-group w-100 mt-2">
<label class="input-group-text" for="addLink"><ng-container i18n>Expires</ng-container>:</label>
<select class="form-select fs-6" [(ngModel)]="expirationDays">
@for (option of expirationOptions; track option) {
@for (option of EXPIRATION_OPTIONS; track option) {
<option [ngValue]="option.value">{{ option.label }}</option>
}
</select>

View File

@@ -4,11 +4,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first } from 'rxjs'
import {
FileVersion,
SHARE_LINK_EXPIRATION_OPTIONS,
ShareLink,
} from 'src/app/data/share-link'
import { FileVersion, ShareLink } from 'src/app/data/share-link'
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
@@ -25,7 +21,12 @@ export class ShareLinksDialogComponent implements OnInit {
private toastService = inject(ToastService)
private clipboard = inject(Clipboard)
readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
EXPIRATION_OPTIONS = [
{ label: $localize`1 day`, value: 1 },
{ label: $localize`7 days`, value: 7 },
{ label: $localize`30 days`, value: 30 },
{ label: $localize`Never`, value: null },
]
@Input()
title = $localize`Share Links`

View File

@@ -5,12 +5,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { RouterTestingModule } from '@angular/router/testing'
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import {
provideUiTour,
TourNgBootstrap,
TourService,
} from 'ngx-ui-tour-ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { of, throwError } from 'rxjs'
import { SavedView } from 'src/app/data/saved-view'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
@@ -79,7 +75,7 @@ describe('DashboardComponent', () => {
imports: [
NgbAlertModule,
RouterTestingModule,
TourNgBootstrap,
TourNgBootstrapModule,
DragDropModule,
NgxBootstrapIconsModule.pick(allIcons),
DashboardComponent,
@@ -115,7 +111,6 @@ describe('DashboardComponent', () => {
},
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
provideUiTour(),
],
}).compileComponents()

View File

@@ -8,7 +8,7 @@ import {
import { Component, inject } from '@angular/core'
import { RouterModule } from '@angular/router'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { SavedView } from 'src/app/data/saved-view'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
@@ -36,7 +36,7 @@ import { WelcomeWidgetComponent } from './widgets/welcome-widget/welcome-widget.
WelcomeWidgetComponent,
IfPermissionsDirective,
DragDropModule,
TourNgBootstrap,
TourNgBootstrapModule,
NgxBootstrapIconsModule,
RouterModule,
],

View File

@@ -9,7 +9,6 @@ import {
import { By } from '@angular/platform-browser'
import { RouterTestingModule } from '@angular/router/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { PermissionsService } from 'src/app/services/permissions.service'
@@ -62,7 +61,6 @@ describe('UploadFileWidgetComponent', () => {
},
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
provideUiTour(),
],
}).compileComponents()

View File

@@ -7,7 +7,7 @@ import {
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@@ -33,7 +33,7 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
NgbAlertModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
TourNgBootstrap,
TourNgBootstrapModule,
],
})
export class UploadFileWidgetComponent extends ComponentWithPermissions {

View File

@@ -1,7 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbAlert, NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { WelcomeWidgetComponent } from './welcome-widget.component'
@@ -12,7 +11,7 @@ describe('WelcomeWidgetComponent', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [PermissionsGuard, provideUiTour()],
providers: [PermissionsGuard],
imports: [NgbAlertModule, WelcomeWidgetComponent, WidgetFrameComponent],
}).compileComponents()

View File

@@ -146,26 +146,16 @@
<ng-template ngbNavContent>
<div>
<pngx-input-text #inputTitle i18n-title title="Title" formControlName="title" [horizontal]="true" [suggestion]="suggestions?.title" (keyup)="titleKeyUp($event)" [error]="error?.title"></pngx-input-text>
@if (!isFieldHidden(DocumentDetailFieldID.ArchiveSerialNumber)) {
<pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" [horizontal]="true" formControlName='archive_serial_number'></pngx-input-number>
}
<pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" [horizontal]="true" formControlName='archive_serial_number'></pngx-input-number>
<pngx-input-date i18n-title title="Date created" formControlName="created" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
[error]="error?.created"></pngx-input-date>
@if (!isFieldHidden(DocumentDetailFieldID.Correspondent)) {
<pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Correspondent)"
(createNew)="createCorrespondent($event)" [hideAddButton]="createDisabled(DataType.Correspondent)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
}
@if (!isFieldHidden(DocumentDetailFieldID.DocumentType)) {
<pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.DocumentType)"
(createNew)="createDocumentType($event)" [hideAddButton]="createDisabled(DataType.DocumentType)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
}
@if (!isFieldHidden(DocumentDetailFieldID.StoragePath)) {
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.StoragePath)"
(createNew)="createStoragePath($event)" [hideAddButton]="createDisabled(DataType.StoragePath)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
}
@if (!isFieldHidden(DocumentDetailFieldID.Tags)) {
<pngx-input-tags #tagsInput formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Tag)" [hideAddButton]="createDisabled(DataType.Tag)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
}
<pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Correspondent)"
(createNew)="createCorrespondent($event)" [hideAddButton]="createDisabled(DataType.Correspondent)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
<pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.DocumentType)"
(createNew)="createDocumentType($event)" [hideAddButton]="createDisabled(DataType.DocumentType)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.StoragePath)"
(createNew)="createStoragePath($event)" [hideAddButton]="createDisabled(DataType.StoragePath)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
<pngx-input-tags #tagsInput formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Tag)" [hideAddButton]="createDisabled(DataType.Tag)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
@for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) {
<div [formGroup]="customFieldFormFields.controls[i]">
@switch (getCustomFieldFromInstance(fieldInstance)?.data_type) {
@@ -380,37 +370,6 @@
</ng-template>
</li>
}
@if (document?.duplicate_documents?.length) {
<li [ngbNavItem]="DocumentDetailNavIDs.Duplicates">
<a class="text-nowrap" ngbNavLink i18n>
Duplicates
<span class="badge text-bg-secondary ms-1">{{ document.duplicate_documents.length }}</span>
</a>
<ng-template ngbNavContent>
<div class="d-flex flex-column gap-2">
<div class="fst-italic" i18n>Duplicate documents detected:</div>
<div class="list-group">
@for (duplicate of document.duplicate_documents; track duplicate.id) {
<a
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
[routerLink]="['/documents', duplicate.id, 'details']"
[class.disabled]="duplicate.deleted_at"
>
<span class="d-flex align-items-center gap-2">
<span>{{ duplicate.title || ('#' + duplicate.id) }}</span>
@if (duplicate.deleted_at) {
<span class="badge text-bg-secondary" i18n>In trash</span>
}
</span>
<span class="text-secondary">#{{ duplicate.id }}</span>
</a>
}
</div>
</div>
</ng-template>
</li>
}
</ul>
<div [ngbNavOutlet]="nav" class="mt-3"></div>

View File

@@ -48,7 +48,6 @@ import {
} from 'src/app/data/filter-rule-type'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
@@ -69,8 +68,10 @@ import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import { DocumentDetailComponent } from './document-detail.component'
import { ZoomSetting } from './zoom-setting'
import {
DocumentDetailComponent,
ZoomSetting,
} from './document-detail.component'
const doc: Document = {
id: 3,
@@ -300,16 +301,16 @@ describe('DocumentDetailComponent', () => {
.spyOn(openDocumentsService, 'openDocument')
.mockReturnValueOnce(of(true))
fixture.detectChanges()
expect(component.activeNavID).toEqual(component.DocumentDetailNavIDs.Notes)
expect(component.activeNavID).toEqual(5) // DocumentDetailNavIDs.Notes
})
it('should change url on tab switch', () => {
initNormally()
const navigateSpy = jest.spyOn(router, 'navigate')
component.nav.select(component.DocumentDetailNavIDs.Notes)
component.nav.select(5)
component.nav.navChange.next({
activeId: 1,
nextId: component.DocumentDetailNavIDs.Notes,
nextId: 5,
preventDefault: () => {},
})
fixture.detectChanges()
@@ -351,18 +352,6 @@ describe('DocumentDetailComponent', () => {
expect(component.document).toEqual(doc)
})
it('should fall back to details tab when duplicates tab is active but no duplicates', () => {
initNormally()
component.activeNavID = component.DocumentDetailNavIDs.Duplicates
const noDupDoc = { ...doc, duplicate_documents: [] }
component.updateComponent(noDupDoc)
expect(component.activeNavID).toEqual(
component.DocumentDetailNavIDs.Details
)
})
it('should load already-opened document via param', () => {
initNormally()
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
@@ -378,38 +367,6 @@ describe('DocumentDetailComponent', () => {
expect(component.document).toEqual(doc)
})
it('should update cached open document duplicates when reloading an open doc', () => {
const openDoc = { ...doc, duplicate_documents: [{ id: 1, title: 'Old' }] }
const updatedDuplicates = [
{ id: 2, title: 'Newer duplicate', deleted_at: null },
]
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
jest.spyOn(documentService, 'get').mockReturnValue(
of({
...doc,
modified: new Date('2024-01-02T00:00:00Z'),
duplicate_documents: updatedDuplicates,
})
)
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
const saveSpy = jest.spyOn(openDocumentsService, 'save')
jest.spyOn(openDocumentsService, 'openDocument').mockReturnValue(of(true))
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({
count: customFields.length,
all: customFields.map((f) => f.id),
results: customFields,
})
)
fixture.detectChanges()
expect(openDoc.duplicate_documents).toEqual(updatedDuplicates)
expect(saveSpy).toHaveBeenCalled()
})
it('should disable form if user cannot edit', () => {
currentUserHasObjectPermissions = false
initNormally()
@@ -1014,7 +971,7 @@ describe('DocumentDetailComponent', () => {
it('should display built-in pdf viewer if not disabled', () => {
initNormally()
component.document.archived_file_name = 'file.pdf'
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
jest.spyOn(settingsService, 'get').mockReturnValue(false)
expect(component.useNativePdfViewer).toBeFalsy()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
@@ -1023,7 +980,7 @@ describe('DocumentDetailComponent', () => {
it('should display native pdf viewer if enabled', () => {
initNormally()
component.document.archived_file_name = 'file.pdf'
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
jest.spyOn(settingsService, 'get').mockReturnValue(true)
expect(component.useNativePdfViewer).toBeTruthy()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()

Some files were not shown because too many files have changed in this diff Show More