mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-09 05:25:10 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a891b38a8 |
@@ -24,7 +24,7 @@ jobs:
|
|||||||
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
|
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Start containers
|
- name: Start containers
|
||||||
@@ -96,11 +96,11 @@ jobs:
|
|||||||
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
|
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
@@ -141,13 +141,13 @@ jobs:
|
|||||||
pytest
|
pytest
|
||||||
- name: Upload test results to Codecov
|
- name: Upload test results to Codecov
|
||||||
if: always()
|
if: always()
|
||||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||||
with:
|
with:
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: junit.xml
|
files: junit.xml
|
||||||
report_type: test_results
|
report_type: test_results
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||||
with:
|
with:
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: coverage.xml
|
files: coverage.xml
|
||||||
@@ -169,16 +169,16 @@ jobs:
|
|||||||
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
|
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "${{ env.DEFAULT_PYTHON }}"
|
python-version: "${{ env.DEFAULT_PYTHON }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
@@ -202,7 +202,7 @@ jobs:
|
|||||||
check \
|
check \
|
||||||
src/
|
src/
|
||||||
- name: Cache Mypy
|
- name: Cache Mypy
|
||||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: .mypy_cache
|
path: .mypy_cache
|
||||||
# Keyed by OS, Python version, and dependency hashes
|
# Keyed by OS, Python version, and dependency hashes
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
ref-name: ${{ steps.ref.outputs.name }}
|
ref-name: ${{ steps.ref.outputs.name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Determine ref name
|
- name: Determine ref name
|
||||||
@@ -106,9 +106,9 @@ jobs:
|
|||||||
echo "repository=${repo_name}"
|
echo "repository=${repo_name}"
|
||||||
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -121,7 +121,7 @@ jobs:
|
|||||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||||
- name: Docker metadata
|
- name: Docker metadata
|
||||||
id: docker-meta
|
id: docker-meta
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
|
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
- name: Build and push by digest
|
- name: Build and push by digest
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
@@ -182,29 +182,29 @@ jobs:
|
|||||||
echo "Downloaded digests:"
|
echo "Downloaded digests:"
|
||||||
ls -la /tmp/digests/
|
ls -la /tmp/digests/
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: needs.build-arch.outputs.push-external == 'true'
|
if: needs.build-arch.outputs.push-external == 'true'
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
if: needs.build-arch.outputs.push-external == 'true'
|
if: needs.build-arch.outputs.push-external == 'true'
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
||||||
- name: Docker metadata
|
- name: Docker metadata
|
||||||
id: docker-meta
|
id: docker-meta
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
|
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
|
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -69,16 +69,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
|
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -77,11 +77,11 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -92,7 +92,7 @@ jobs:
|
|||||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
id: cache-frontend-deps
|
id: cache-frontend-deps
|
||||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
@@ -109,11 +109,11 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -123,7 +123,7 @@ jobs:
|
|||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
@@ -148,11 +148,11 @@ jobs:
|
|||||||
shard-count: [4]
|
shard-count: [4]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -162,7 +162,7 @@ jobs:
|
|||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
@@ -174,13 +174,13 @@ jobs:
|
|||||||
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||||
- name: Upload test results to Codecov
|
- name: Upload test results to Codecov
|
||||||
if: always()
|
if: always()
|
||||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||||
with:
|
with:
|
||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/
|
directory: src-ui/
|
||||||
report_type: test_results
|
report_type: test_results
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||||
with:
|
with:
|
||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/coverage/
|
directory: src-ui/coverage/
|
||||||
@@ -191,7 +191,7 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
container: mcr.microsoft.com/playwright:v1.60.0-noble
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||||
@@ -203,11 +203,11 @@ jobs:
|
|||||||
shard-count: [2]
|
shard-count: [2]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -217,7 +217,7 @@ jobs:
|
|||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
@@ -239,12 +239,12 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -254,7 +254,7 @@ jobs:
|
|||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ jobs:
|
|||||||
runs-on: ubuntu-slim
|
runs-on: ubuntu-slim
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.14"
|
python-version: "3.14"
|
||||||
- name: Run prek
|
- name: Run prek
|
||||||
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
|
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
statuses: read
|
statuses: read
|
||||||
steps:
|
steps:
|
||||||
- name: Wait for Docker build
|
- name: Wait for Docker build
|
||||||
uses: lewagon/wait-on-check-action@96d9100b431964d10e0136aff8b9ccb92470505e # v1.8.0
|
uses: lewagon/wait-on-check-action@9312864dfbc9fd208e9c0417843430751c042800 # v1.7.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.sha }}
|
ref: ${{ github.sha }}
|
||||||
check-name: 'Merge and Push Manifest'
|
check-name: 'Merge and Push Manifest'
|
||||||
@@ -34,12 +34,12 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# ---- Frontend Build ----
|
# ---- Frontend Build ----
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -54,11 +54,11 @@ jobs:
|
|||||||
# ---- Backend Setup ----
|
# ---- Backend Setup ----
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -170,7 +170,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
- name: Create release and changelog
|
- name: Create release and changelog
|
||||||
id: create-release
|
id: create-release
|
||||||
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
|
||||||
with:
|
with:
|
||||||
name: Paperless-ngx ${{ steps.get-version.outputs.version }}
|
name: Paperless-ngx ${{ steps.get-version.outputs.version }}
|
||||||
tag: ${{ steps.get-version.outputs.version }}
|
tag: ${{ steps.get-version.outputs.version }}
|
||||||
@@ -181,7 +181,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Upload release archive
|
- name: Upload release archive
|
||||||
uses: shogo82148/actions-upload-release-asset@394b3c11c3cfc038b5396ad265c074065cf875c3 # v1.10.2
|
uses: shogo82148/actions-upload-release-asset@ee2ae851dc5d938b90075b3ef12c540abfd1ee72 # v1.10.1
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
upload_url: ${{ steps.create-release.outputs.upload_url }}
|
upload_url: ${{ steps.create-release.outputs.upload_url }}
|
||||||
@@ -201,17 +201,17 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
persist-credentials: true # for pushing changelog branch
|
persist-credentials: true # for pushing changelog branch
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ jobs:
|
|||||||
security-events: write
|
security-events: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||||
semgrep:
|
semgrep:
|
||||||
name: Semgrep CE
|
name: Semgrep CE
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -38,13 +38,13 @@ jobs:
|
|||||||
security-events: write
|
security-events: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Run Semgrep
|
- name: Run Semgrep
|
||||||
run: semgrep scan --config auto --sarif-output results.sarif
|
run: semgrep scan --config auto --sarif-output results.sarif
|
||||||
- name: Upload results to GitHub code scanning
|
- name: Upload results to GitHub code scanning
|
||||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ jobs:
|
|||||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -47,4 +47,4 @@ jobs:
|
|||||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ jobs:
|
|||||||
environment: translation-sync
|
environment: translation-sync
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: crowdin action
|
- name: crowdin action
|
||||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||||
with:
|
with:
|
||||||
upload_translations: false
|
upload_translations: false
|
||||||
download_translations: true
|
download_translations: true
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Label PR by file path or branch name
|
- name: Label PR by file path or branch name
|
||||||
# see .github/labeler.yml for the labeler config
|
# see .github/labeler.yml for the labeler config
|
||||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Label by size
|
- name: Label by size
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ jobs:
|
|||||||
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
|
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
|
||||||
steps:
|
steps:
|
||||||
- name: Label PR with release-drafter
|
- name: Label PR with release-drafter
|
||||||
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||||
with:
|
with:
|
||||||
days-before-stale: 7
|
days-before-stale: 7
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
env:
|
env:
|
||||||
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
|
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
|
||||||
with:
|
with:
|
||||||
@@ -23,13 +23,13 @@ jobs:
|
|||||||
persist-credentials: true # for pushing translation branch
|
persist-credentials: true # for pushing translation branch
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -qq --no-install-recommends gettext
|
sudo apt-get install -qq --no-install-recommends gettext
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
PAPERLESS_SECRET_KEY: "ci-translate-not-a-real-secret"
|
PAPERLESS_SECRET_KEY: "ci-translate-not-a-real-secret"
|
||||||
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
|
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
id: cache-frontend-deps
|
id: cache-frontend-deps
|
||||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ repos:
|
|||||||
- json
|
- json
|
||||||
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
||||||
- repo: https://github.com/rbubley/mirrors-prettier
|
- repo: https://github.com/rbubley/mirrors-prettier
|
||||||
rev: 'v3.8.4'
|
rev: 'v3.8.3'
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or:
|
types_or:
|
||||||
@@ -50,15 +50,14 @@ repos:
|
|||||||
- 'prettier-plugin-organize-imports@4.3.0'
|
- 'prettier-plugin-organize-imports@4.3.0'
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.15.17
|
rev: v0.15.12
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||||
rev: "v2.24.1"
|
rev: "v2.21.1"
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyproject-fmt
|
- id: pyproject-fmt
|
||||||
additional_dependencies: [tomli]
|
|
||||||
# Dockerfile hooks
|
# Dockerfile hooks
|
||||||
- repo: https://github.com/AleksaC/hadolint-py
|
- repo: https://github.com/AleksaC/hadolint-py
|
||||||
rev: v2.14.0
|
rev: v2.14.0
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ RUN set -eux \
|
|||||||
# Purpose: Installs s6-overlay and rootfs
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - Don't leave anything extra in here either
|
||||||
FROM ghcr.io/astral-sh/uv:0.11.19-python3.12-trixie-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.11.6-python3.12-trixie-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ The following are not generally considered vulnerabilities unless accompanied by
|
|||||||
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
||||||
- missing limits or hardening settings presented without concrete impact
|
- missing limits or hardening settings presented without concrete impact
|
||||||
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
|
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
|
||||||
- the ability to attach objects that a user cannot access to a document by ID is an intentional design choice, and not considered a vulnerability
|
|
||||||
|
|
||||||
## Transparency
|
## Transparency
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
# documentation.
|
# documentation.
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/valkey/valkey:9-alpine
|
image: docker.io/library/redis:8
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
# documentation.
|
# documentation.
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/valkey/valkey:9-alpine
|
image: docker.io/library/redis:8
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
# documentation.
|
# documentation.
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/valkey/valkey:9-alpine
|
image: docker.io/library/redis:8
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
# documentation.
|
# documentation.
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/valkey/valkey:9-alpine
|
image: docker.io/library/redis:8
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
# documentation.
|
# documentation.
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/valkey/valkey:9-alpine
|
image: docker.io/library/redis:8
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
# documentation.
|
# documentation.
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/valkey/valkey:9-alpine
|
image: docker.io/library/redis:8
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
# documentation.
|
# documentation.
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/valkey/valkey:9-alpine
|
image: docker.io/library/redis:8
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|||||||
@@ -65,11 +65,6 @@ copies you created in the steps above.
|
|||||||
|
|
||||||
Please review the [migration instructions](migration-v3.md) before upgrading Paperless-ngx to v3.0, it includes some breaking changes that require manual intervention before upgrading.
|
Please review the [migration instructions](migration-v3.md) before upgrading Paperless-ngx to v3.0, it includes some breaking changes that require manual intervention before upgrading.
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
Upgrading to v3 clears the existing task history; previously completed, failed, or
|
|
||||||
acknowledged tasks will no longer appear in the task list afterward. No action is required.
|
|
||||||
|
|
||||||
### Docker Route {#docker-updating}
|
### Docker Route {#docker-updating}
|
||||||
|
|
||||||
If a new release of paperless-ngx is available, upgrading depends on how
|
If a new release of paperless-ngx is available, upgrading depends on how
|
||||||
@@ -505,33 +500,6 @@ task scheduler.
|
|||||||
python3 manage.py document_index reindex --if-needed
|
python3 manage.py document_index reindex --if-needed
|
||||||
```
|
```
|
||||||
|
|
||||||
### Managing the LLM (AI) index {#llm-index}
|
|
||||||
|
|
||||||
When the [AI features](advanced_usage.md#ai-features) are enabled with an embedding
|
|
||||||
backend, Paperless-ngx maintains a vector index of your documents used for
|
|
||||||
Retrieval-Augmented Generation (RAG), similar-document retrieval, and document chat. The
|
|
||||||
index is updated automatically on the schedule set by
|
|
||||||
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON), but you
|
|
||||||
can manage it manually:
|
|
||||||
|
|
||||||
```
|
|
||||||
document_llmindex {rebuild,update,compact}
|
|
||||||
```
|
|
||||||
|
|
||||||
Specify `rebuild` to build the index from scratch from all documents in the database. Use
|
|
||||||
this the first time you enable the feature, or after changing the embedding backend or
|
|
||||||
model.
|
|
||||||
|
|
||||||
Specify `update` to incrementally index new and changed documents. This is what the
|
|
||||||
scheduled task runs.
|
|
||||||
|
|
||||||
Specify `compact` to reclaim space and optimize the on-disk vector store.
|
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
These commands have no effect unless AI is enabled and an embedding backend is
|
|
||||||
configured.
|
|
||||||
|
|
||||||
### Clearing the database read cache
|
### Clearing the database read cache
|
||||||
|
|
||||||
If the database read cache is enabled, **you must run this command** after making any changes to the database outside the application context.
|
If the database read cache is enabled, **you must run this command** after making any changes to the database outside the application context.
|
||||||
|
|||||||
+2
-83
@@ -97,85 +97,6 @@ when using this feature:
|
|||||||
of these correspondents to ANY new document, if both are set to
|
of these correspondents to ANY new document, if both are set to
|
||||||
automatic matching.
|
automatic matching.
|
||||||
|
|
||||||
## AI features {#ai-features}
|
|
||||||
|
|
||||||
Paperless-ngx includes a set of optional features backed by a large language model
|
|
||||||
(LLM): AI-assisted suggestions, similar-document retrieval, and a document chat. They
|
|
||||||
are **off by default** and never replace the built-in, non-LLM
|
|
||||||
[matching and suggestions](#matching).
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
Enabling these features sends document content (and metadata) to the LLM backend you
|
|
||||||
configure. If that backend is a remote/hosted provider, your documents leave your
|
|
||||||
server and may incur usage charges. Consider the privacy implications before enabling,
|
|
||||||
and prefer a local backend (Ollama, or a self-hosted OpenAI-compatible gateway) if that
|
|
||||||
matters to you.
|
|
||||||
|
|
||||||
All AI settings can be supplied as `PAPERLESS_AI_*` environment variables (see
|
|
||||||
[configuration](configuration.md#ai)) or set in the admin under
|
|
||||||
**Settings → Application Configuration**; the database value takes precedence over the
|
|
||||||
environment.
|
|
||||||
|
|
||||||
### Enabling the AI features
|
|
||||||
|
|
||||||
At a minimum you need to enable AI and choose an LLM backend:
|
|
||||||
|
|
||||||
- [`PAPERLESS_AI_ENABLED`](configuration.md#PAPERLESS_AI_ENABLED) — master switch.
|
|
||||||
- [`PAPERLESS_AI_LLM_BACKEND`](configuration.md#PAPERLESS_AI_LLM_BACKEND) — `ollama`
|
|
||||||
(runs locally) or `openai-like` (OpenAI itself or any OpenAI-compatible API).
|
|
||||||
- [`PAPERLESS_AI_LLM_MODEL`](configuration.md#PAPERLESS_AI_LLM_MODEL), and for
|
|
||||||
`openai-like` usually [`PAPERLESS_AI_LLM_API_KEY`](configuration.md#PAPERLESS_AI_LLM_API_KEY)
|
|
||||||
and/or [`PAPERLESS_AI_LLM_ENDPOINT`](configuration.md#PAPERLESS_AI_LLM_ENDPOINT). Ollama
|
|
||||||
requires `PAPERLESS_AI_LLM_ENDPOINT` pointing at your Ollama server.
|
|
||||||
|
|
||||||
### AI-assisted suggestions
|
|
||||||
|
|
||||||
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
|
||||||
storage path and dates by sending the document to the LLM. This is **opt-in per request**
|
|
||||||
and surfaces through the "Suggest" control on the document detail page, alongside the
|
|
||||||
classic classifier-based suggestions — it does not disable them. Suggestion output
|
|
||||||
language can be steered with
|
|
||||||
[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
|
|
||||||
(otherwise it follows the user's UI language).
|
|
||||||
|
|
||||||
### The LLM index (RAG) and similar documents
|
|
||||||
|
|
||||||
Setting an embedding backend turns on the **LLM index**, a vector index of your documents
|
|
||||||
that enables Retrieval-Augmented Generation (RAG). When enabled, suggestions are grounded
|
|
||||||
in similar existing documents, and the document chat can retrieve relevant context.
|
|
||||||
|
|
||||||
Enable it by setting
|
|
||||||
[`PAPERLESS_AI_LLM_EMBEDDING_BACKEND`](configuration.md#PAPERLESS_AI_LLM_EMBEDDING_BACKEND)
|
|
||||||
(`huggingface` for fully-local embeddings, or `ollama` / `openai-like`). The index is only
|
|
||||||
built when AI is enabled **and** an embedding backend is set.
|
|
||||||
|
|
||||||
The index is updated automatically on a schedule controlled by
|
|
||||||
[`PAPERLESS_LLM_INDEX_TASK_CRON`](configuration.md#PAPERLESS_LLM_INDEX_TASK_CRON) (daily by
|
|
||||||
default), and can be rebuilt or compacted manually — see
|
|
||||||
[Managing the LLM index](administration.md#llm-index).
|
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
Local embeddings via `huggingface` download the embedding model on first use into the
|
|
||||||
Paperless data directory. The first run therefore needs network access and some disk
|
|
||||||
space.
|
|
||||||
|
|
||||||
### Document chat
|
|
||||||
|
|
||||||
When the LLM index is enabled, the chat control in the top app toolbar answers questions
|
|
||||||
about your documents. It operates over a single document or across multiple documents
|
|
||||||
depending on the current view, and its answers include links to the source documents it
|
|
||||||
drew from.
|
|
||||||
|
|
||||||
### AI Security notes
|
|
||||||
|
|
||||||
- Document content is passed to the LLM as **untrusted data**.
|
|
||||||
- By default Paperless-ngx allows AI endpoints that resolve to private/loopback addresses
|
|
||||||
(for local backends). Set
|
|
||||||
[`PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS`](configuration.md#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS)
|
|
||||||
to `false` to block them.
|
|
||||||
|
|
||||||
## Hooking into the consumption process {#consume-hooks}
|
## Hooking into the consumption process {#consume-hooks}
|
||||||
|
|
||||||
Sometimes you may want to do something arbitrary whenever a document is
|
Sometimes you may want to do something arbitrary whenever a document is
|
||||||
@@ -925,7 +846,7 @@ Paperless is able to utilize barcodes for automatically performing some tasks. B
|
|||||||
|
|
||||||
At this time, the library utilized for detection of barcodes supports the following types:
|
At this time, the library utilized for detection of barcodes supports the following types:
|
||||||
|
|
||||||
- EAN-13/UPC-A
|
- AN-13/UPC-A
|
||||||
- UPC-E
|
- UPC-E
|
||||||
- EAN-8
|
- EAN-8
|
||||||
- Code 128
|
- Code 128
|
||||||
@@ -934,9 +855,7 @@ At this time, the library utilized for detection of barcodes supports the follow
|
|||||||
- Codabar
|
- Codabar
|
||||||
- Interleaved 2 of 5
|
- Interleaved 2 of 5
|
||||||
- QR Code
|
- QR Code
|
||||||
- Data Matrix
|
- SQ Code
|
||||||
- Aztec
|
|
||||||
- PDF417
|
|
||||||
|
|
||||||
For usage in Paperless, the type of barcode does not matter, only the contents of it.
|
For usage in Paperless, the type of barcode does not matter, only the contents of it.
|
||||||
|
|
||||||
|
|||||||
@@ -227,7 +227,6 @@ Version-aware endpoints:
|
|||||||
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
|
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
|
||||||
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
|
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
|
||||||
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
|
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
|
||||||
- `PATCH /api/documents/{id}/versions/{version_id}/`: updates the `version_label` of a specific version.
|
|
||||||
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
|
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
@@ -446,9 +445,3 @@ Initial API version.
|
|||||||
large lists of object IDs for operations affecting many objects.
|
large lists of object IDs for operations affecting many objects.
|
||||||
- The legacy `title_content` document search parameter is deprecated and will be removed in a future version.
|
- The legacy `title_content` document search parameter is deprecated and will be removed in a future version.
|
||||||
Clients should use `text` for simple title-and-content search and `title_search` for title-only search.
|
Clients should use `text` for simple title-and-content search and `title_search` for title-only search.
|
||||||
- The task tracking system was redesigned. The tasks list (`/api/tasks/`) is now paginated, and the
|
|
||||||
task object exposes `task_type` (formerly `task_name`) and `trigger_source` (formerly `type`). New
|
|
||||||
read-only endpoints `/api/tasks/summary/`, `/api/tasks/status_counts/`, and `/api/tasks/active/`
|
|
||||||
provide aggregate views, and `POST /api/tasks/run/` lets privileged users dispatch supported tasks.
|
|
||||||
API v9 continues to serve the unpaginated list with the legacy field names until support for v9 is
|
|
||||||
dropped.
|
|
||||||
|
|||||||
+17
-28
@@ -22,11 +22,7 @@ or applicable default will be utilized instead.
|
|||||||
|
|
||||||
## Required services
|
## Required services
|
||||||
|
|
||||||
### Message Broker
|
### Redis Broker
|
||||||
|
|
||||||
Paperless-ngx uses a Redis-compatible message broker. Any broker that
|
|
||||||
speaks the Redis protocol works here, including [Valkey](https://valkey.io/)
|
|
||||||
(the default in the bundled Docker Compose files) and Redis itself.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_REDIS=<url>`](#PAPERLESS_REDIS) {#PAPERLESS_REDIS}
|
#### [`PAPERLESS_REDIS=<url>`](#PAPERLESS_REDIS) {#PAPERLESS_REDIS}
|
||||||
|
|
||||||
@@ -34,21 +30,21 @@ speaks the Redis protocol works here, including [Valkey](https://valkey.io/)
|
|||||||
fetching, index optimization and for training the automatic document
|
fetching, index optimization and for training the automatic document
|
||||||
matcher.
|
matcher.
|
||||||
|
|
||||||
- If your broker needs login credentials PAPERLESS_REDIS =
|
- If your Redis server needs login credentials PAPERLESS_REDIS =
|
||||||
`redis://<username>:<password>@<host>:<port>`
|
`redis://<username>:<password>@<host>:<port>`
|
||||||
- With the requirepass option PAPERLESS_REDIS =
|
- With the requirepass option PAPERLESS_REDIS =
|
||||||
`redis://:<password>@<host>:<port>`
|
`redis://:<password>@<host>:<port>`
|
||||||
- To include the database index PAPERLESS_REDIS =
|
- To include the redis database index PAPERLESS_REDIS =
|
||||||
`redis://<username>:<password>@<host>:<port>/<DBIndex>`
|
`redis://<username>:<password>@<host>:<port>/<DBIndex>`
|
||||||
|
|
||||||
[More information on securing your broker
|
[More information on securing your Redis
|
||||||
instance](https://valkey.io/topics/security/).
|
Instance](https://redis.io/docs/latest/operate/oss_and_stack/management/security).
|
||||||
|
|
||||||
Defaults to `redis://localhost:6379`.
|
Defaults to `redis://localhost:6379`.
|
||||||
|
|
||||||
#### [`PAPERLESS_REDIS_PREFIX=<prefix>`](#PAPERLESS_REDIS_PREFIX) {#PAPERLESS_REDIS_PREFIX}
|
#### [`PAPERLESS_REDIS_PREFIX=<prefix>`](#PAPERLESS_REDIS_PREFIX) {#PAPERLESS_REDIS_PREFIX}
|
||||||
|
|
||||||
: Prefix to be used in the broker for keys and channels. Useful for sharing one broker among multiple Paperless instances.
|
: Prefix to be used in Redis for keys and channels. Useful for sharing one Redis server among multiple Paperless instances.
|
||||||
|
|
||||||
Defaults to no prefix.
|
Defaults to no prefix.
|
||||||
|
|
||||||
@@ -62,14 +58,14 @@ and the relevant connection variables.
|
|||||||
#### [`PAPERLESS_DBENGINE=<engine>`](#PAPERLESS_DBENGINE) {#PAPERLESS_DBENGINE}
|
#### [`PAPERLESS_DBENGINE=<engine>`](#PAPERLESS_DBENGINE) {#PAPERLESS_DBENGINE}
|
||||||
|
|
||||||
: Specifies the database engine to use. Accepted values are `sqlite`, `postgresql`,
|
: Specifies the database engine to use. Accepted values are `sqlite`, `postgresql`,
|
||||||
and `mariadb`. PostgreSQL and MariaDB users must set this explicitly.
|
and `mariadb`.
|
||||||
|
|
||||||
|
Defaults to `sqlite` if not set.
|
||||||
|
|
||||||
PostgreSQL and MariaDB both require [`PAPERLESS_DBHOST`](#PAPERLESS_DBHOST) to be
|
PostgreSQL and MariaDB both require [`PAPERLESS_DBHOST`](#PAPERLESS_DBHOST) to be
|
||||||
set. SQLite does not use any other connection variables; the database file is always
|
set. SQLite does not use any other connection variables; the database file is always
|
||||||
located at `<PAPERLESS_DATA_DIR>/db.sqlite3`.
|
located at `<PAPERLESS_DATA_DIR>/db.sqlite3`.
|
||||||
|
|
||||||
Defaults to `sqlite`.
|
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
Using MariaDB comes with some caveats.
|
Using MariaDB comes with some caveats.
|
||||||
See [MySQL Caveats](advanced_usage.md#mysql-caveats).
|
See [MySQL Caveats](advanced_usage.md#mysql-caveats).
|
||||||
@@ -242,7 +238,7 @@ dictionaries; for example, `pool.max_size=20` sets
|
|||||||
|
|
||||||
#### [`PAPERLESS_DB_READ_CACHE_ENABLED=<bool>`](#PAPERLESS_DB_READ_CACHE_ENABLED) {#PAPERLESS_DB_READ_CACHE_ENABLED}
|
#### [`PAPERLESS_DB_READ_CACHE_ENABLED=<bool>`](#PAPERLESS_DB_READ_CACHE_ENABLED) {#PAPERLESS_DB_READ_CACHE_ENABLED}
|
||||||
|
|
||||||
: Caches the database read query results into the broker. This can significantly improve application response times by caching database queries, at the cost of slightly increased memory usage.
|
: Caches the database read query results into Redis. This can significantly improve application response times by caching database queries, at the cost of slightly increased memory usage.
|
||||||
|
|
||||||
Defaults to `false`.
|
Defaults to `false`.
|
||||||
|
|
||||||
@@ -262,18 +258,18 @@ dictionaries; for example, `pool.max_size=20` sets
|
|||||||
|
|
||||||
A high TTL increases memory usage over time. Memory may be used until end of TTL, even if the cache is invalidated with the `invalidate_cachalot` command.
|
A high TTL increases memory usage over time. Memory may be used until end of TTL, even if the cache is invalidated with the `invalidate_cachalot` command.
|
||||||
|
|
||||||
In case of an out-of-memory (OOM) situation, the broker may stop accepting new data — including cache entries, scheduled tasks, and documents to consume.
|
In case of an out-of-memory (OOM) situation, Redis may stop accepting new data — including cache entries, scheduled tasks, and documents to consume.
|
||||||
If your system has limited RAM, consider configuring a dedicated broker instance for the read cache, with a memory limit and the eviction policy set to `allkeys-lru`.
|
If your system has limited RAM, consider configuring a dedicated Redis instance for the read cache, with a memory limit and the eviction policy set to `allkeys-lru`.
|
||||||
For more details, refer to the [Redis eviction policy documentation](https://redis.io/docs/latest/develop/reference/eviction/), and see the `PAPERLESS_READ_CACHE_REDIS_URL` setting to specify a separate broker.
|
For more details, refer to the [Redis eviction policy documentation](https://redis.io/docs/latest/develop/reference/eviction/), and see the `PAPERLESS_READ_CACHE_REDIS_URL` setting to specify a separate Redis broker.
|
||||||
|
|
||||||
#### [`PAPERLESS_READ_CACHE_REDIS_URL=<url>`](#PAPERLESS_READ_CACHE_REDIS_URL) {#PAPERLESS_READ_CACHE_REDIS_URL}
|
#### [`PAPERLESS_READ_CACHE_REDIS_URL=<url>`](#PAPERLESS_READ_CACHE_REDIS_URL) {#PAPERLESS_READ_CACHE_REDIS_URL}
|
||||||
|
|
||||||
: Defines the broker instance used for the read cache.
|
: Defines the Redis instance used for the read cache.
|
||||||
|
|
||||||
Defaults to `None`.
|
Defaults to `None`.
|
||||||
|
|
||||||
!!! Note
|
!!! Note
|
||||||
If this value is not set, the same broker instance used for scheduled tasks will be used for caching as well.
|
If this value is not set, the same Redis instance used for scheduled tasks will be used for caching as well.
|
||||||
|
|
||||||
## Optional Services
|
## Optional Services
|
||||||
|
|
||||||
@@ -892,7 +888,7 @@ modes are available:
|
|||||||
|
|
||||||
The default is `auto`.
|
The default is `auto`.
|
||||||
|
|
||||||
For the `redo` and `force` modes, read more about OCR
|
For the `skip`, `redo`, and `force` modes, read more about OCR
|
||||||
behaviour in the [OCRmyPDF
|
behaviour in the [OCRmyPDF
|
||||||
documentation](https://ocrmypdf.readthedocs.io/en/latest/advanced.html#when-ocr-is-skipped).
|
documentation](https://ocrmypdf.readthedocs.io/en/latest/advanced.html#when-ocr-is-skipped).
|
||||||
|
|
||||||
@@ -2072,13 +2068,6 @@ context by default.
|
|||||||
|
|
||||||
Defaults to 8192.
|
Defaults to 8192.
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_LLM_REQUEST_TIMEOUT=<int>`](#PAPERLESS_AI_LLM_REQUEST_TIMEOUT) {#PAPERLESS_AI_LLM_REQUEST_TIMEOUT}
|
|
||||||
|
|
||||||
: The timeout, in seconds, for requests to the configured AI backend. Increase this when using
|
|
||||||
local or slow inference servers that need more time to generate responses.
|
|
||||||
|
|
||||||
Defaults to 120.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_LLM_BACKEND=<str>`](#PAPERLESS_AI_LLM_BACKEND) {#PAPERLESS_AI_LLM_BACKEND}
|
#### [`PAPERLESS_AI_LLM_BACKEND=<str>`](#PAPERLESS_AI_LLM_BACKEND) {#PAPERLESS_AI_LLM_BACKEND}
|
||||||
|
|
||||||
: The AI backend to use. This can be either "openai-like" or "ollama". If set to "ollama", the AI
|
: The AI backend to use. This can be either "openai-like" or "ollama". If set to "ollama", the AI
|
||||||
@@ -2131,7 +2120,7 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
|
|||||||
|
|
||||||
Defaults to true, which allows internal endpoints.
|
Defaults to true, which allows internal endpoints.
|
||||||
|
|
||||||
#### [`PAPERLESS_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_LLM_INDEX_TASK_CRON) {#PAPERLESS_LLM_INDEX_TASK_CRON}
|
#### [`PAPERLESS_AI_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_AI_LLM_INDEX_TASK_CRON) {#PAPERLESS_AI_LLM_INDEX_TASK_CRON}
|
||||||
|
|
||||||
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
|
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
|
||||||
AI is enabled and the LLM embedding backend is set.
|
AI is enabled and the LLM embedding backend is set.
|
||||||
|
|||||||
+12
-13
@@ -94,16 +94,16 @@ first-time setup.
|
|||||||
```
|
```
|
||||||
|
|
||||||
7. You can now either ...
|
7. You can now either ...
|
||||||
- install a Redis-compatible broker (e.g. Valkey or Redis) or
|
- install Redis or
|
||||||
|
|
||||||
- use the included `scripts/start_services.sh` to use Docker to fire
|
- use the included `scripts/start_services.sh` to use Docker to fire
|
||||||
up a broker instance (and some other services such as Tika,
|
up a Redis instance (and some other services such as Tika,
|
||||||
Gotenberg and a database server) or
|
Gotenberg and a database server) or
|
||||||
|
|
||||||
- spin up a bare broker container
|
- spin up a bare Redis container
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 6379:6379 --restart unless-stopped docker.io/valkey/valkey:9-alpine
|
docker run -d -p 6379:6379 --restart unless-stopped redis:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
8. Continue with either back-end or front-end development – or both :-).
|
8. Continue with either back-end or front-end development – or both :-).
|
||||||
@@ -132,7 +132,7 @@ uv run manage.py runserver & \
|
|||||||
```
|
```
|
||||||
|
|
||||||
You might need the front end to test your back end code.
|
You might need the front end to test your back end code.
|
||||||
This assumes that you have Angular installed on your system.
|
This assumes that you have AngularJS installed on your system.
|
||||||
Go to the [Front end development](#front-end-development) section for further details.
|
Go to the [Front end development](#front-end-development) section for further details.
|
||||||
To build the front end once use this command:
|
To build the front end once use this command:
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ To add a new development package `uv add --dev <package>`
|
|||||||
|
|
||||||
## Front end development
|
## Front end development
|
||||||
|
|
||||||
The front end is built using Angular. In order to get started, you need Node.js (version 24+) and
|
The front end is built using AngularJS. In order to get started, you need Node.js (version 24+) and
|
||||||
`pnpm`.
|
`pnpm`.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@@ -248,12 +248,12 @@ that authentication is working.
|
|||||||
## Localization
|
## Localization
|
||||||
|
|
||||||
Paperless-ngx is available in many different languages. Since Paperless-ngx
|
Paperless-ngx is available in many different languages. Since Paperless-ngx
|
||||||
consists both of a Django application and an Angular front end, both
|
consists both of a Django application and an AngularJS front end, both
|
||||||
these parts have to be translated separately.
|
these parts have to be translated separately.
|
||||||
|
|
||||||
### Front end localization
|
### Front end localization
|
||||||
|
|
||||||
- The Angular front end does localization according to the [Angular
|
- The AngularJS front end does localization according to the [Angular
|
||||||
documentation](https://angular.io/guide/i18n).
|
documentation](https://angular.io/guide/i18n).
|
||||||
- The source language of the project is "en_US".
|
- The source language of the project is "en_US".
|
||||||
- The source strings end up in the file `src-ui/messages.xlf`.
|
- The source strings end up in the file `src-ui/messages.xlf`.
|
||||||
@@ -495,7 +495,7 @@ class MyCustomParser:
|
|||||||
self._tempdir = Path(
|
self._tempdir = Path(
|
||||||
tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR)
|
tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR)
|
||||||
)
|
)
|
||||||
self._text: str = ""
|
self._text: str | None = None
|
||||||
self._archive_path: Path | None = None
|
self._archive_path: Path | None = None
|
||||||
|
|
||||||
def __enter__(self) -> Self:
|
def __enter__(self) -> Self:
|
||||||
@@ -553,8 +553,7 @@ def parse(
|
|||||||
**Result accessors**
|
**Result accessors**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def get_text(self) -> str:
|
def get_text(self) -> str | None:
|
||||||
# Return the extracted text, or an empty string if none was found.
|
|
||||||
return self._text
|
return self._text
|
||||||
|
|
||||||
def get_date(self) -> "datetime.datetime | None":
|
def get_date(self) -> "datetime.datetime | None":
|
||||||
@@ -685,7 +684,7 @@ class XmlDocumentParser:
|
|||||||
def __init__(self, logging_group: object = None) -> None:
|
def __init__(self, logging_group: object = None) -> None:
|
||||||
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
self._tempdir = Path(tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR))
|
self._tempdir = Path(tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR))
|
||||||
self._text: str = ""
|
self._text: str | None = None
|
||||||
|
|
||||||
def __enter__(self) -> Self:
|
def __enter__(self) -> Self:
|
||||||
return self
|
return self
|
||||||
@@ -703,7 +702,7 @@ class XmlDocumentParser:
|
|||||||
except ET.ParseError as e:
|
except ET.ParseError as e:
|
||||||
raise ParseError(f"XML parse error: {e}") from e
|
raise ParseError(f"XML parse error: {e}") from e
|
||||||
|
|
||||||
def get_text(self) -> str:
|
def get_text(self) -> str | None:
|
||||||
return self._text
|
return self._text
|
||||||
|
|
||||||
def get_date(self):
|
def get_date(self):
|
||||||
|
|||||||
+6
-29
@@ -70,16 +70,7 @@ elsewhere. Here are a couple notes about that.
|
|||||||
Paperless-ngx determines the type of a file by inspecting its content
|
Paperless-ngx determines the type of a file by inspecting its content
|
||||||
rather than its file extensions. However, files processed via the
|
rather than its file extensions. However, files processed via the
|
||||||
consumption directory will be rejected if they have a file extension that
|
consumption directory will be rejected if they have a file extension that
|
||||||
is not supported by any of the available parsers.
|
not supported by any of the available parsers.
|
||||||
|
|
||||||
## _Are duplicate documents rejected?_
|
|
||||||
|
|
||||||
**A:** Not by default. As of v3, a file whose contents match an existing document is still
|
|
||||||
consumed, and the duplicate is flagged in the UI — open the document and check the
|
|
||||||
**Duplicates** tab to review documents that share the same content. If you prefer the old
|
|
||||||
behavior of rejecting duplicates during consumption, set
|
|
||||||
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
|
||||||
to `true`.
|
|
||||||
|
|
||||||
## _Will paperless-ngx run on Raspberry Pi?_
|
## _Will paperless-ngx run on Raspberry Pi?_
|
||||||
|
|
||||||
@@ -127,24 +118,10 @@ able to run paperless, you're a bit on your own. If you can't run the
|
|||||||
docker image, the documentation has instructions for bare metal
|
docker image, the documentation has instructions for bare metal
|
||||||
installs.
|
installs.
|
||||||
|
|
||||||
## _Does Paperless-ngx use AI, and is my data private?_
|
## _What about the Redis licensing change and using one of the open source forks_?
|
||||||
|
|
||||||
**A:** Paperless-ngx includes optional AI features — LLM-based suggestions, document chat,
|
Currently (October 2024), forks of Redis such as Valkey or Redirect are not officially supported by our upstream
|
||||||
and similar-document retrieval — that are **disabled by default**. They only run when you
|
libraries, so using one of these to replace Redis is not officially supported.
|
||||||
enable them and configure an LLM backend. The built-in tag/correspondent suggestions use a
|
|
||||||
local, non-LLM machine-learning model and do not send your data anywhere. If you enable the
|
|
||||||
LLM features, document content is sent to whichever backend you configure — this can be a
|
|
||||||
fully local backend (e.g. Ollama) or a remote provider. See
|
|
||||||
[AI features](advanced_usage.md#ai-features) for details.
|
|
||||||
|
|
||||||
## _Which message broker should I use_?
|
However, they do claim to be compatible with the Redis protocol and will likely work, but we will
|
||||||
|
not be updating from using Redis as the broker officially just yet.
|
||||||
Paperless-ngx talks to a Redis-compatible message broker, so any broker that
|
|
||||||
implements the Redis protocol will work. The bundled Docker Compose files
|
|
||||||
default to [Valkey](https://valkey.io/), the open-source fork created after
|
|
||||||
Redis' licensing change, but Redis itself and other wire-compatible brokers
|
|
||||||
(such as Microsoft's Garnet) are equally fine.
|
|
||||||
|
|
||||||
Existing installs can switch broker implementations in place: point
|
|
||||||
[`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) at the new instance and
|
|
||||||
reuse the same data volume.
|
|
||||||
|
|||||||
+1
-2
@@ -35,10 +35,9 @@ physical documents into a searchable online archive so you can keep, well, _less
|
|||||||
- _New!_ Supports remote OCR with Azure AI (opt-in).
|
- _New!_ Supports remote OCR with Azure AI (opt-in).
|
||||||
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
|
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
|
||||||
- Uses machine-learning to automatically add tags, correspondents and document types to your documents.
|
- Uses machine-learning to automatically add tags, correspondents and document types to your documents.
|
||||||
- **New**: Paperless-ngx can optionally leverage AI (Large Language Models or LLMs) for document suggestions, chatting with your documents, and similar-document retrieval. These features are opt-in and disabled by default.
|
- **New**: Paperless-ngx can now leverage AI (Large Language Models or LLMs) for document suggestions. This is an optional feature that can be enabled (and is disabled by default).
|
||||||
- Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more.
|
- Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more.
|
||||||
- Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents.
|
- Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents.
|
||||||
- Keep multiple **versions** of a document's file under a single entry, sharing one set of metadata.
|
|
||||||
- **Beautiful, modern web application** that features:
|
- **Beautiful, modern web application** that features:
|
||||||
- Customizable dashboard with statistics.
|
- Customizable dashboard with statistics.
|
||||||
- Filtering by tags, correspondents, types, and more.
|
- Filtering by tags, correspondents, types, and more.
|
||||||
|
|||||||
+12
-19
@@ -178,7 +178,7 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
|||||||
- `fonts-liberation` for generating thumbnails for plain text
|
- `fonts-liberation` for generating thumbnails for plain text
|
||||||
files
|
files
|
||||||
- `imagemagick` >= 6 for PDF conversion
|
- `imagemagick` >= 6 for PDF conversion
|
||||||
- `gnupg` for decrypting GPG-encrypted email
|
- `gnupg` for handling encrypted documents
|
||||||
- `libpq-dev` for PostgreSQL
|
- `libpq-dev` for PostgreSQL
|
||||||
- `libmagic-dev` for mime type detection
|
- `libmagic-dev` for mime type detection
|
||||||
- `mariadb-client` for MariaDB compile time
|
- `mariadb-client` for MariaDB compile time
|
||||||
@@ -226,8 +226,7 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
|||||||
build-essential python3-setuptools python3-wheel
|
build-essential python3-setuptools python3-wheel
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install a Redis-compatible broker (a current release of Valkey or
|
2. Install `redis` >= 6.0 and configure it to start automatically.
|
||||||
Redis) and configure it to start automatically.
|
|
||||||
|
|
||||||
3. Optional: Install `postgresql` and configure a database, user, and
|
3. Optional: Install `postgresql` and configure a database, user, and
|
||||||
password for Paperless-ngx. If you do not wish to use PostgreSQL,
|
password for Paperless-ngx. If you do not wish to use PostgreSQL,
|
||||||
@@ -269,10 +268,10 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
|||||||
6. Configure Paperless-ngx. See [configuration](configuration.md) for details.
|
6. Configure Paperless-ngx. See [configuration](configuration.md) for details.
|
||||||
Edit the included `paperless.conf` and adjust the settings to your
|
Edit the included `paperless.conf` and adjust the settings to your
|
||||||
needs. Required settings for getting Paperless-ngx running are:
|
needs. Required settings for getting Paperless-ngx running are:
|
||||||
- [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your broker, such as
|
- [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your Redis server, such as
|
||||||
`redis://localhost:6379`.
|
`redis://localhost:6379`.
|
||||||
- [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) should be one of `postgresql`,
|
- [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) is optional, and should be one of `postgres`,
|
||||||
`mariadb`, or `sqlite`. PostgreSQL and MariaDB users must set this explicitly.
|
`mariadb`, or `sqlite`
|
||||||
- [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your
|
- [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your
|
||||||
PostgreSQL server is running. Do not configure this to use
|
PostgreSQL server is running. Do not configure this to use
|
||||||
SQLite instead. Also configure port, database name, user and
|
SQLite instead. Also configure port, database name, user and
|
||||||
@@ -298,7 +297,7 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
|||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
Ensure your broker instance [is secured](https://valkey.io/topics/security/).
|
Ensure your Redis instance [is secured](https://redis.io/docs/latest/operate/oss_and_stack/management/security/).
|
||||||
|
|
||||||
7. Create the following directories if they do not already exist:
|
7. Create the following directories if they do not already exist:
|
||||||
- `/opt/paperless/media`
|
- `/opt/paperless/media`
|
||||||
@@ -390,9 +389,9 @@ to enable polling and disable inotify. See [here](configuration.md#polling).
|
|||||||
`Require=paperless-webserver.socket` in the `webserver` script
|
`Require=paperless-webserver.socket` in the `webserver` script
|
||||||
and configure `granian` to listen on port 80 (set `GRANIAN_PORT`).
|
and configure `granian` to listen on port 80 (set `GRANIAN_PORT`).
|
||||||
|
|
||||||
These services rely on the broker and optionally the database server, but
|
These services rely on Redis and optionally the database server, but
|
||||||
don't need to be started in any particular order. The example files
|
don't need to be started in any particular order. The example files
|
||||||
depend on the broker being started. If you use a database server, you
|
depend on Redis being started. If you use a database server, you
|
||||||
should add additional dependencies.
|
should add additional dependencies.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@@ -450,12 +449,6 @@ development documentation.
|
|||||||
You can migrate to Paperless-ngx from Paperless-ng or from the original
|
You can migrate to Paperless-ngx from Paperless-ng or from the original
|
||||||
Paperless project.
|
Paperless project.
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
Upgrading an existing Paperless-ngx installation from v2 to v3 has its own
|
|
||||||
breaking changes and required steps. See the [v3 migration guide](migration-v3.md)
|
|
||||||
before upgrading.
|
|
||||||
|
|
||||||
<h3 id="migration_ng">Migrating from Paperless-ng</h3>
|
<h3 id="migration_ng">Migrating from Paperless-ng</h3>
|
||||||
|
|
||||||
Paperless-ngx is meant to be a drop-in replacement for Paperless-ng, and
|
Paperless-ngx is meant to be a drop-in replacement for Paperless-ng, and
|
||||||
@@ -501,7 +494,7 @@ installation. Keep these points in mind:
|
|||||||
for other services, you might as well use it for Paperless as well.
|
for other services, you might as well use it for Paperless as well.
|
||||||
- The task scheduler of Paperless, which is used to execute periodic
|
- The task scheduler of Paperless, which is used to execute periodic
|
||||||
tasks such as email checking and maintenance, requires a
|
tasks such as email checking and maintenance, requires a
|
||||||
Redis-compatible message broker instance (such as Valkey or Redis). The
|
[Redis](https://redis.io/) message broker instance. The
|
||||||
Docker Compose route takes care of that.
|
Docker Compose route takes care of that.
|
||||||
- The layout of the folder structure for your documents and data
|
- The layout of the folder structure for your documents and data
|
||||||
remains the same, so you can plug your old Docker volumes into
|
remains the same, so you can plug your old Docker volumes into
|
||||||
@@ -589,16 +582,16 @@ commands as well.
|
|||||||
|
|
||||||
1. Stop and remove the Paperless container.
|
1. Stop and remove the Paperless container.
|
||||||
2. If using an external database, stop that container.
|
2. If using an external database, stop that container.
|
||||||
3. Update broker configuration.
|
3. Update Redis configuration.
|
||||||
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
|
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
|
||||||
and continue to step 4.
|
and continue to step 4.
|
||||||
|
|
||||||
1. Otherwise, add a new broker service in `docker-compose.yml`,
|
1. Otherwise, add a new Redis service in `docker-compose.yml`,
|
||||||
following [the example compose
|
following [the example compose
|
||||||
files](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
|
files](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
|
||||||
|
|
||||||
1. Set the environment variable [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) so it points to
|
1. Set the environment variable [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) so it points to
|
||||||
the new broker container.
|
the new Redis container.
|
||||||
|
|
||||||
4. Update user mapping.
|
4. Update user mapping.
|
||||||
1. If set, change the environment variable `PUID` to `USERMAP_UID`.
|
1. If set, change the environment variable `PUID` to `USERMAP_UID`.
|
||||||
|
|||||||
+33
-2
@@ -10,9 +10,9 @@ Check for the following issues:
|
|||||||
`CONSUMPTION_DIR` setting. Don't adjust this setting if you're
|
`CONSUMPTION_DIR` setting. Don't adjust this setting if you're
|
||||||
using docker.
|
using docker.
|
||||||
|
|
||||||
- Ensure that the broker is up and running. Paperless does its task
|
- Ensure that redis is up and running. Paperless does its task
|
||||||
processing asynchronously, and for documents to arrive at the task
|
processing asynchronously, and for documents to arrive at the task
|
||||||
processor, it needs the broker to run.
|
processor, it needs redis to run.
|
||||||
|
|
||||||
- Ensure that the task processor is running. Docker does this
|
- Ensure that the task processor is running. Docker does this
|
||||||
automatically. Manually invoke the task processor by executing
|
automatically. Manually invoke the task processor by executing
|
||||||
@@ -149,6 +149,37 @@ operating system, if these are different from `1000`. See [Docker setup](setup.m
|
|||||||
Also ensure that you are able to read and write to the consumption
|
Also ensure that you are able to read and write to the consumption
|
||||||
directory on the host.
|
directory on the host.
|
||||||
|
|
||||||
|
## OSError: \[Errno 19\] No such device when consuming files
|
||||||
|
|
||||||
|
If you experience errors such as:
|
||||||
|
|
||||||
|
```shell-session
|
||||||
|
File "/usr/local/lib/python3.7/site-packages/whoosh/codec/base.py", line 570, in open_compound_file
|
||||||
|
return CompoundStorage(dbfile, use_mmap=storage.supports_mmap)
|
||||||
|
File "/usr/local/lib/python3.7/site-packages/whoosh/filedb/compound.py", line 75, in __init__
|
||||||
|
self._source = mmap.mmap(fileno, 0, access=mmap.ACCESS_READ)
|
||||||
|
OSError: [Errno 19] No such device
|
||||||
|
|
||||||
|
During handling of the above exception, another exception occurred:
|
||||||
|
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/usr/local/lib/python3.7/site-packages/django_q/cluster.py", line 436, in worker
|
||||||
|
res = f(*task["args"], **task["kwargs"])
|
||||||
|
File "/usr/src/paperless/src/documents/tasks.py", line 73, in consume_file
|
||||||
|
override_tag_ids=override_tag_ids)
|
||||||
|
File "/usr/src/paperless/src/documents/consumer.py", line 271, in try_consume_file
|
||||||
|
raise ConsumerError(e)
|
||||||
|
```
|
||||||
|
|
||||||
|
Paperless uses a search index to provide better and faster full text
|
||||||
|
searching. This search index is stored inside the `data` folder. The
|
||||||
|
search index uses memory-mapped files (mmap). The above error indicates
|
||||||
|
that paperless was unable to create and open these files.
|
||||||
|
|
||||||
|
This happens when you're trying to store the data directory on certain
|
||||||
|
file systems (mostly network shares) that don't support memory-mapped
|
||||||
|
files.
|
||||||
|
|
||||||
## Web-UI stuck at "Loading\..."
|
## Web-UI stuck at "Loading\..."
|
||||||
|
|
||||||
This might have multiple reasons.
|
This might have multiple reasons.
|
||||||
|
|||||||
+2
-21
@@ -292,23 +292,6 @@ Once setup, navigating to the email settings page in Paperless-ngx will allow yo
|
|||||||
You can also submit a document using the REST API, see [POSTing documents](api.md#file-uploads)
|
You can also submit a document using the REST API, see [POSTing documents](api.md#file-uploads)
|
||||||
for details.
|
for details.
|
||||||
|
|
||||||
### Duplicate documents
|
|
||||||
|
|
||||||
By default, Paperless-ngx **does not reject duplicates**. If you consume a file whose
|
|
||||||
contents exactly match an existing document (same checksum), the new copy is still
|
|
||||||
consumed and a warning is logged. The task entry for the upload also flags that a
|
|
||||||
duplicate was detected and links to the existing document(s).
|
|
||||||
|
|
||||||
To review duplicates, open a document and switch to the **Duplicates** tab on the
|
|
||||||
document detail page. It lists other documents that share the same content, including any
|
|
||||||
that are in the trash (shown with a badge), and links to each so you can decide which to
|
|
||||||
keep.
|
|
||||||
|
|
||||||
If you would rather reject duplicates at consumption time (the pre-v3 behavior), set
|
|
||||||
[`PAPERLESS_CONSUMER_DELETE_DUPLICATES`](configuration.md#PAPERLESS_CONSUMER_DELETE_DUPLICATES)
|
|
||||||
to `true`. The duplicate file is then deleted instead of consumed, and the task fails with
|
|
||||||
a "document already exists" message.
|
|
||||||
|
|
||||||
## Document Suggestions
|
## Document Suggestions
|
||||||
|
|
||||||
Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user.
|
Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user.
|
||||||
@@ -323,9 +306,7 @@ Paperless-ngx includes several features that use AI to enhance the document mana
|
|||||||
so consider the privacy implications of using these features, especially if using a remote
|
so consider the privacy implications of using these features, especially if using a remote
|
||||||
model or API provider instead of the default local model.
|
model or API provider instead of the default local model.
|
||||||
|
|
||||||
The AI features work by creating an embedding of the text content and metadata of documents, which is then used for various tasks such as similarity search and question answering.
|
The AI features work by creating an embedding of the text content and metadata of documents, which is then used for various tasks such as similarity search and question answering. This uses the FAISS vector store.
|
||||||
|
|
||||||
See [AI features](advanced_usage.md#ai-features) for how to enable and configure these features, including choosing an LLM backend and setting up the LLM index for RAG.
|
|
||||||
|
|
||||||
### AI-Enhanced Suggestions
|
### AI-Enhanced Suggestions
|
||||||
|
|
||||||
@@ -1116,7 +1097,7 @@ Paperless-ngx consists of the following components:
|
|||||||
errors (i.e., wrong email credentials, errors during consuming a
|
errors (i.e., wrong email credentials, errors during consuming a
|
||||||
specific file, etc).
|
specific file, etc).
|
||||||
|
|
||||||
- A message broker (such as Valkey or Redis): This is a really
|
- A [redis](https://redis.io/) message broker: This is a really
|
||||||
lightweight service that is responsible for getting the tasks from
|
lightweight service that is responsible for getting the tasks from
|
||||||
the webserver and the consumer to the task scheduler. These run in a
|
the webserver and the consumer to the task scheduler. These run in a
|
||||||
different process (maybe even on different machines!), and
|
different process (maybe even on different machines!), and
|
||||||
|
|||||||
+26
-26
@@ -50,7 +50,7 @@ dependencies = [
|
|||||||
"imap-tools~=1.13.0",
|
"imap-tools~=1.13.0",
|
||||||
"jinja2~=3.1.5",
|
"jinja2~=3.1.5",
|
||||||
"langdetect~=1.0.9",
|
"langdetect~=1.0.9",
|
||||||
"llama-index-core>=0.14.22",
|
"llama-index-core>=0.14.21",
|
||||||
"llama-index-embeddings-huggingface>=0.6.1",
|
"llama-index-embeddings-huggingface>=0.6.1",
|
||||||
"llama-index-embeddings-ollama>=0.9",
|
"llama-index-embeddings-ollama>=0.9",
|
||||||
"llama-index-embeddings-openai-like>=0.2.2",
|
"llama-index-embeddings-openai-like>=0.2.2",
|
||||||
@@ -75,7 +75,7 @@ dependencies = [
|
|||||||
"sqlite-vec==0.1.9",
|
"sqlite-vec==0.1.9",
|
||||||
"tantivy~=0.26.0",
|
"tantivy~=0.26.0",
|
||||||
"tika-client~=0.11.0",
|
"tika-client~=0.11.0",
|
||||||
"torch~=2.12.0",
|
"torch~=2.11.0",
|
||||||
"watchfiles>=1.1.1",
|
"watchfiles>=1.1.1",
|
||||||
"whitenoise~=6.11",
|
"whitenoise~=6.11",
|
||||||
"zxing-cpp~=3.0.0",
|
"zxing-cpp~=3.0.0",
|
||||||
@@ -88,7 +88,7 @@ postgres = [
|
|||||||
"psycopg[c,pool]==3.3",
|
"psycopg[c,pool]==3.3",
|
||||||
# Direct dependency for proper resolution of the pre-built wheels
|
# Direct dependency for proper resolution of the pre-built wheels
|
||||||
"psycopg-c==3.3",
|
"psycopg-c==3.3",
|
||||||
"psycopg-pool==3.3.1",
|
"psycopg-pool==3.3",
|
||||||
]
|
]
|
||||||
webserver = [
|
webserver = [
|
||||||
"granian[uvloop]~=2.7.0",
|
"granian[uvloop]~=2.7.0",
|
||||||
@@ -101,11 +101,11 @@ dev = [
|
|||||||
{ include-group = "testing" },
|
{ include-group = "testing" },
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
"zensical>=0.0.43",
|
"zensical>=0.0.36",
|
||||||
]
|
]
|
||||||
lint = [
|
lint = [
|
||||||
"prek~=0.3.10",
|
"prek~=0.3.10",
|
||||||
"ruff~=0.15.15",
|
"ruff~=0.15.12",
|
||||||
]
|
]
|
||||||
testing = [
|
testing = [
|
||||||
"daphne",
|
"daphne",
|
||||||
@@ -245,38 +245,50 @@ per-file-ignores."src/documents/models.py" = [
|
|||||||
isort.force-single-line = true
|
isort.force-single-line = true
|
||||||
|
|
||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
|
write-changes = true
|
||||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
||||||
skip = """\
|
skip = """\
|
||||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
||||||
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
||||||
"""
|
"""
|
||||||
write-changes = true
|
|
||||||
|
|
||||||
[tool.pyproject-fmt]
|
[tool.pyproject-fmt]
|
||||||
table_format = "long"
|
table_format = "long"
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
mypy_path = "src"
|
mypy_path = "src"
|
||||||
disallow_any_generics = true
|
|
||||||
disallow_untyped_defs = true
|
|
||||||
disallow_incomplete_defs = true
|
|
||||||
check_untyped_defs = true
|
|
||||||
warn_redundant_casts = true
|
|
||||||
warn_unused_ignores = true
|
|
||||||
plugins = [
|
plugins = [
|
||||||
"mypy_django_plugin.main",
|
"mypy_django_plugin.main",
|
||||||
"mypy_drf_plugin.main",
|
"mypy_drf_plugin.main",
|
||||||
]
|
]
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_any_generics = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
|
||||||
[tool.pyrefly]
|
[tool.pyrefly]
|
||||||
|
search-path = [ "src" ]
|
||||||
baseline = ".pyrefly-baseline.json"
|
baseline = ".pyrefly-baseline.json"
|
||||||
python-platform = "linux"
|
python-platform = "linux"
|
||||||
search-path = [ "src" ]
|
|
||||||
|
|
||||||
[tool.django-stubs]
|
[tool.django-stubs]
|
||||||
django_settings_module = "paperless.settings"
|
django_settings_module = "paperless.settings"
|
||||||
|
|
||||||
[tool.pytest]
|
[tool.pytest]
|
||||||
|
minversion = "9.0"
|
||||||
|
pythonpath = [ "src" ]
|
||||||
|
strict_config = true
|
||||||
|
strict_markers = true
|
||||||
|
strict_parametrization_ids = true
|
||||||
|
strict_xfail = true
|
||||||
|
testpaths = [
|
||||||
|
"src/documents/tests/",
|
||||||
|
"src/paperless/tests/",
|
||||||
|
"src/paperless_mail/tests/",
|
||||||
|
"src/paperless_ai/tests",
|
||||||
|
]
|
||||||
addopts = [
|
addopts = [
|
||||||
"--pythonwarnings=all",
|
"--pythonwarnings=all",
|
||||||
"--cov",
|
"--cov",
|
||||||
@@ -291,6 +303,7 @@ addopts = [
|
|||||||
"-o",
|
"-o",
|
||||||
"junit_family=legacy",
|
"junit_family=legacy",
|
||||||
]
|
]
|
||||||
|
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
|
||||||
DJANGO_SETTINGS_MODULE = "paperless.settings"
|
DJANGO_SETTINGS_MODULE = "paperless.settings"
|
||||||
markers = [
|
markers = [
|
||||||
"live: Integration tests requiring external services (Gotenberg, Tika, nginx, etc)",
|
"live: Integration tests requiring external services (Gotenberg, Tika, nginx, etc)",
|
||||||
@@ -303,19 +316,6 @@ markers = [
|
|||||||
"search: Tests for the Tantivy search backend",
|
"search: Tests for the Tantivy search backend",
|
||||||
"api: Tests for REST API endpoints",
|
"api: Tests for REST API endpoints",
|
||||||
]
|
]
|
||||||
minversion = "9.0"
|
|
||||||
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
|
|
||||||
pythonpath = [ "src" ]
|
|
||||||
strict_config = true
|
|
||||||
strict_markers = true
|
|
||||||
strict_parametrization_ids = true
|
|
||||||
strict_xfail = true
|
|
||||||
testpaths = [
|
|
||||||
"src/documents/tests/",
|
|
||||||
"src/paperless/tests/",
|
|
||||||
"src/paperless_mail/tests/",
|
|
||||||
"src/paperless_ai/tests",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.pytest_env]
|
[tool.pytest_env]
|
||||||
PAPERLESS_SECRET_KEY = "test-secret-key-do-not-use-in-production"
|
PAPERLESS_SECRET_KEY = "test-secret-key-do-not-use-in-production"
|
||||||
|
|||||||
+1
-3
@@ -155,9 +155,7 @@
|
|||||||
"builder": "@angular-builders/jest:run",
|
"builder": "@angular-builders/jest:run",
|
||||||
"options": {
|
"options": {
|
||||||
"tsConfig": "tsconfig.spec.json",
|
"tsConfig": "tsconfig.spec.json",
|
||||||
"zoneless": true,
|
"zoneless": false
|
||||||
"watch": false,
|
|
||||||
"coverage": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
|
|||||||
+5
-24
@@ -3,27 +3,6 @@ const angularTemplatePlugin = require('@angular-eslint/eslint-plugin-template')
|
|||||||
const angularTemplateParser = require('@angular-eslint/template-parser')
|
const angularTemplateParser = require('@angular-eslint/template-parser')
|
||||||
const tsParser = require('@typescript-eslint/parser')
|
const tsParser = require('@typescript-eslint/parser')
|
||||||
|
|
||||||
const angularTsRecommendedRules = {
|
|
||||||
'@angular-eslint/contextual-lifecycle': 'error',
|
|
||||||
'@angular-eslint/no-empty-lifecycle-method': 'error',
|
|
||||||
'@angular-eslint/no-input-rename': 'error',
|
|
||||||
'@angular-eslint/no-inputs-metadata-property': 'error',
|
|
||||||
'@angular-eslint/no-output-native': 'error',
|
|
||||||
'@angular-eslint/no-output-on-prefix': 'error',
|
|
||||||
'@angular-eslint/no-output-rename': 'error',
|
|
||||||
'@angular-eslint/no-outputs-metadata-property': 'error',
|
|
||||||
'@angular-eslint/prefer-inject': 'error',
|
|
||||||
'@angular-eslint/prefer-standalone': 'error',
|
|
||||||
'@angular-eslint/use-pipe-transform-interface': 'error',
|
|
||||||
'@angular-eslint/use-lifecycle-interface': 'warn',
|
|
||||||
}
|
|
||||||
const angularTemplateRecommendedRules = {
|
|
||||||
'@angular-eslint/template/banana-in-box': 'error',
|
|
||||||
'@angular-eslint/template/eqeqeq': 'error',
|
|
||||||
'@angular-eslint/template/no-negated-async': 'error',
|
|
||||||
'@angular-eslint/template/prefer-control-flow': 'error',
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = [
|
module.exports = [
|
||||||
{
|
{
|
||||||
ignores: ['projects/**/*', 'src/app/components/common/pdf-viewer/**'],
|
ignores: ['projects/**/*', 'src/app/components/common/pdf-viewer/**'],
|
||||||
@@ -33,6 +12,8 @@ module.exports = [
|
|||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: tsParser,
|
parser: tsParser,
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
|
project: ['tsconfig.json'],
|
||||||
|
createDefaultProgram: true,
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
},
|
},
|
||||||
@@ -41,9 +22,9 @@ module.exports = [
|
|||||||
'@angular-eslint': angularEslintPlugin,
|
'@angular-eslint': angularEslintPlugin,
|
||||||
'@angular-eslint/template': angularTemplatePlugin,
|
'@angular-eslint/template': angularTemplatePlugin,
|
||||||
},
|
},
|
||||||
processor: angularTemplatePlugin.processors['extract-inline-html'],
|
processor: '@angular-eslint/template/extract-inline-html',
|
||||||
rules: {
|
rules: {
|
||||||
...angularTsRecommendedRules,
|
...angularEslintPlugin.configs.recommended.rules,
|
||||||
'@angular-eslint/directive-selector': [
|
'@angular-eslint/directive-selector': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
@@ -71,7 +52,7 @@ module.exports = [
|
|||||||
'@angular-eslint/template': angularTemplatePlugin,
|
'@angular-eslint/template': angularTemplatePlugin,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...angularTemplateRecommendedRules,
|
...angularTemplatePlugin.configs.recommended.rules,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ module.exports = {
|
|||||||
'abstract-paperless-service',
|
'abstract-paperless-service',
|
||||||
],
|
],
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))',
|
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|@angular/common/locales/.*\\.js$))',
|
||||||
],
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
...esmPreset.moduleNameMapper,
|
...esmPreset.moduleNameMapper,
|
||||||
|
|||||||
+188
-242
File diff suppressed because it is too large
Load Diff
+49
-41
@@ -6,22 +6,22 @@
|
|||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"test": "ng test",
|
"test": "ng test --no-watch --coverage",
|
||||||
"lint": "ng lint"
|
"lint": "ng lint"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.0.3",
|
"@angular/cdk": "^21.2.12",
|
||||||
"@angular/common": "~22.0.5",
|
"@angular/common": "~21.2.14",
|
||||||
"@angular/compiler": "~22.0.5",
|
"@angular/compiler": "~21.2.14",
|
||||||
"@angular/core": "~22.0.5",
|
"@angular/core": "~21.2.14",
|
||||||
"@angular/forms": "~22.0.5",
|
"@angular/forms": "~21.2.14",
|
||||||
"@angular/localize": "~22.0.5",
|
"@angular/localize": "~21.2.14",
|
||||||
"@angular/platform-browser": "~22.0.5",
|
"@angular/platform-browser": "~21.2.14",
|
||||||
"@angular/platform-browser-dynamic": "~22.0.5",
|
"@angular/platform-browser-dynamic": "~21.2.14",
|
||||||
"@angular/router": "~22.0.5",
|
"@angular/router": "~21.2.14",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
||||||
"@ng-select/ng-select": "^23.2.0",
|
"@ng-select/ng-select": "^21.8.2",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
@@ -29,47 +29,55 @@
|
|||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^10.1.0",
|
"ngx-color": "^10.1.0",
|
||||||
"ngx-cookie-service": "^22.0.0",
|
"ngx-cookie-service": "^21.3.1",
|
||||||
"ngx-device-detector": "^12.0.0",
|
"ngx-device-detector": "^11.0.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
|
"ngx-ui-tour-ng-bootstrap": "^18.0.0",
|
||||||
"normalize-diacritics": "^5.0.0",
|
"pdfjs-dist": "^5.7.284",
|
||||||
"pdfjs-dist": "^6.0.227",
|
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
"uuid": "^14.0.1"
|
"uuid": "^14.0.0",
|
||||||
|
"zone.js": "^0.16.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "^22.0.1",
|
"@angular-builders/custom-webpack": "^21.0.3",
|
||||||
"@angular-builders/jest": "^22.0.1",
|
"@angular-builders/jest": "^21.0.3",
|
||||||
"@angular-devkit/core": "^22.0.5",
|
"@angular-devkit/core": "^21.2.12",
|
||||||
"@angular-devkit/schematics": "^22.0.5",
|
"@angular-devkit/schematics": "^21.2.12",
|
||||||
"@angular-eslint/builder": "22.0.0",
|
"@angular-eslint/builder": "21.4.0",
|
||||||
"@angular-eslint/eslint-plugin": "22.0.0",
|
"@angular-eslint/eslint-plugin": "21.4.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "22.0.0",
|
"@angular-eslint/eslint-plugin-template": "21.4.0",
|
||||||
"@angular-eslint/schematics": "22.0.0",
|
"@angular-eslint/schematics": "21.4.0",
|
||||||
"@angular-eslint/template-parser": "22.0.0",
|
"@angular-eslint/template-parser": "21.4.0",
|
||||||
"@angular/build": "^22.0.5",
|
"@angular/build": "^21.2.12",
|
||||||
"@angular/cli": "~22.0.5",
|
"@angular/cli": "~21.2.12",
|
||||||
"@angular/compiler-cli": "~22.0.5",
|
"@angular/compiler-cli": "~21.2.14",
|
||||||
"@codecov/webpack-plugin": "^2.0.1",
|
"@codecov/webpack-plugin": "^2.0.1",
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.60.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^26.0.0",
|
"@types/node": "^25.9.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.62.0",
|
"@typescript-eslint/eslint-plugin": "^8.60.0",
|
||||||
"@typescript-eslint/parser": "^8.62.0",
|
"@typescript-eslint/parser": "^8.60.0",
|
||||||
"@typescript-eslint/utils": "^8.62.0",
|
"@typescript-eslint/utils": "^8.60.0",
|
||||||
"eslint": "^10.5.0",
|
"eslint": "^10.4.0",
|
||||||
"jest": "30.4.2",
|
"jest": "30.4.2",
|
||||||
"jest-environment-jsdom": "^30.4.1",
|
"jest-environment-jsdom": "^30.4.1",
|
||||||
"jest-junit": "^17.0.0",
|
"jest-junit": "^17.0.0",
|
||||||
"jest-preset-angular": "^17.0.0",
|
"jest-preset-angular": "^16.1.5",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^5.9.3",
|
||||||
"webpack": "^5.107.2",
|
"webpack": "^5.107.2"
|
||||||
"zone.js": "^0.16.2"
|
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.17.1"
|
"packageManager": "pnpm@10.17.1",
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@parcel/watcher",
|
||||||
|
"canvas",
|
||||||
|
"esbuild",
|
||||||
|
"lmdb",
|
||||||
|
"msgpackr-extract"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2807
-2271
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
|||||||
packages:
|
|
||||||
- "."
|
|
||||||
allowBuilds:
|
|
||||||
"@parcel/watcher": true
|
|
||||||
canvas: true
|
|
||||||
esbuild: true
|
|
||||||
lmdb: true
|
|
||||||
msgpackr-extract: true
|
|
||||||
normalize-diacritics: false
|
|
||||||
unrs-resolver: false
|
|
||||||
onlyBuiltDependencies:
|
|
||||||
- "@parcel/watcher"
|
|
||||||
- canvas
|
|
||||||
- esbuild
|
|
||||||
- lmdb
|
|
||||||
- msgpackr-extract
|
|
||||||
ignoredBuiltDependencies:
|
|
||||||
- normalize-diacritics
|
|
||||||
- unrs-resolver
|
|
||||||
@@ -97,11 +97,11 @@ export class ConfigComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (config) => {
|
next: (config) => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.initialize(config)
|
this.initialize(config)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.toastService.showError($localize`Error retrieving config`, e)
|
this.toastService.showError($localize`Error retrieving config`, e)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -162,20 +162,20 @@ export class ConfigComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public saveConfig() {
|
public saveConfig() {
|
||||||
this.loading.set(true)
|
this.loading = true
|
||||||
this.configService
|
this.configService
|
||||||
.saveConfig(this.configForm.value as PaperlessConfig)
|
.saveConfig(this.configForm.value as PaperlessConfig)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier), first())
|
.pipe(takeUntil(this.unsubscribeNotifier), first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (config) => {
|
next: (config) => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.initialize(config)
|
this.initialize(config)
|
||||||
this.store.next(config)
|
this.store.next(config)
|
||||||
this.settingsService.initializeSettings().subscribe()
|
this.settingsService.initializeSettings().subscribe()
|
||||||
this.toastService.showInfo($localize`Configuration updated`)
|
this.toastService.showInfo($localize`Configuration updated`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`An error occurred updating configuration`,
|
$localize`An error occurred updating configuration`,
|
||||||
e
|
e
|
||||||
@@ -189,20 +189,20 @@ export class ConfigComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public uploadFile(file: File, key: string) {
|
public uploadFile(file: File, key: string) {
|
||||||
this.loading.set(true)
|
this.loading = true
|
||||||
this.configService
|
this.configService
|
||||||
.uploadFile(file, this.configForm.value['id'], key)
|
.uploadFile(file, this.configForm.value['id'], key)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier), first())
|
.pipe(takeUntil(this.unsubscribeNotifier), first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (config) => {
|
next: (config) => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.initialize(config)
|
this.initialize(config)
|
||||||
this.store.next(config)
|
this.store.next(config)
|
||||||
this.settingsService.initializeSettings().subscribe()
|
this.settingsService.initializeSettings().subscribe()
|
||||||
this.toastService.showInfo($localize`File successfully updated`)
|
this.toastService.showInfo($localize`File successfully updated`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`An error occurred uploading file`,
|
$localize`An error occurred uploading file`,
|
||||||
e
|
e
|
||||||
|
|||||||
@@ -11,30 +11,30 @@
|
|||||||
type="number"
|
type="number"
|
||||||
min="100"
|
min="100"
|
||||||
step="100"
|
step="100"
|
||||||
[ngModel]="limit()"
|
[(ngModel)]="limit"
|
||||||
(ngModelChange)="limit.set($event); onLimitChange($event)"
|
(ngModelChange)="onLimitChange($event)"
|
||||||
style="width: 100px;">
|
style="width: 100px;">
|
||||||
<span class="input-group-text text-muted" i18n>lines</span>
|
<span class="input-group-text text-muted" i18n>lines</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch mt-1">
|
<div class="form-check form-switch mt-1">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)">
|
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
||||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<ul ngbNav #nav="ngbNav" [activeId]="activeLog()" (activeIdChange)="activeLog.set($event); reloadLogs()" class="nav-tabs">
|
<ul ngbNav #nav="ngbNav" [(activeId)]="activeLog" (activeIdChange)="reloadLogs()" class="nav-tabs">
|
||||||
@for (logFile of logFiles(); track logFile) {
|
@for (logFile of logFiles; track logFile) {
|
||||||
<li [ngbNavItem]="logFile">
|
<li [ngbNavItem]="logFile">
|
||||||
<a ngbNavLink>
|
<a ngbNavLink>
|
||||||
{{logFile}}.log
|
{{logFile}}.log
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@if (loading || !logFiles().length) {
|
@if (loading || !logFiles.length) {
|
||||||
<div class="ps-2 d-flex align-items-center">
|
<div class="ps-2 d-flex align-items-center">
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
@if (!logFiles().length) {
|
@if (!logFiles.length) {
|
||||||
<ng-container i18n>Loading...</ng-container>
|
<ng-container i18n>Loading...</ng-container>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -42,13 +42,13 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div #logContainer class="bg-dark text-light font-monospace log-container p-3" (scroll)="onScroll()">
|
<div #logContainer class="bg-dark text-light font-monospace log-container p-3" (scroll)="onScroll()">
|
||||||
@if (loading && !logFiles().length) {
|
@if (loading && !logFiles.length) {
|
||||||
<div>
|
<div>
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
<ng-container i18n>Loading...</ng-container>
|
<ng-container i18n>Loading...</ng-container>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
@for (log of logs(); track log) {
|
@for (log of logs; track log) {
|
||||||
<p class="m-0 p-0" [ngClass]="'log-entry-' + log.level">{{log.message}}</p>
|
<p class="m-0 p-0" [ngClass]="'log-entry-' + log.level">{{log.message}}</p>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-secondary jump-to-bottom position-fixed bottom-0 end-0 m-5"
|
class="btn btn-sm btn-secondary jump-to-bottom position-fixed bottom-0 end-0 m-5"
|
||||||
[class.visible]="showJumpToBottom()"
|
[class.visible]="showJumpToBottom"
|
||||||
(click)="scrollToBottom()"
|
(click)="scrollToBottom()"
|
||||||
>
|
>
|
||||||
↓ <span i18n>Jump to bottom</span>
|
↓ <span i18n>Jump to bottom</span>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ describe('LogsComponent', () => {
|
|||||||
throwError(() => new Error('error getting logs'))
|
throwError(() => new Error('error getting logs'))
|
||||||
)
|
)
|
||||||
component.reloadLogs()
|
component.reloadLogs()
|
||||||
expect(component.logs()).toHaveLength(0)
|
expect(component.logs).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should auto refresh, allow toggle', () => {
|
it('should auto refresh, allow toggle', () => {
|
||||||
@@ -97,7 +97,7 @@ describe('LogsComponent', () => {
|
|||||||
jest.advanceTimersByTime(6000)
|
jest.advanceTimersByTime(6000)
|
||||||
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
component.autoRefreshEnabled.set(false)
|
component.autoRefreshEnabled = false
|
||||||
jest.advanceTimersByTime(6000)
|
jest.advanceTimersByTime(6000)
|
||||||
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
@@ -112,9 +112,9 @@ describe('LogsComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should update jump to bottom visibility on scroll', () => {
|
it('should update jump to bottom visibility on scroll', () => {
|
||||||
component.showJumpToBottom.set(false)
|
component.showJumpToBottom = false
|
||||||
jest.spyOn(component as any, 'isNearBottom').mockReturnValue(false)
|
jest.spyOn(component as any, 'isNearBottom').mockReturnValue(false)
|
||||||
component.onScroll()
|
component.onScroll()
|
||||||
expect(component.showJumpToBottom()).toBe(true)
|
expect(component.showJumpToBottom).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -35,17 +34,17 @@ export class LogsComponent
|
|||||||
private logService = inject(LogService)
|
private logService = inject(LogService)
|
||||||
private changedetectorRef = inject(ChangeDetectorRef)
|
private changedetectorRef = inject(ChangeDetectorRef)
|
||||||
|
|
||||||
readonly logs = signal<Array<{ message: string; level: number }>>([])
|
public logs: Array<{ message: string; level: number }> = []
|
||||||
|
|
||||||
readonly logFiles = signal<string[]>([])
|
public logFiles: string[] = []
|
||||||
|
|
||||||
readonly activeLog = signal<string>(undefined)
|
public activeLog: string
|
||||||
|
|
||||||
readonly autoRefreshEnabled = signal<boolean>(true)
|
public autoRefreshEnabled: boolean = true
|
||||||
|
|
||||||
readonly limit = signal<number>(5000)
|
public limit: number = 5000
|
||||||
|
|
||||||
readonly showJumpToBottom = signal<boolean>(false)
|
public showJumpToBottom = false
|
||||||
|
|
||||||
private readonly limitChange$ = new Subject<number>()
|
private readonly limitChange$ = new Subject<number>()
|
||||||
|
|
||||||
@@ -60,15 +59,15 @@ export class LogsComponent
|
|||||||
.list()
|
.list()
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
this.logFiles.set(result)
|
this.logFiles = result
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
if (this.logFiles().length > 0) {
|
if (this.logFiles.length > 0) {
|
||||||
this.activeLog.set(this.logFiles()[0])
|
this.activeLog = this.logFiles[0]
|
||||||
this.reloadLogs()
|
this.reloadLogs()
|
||||||
}
|
}
|
||||||
timer(5000, 5000)
|
timer(5000, 5000)
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(() => this.autoRefreshEnabled()),
|
filter(() => this.autoRefreshEnabled),
|
||||||
takeUntil(this.unsubscribeNotifier)
|
takeUntil(this.unsubscribeNotifier)
|
||||||
)
|
)
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
@@ -86,19 +85,19 @@ export class LogsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
reloadLogs() {
|
reloadLogs() {
|
||||||
this.loading.set(true)
|
this.loading = true
|
||||||
const shouldStickToBottom = this.isNearBottom()
|
const shouldStickToBottom = this.isNearBottom()
|
||||||
this.logService
|
this.logService
|
||||||
.get(this.activeLog(), this.limit())
|
.get(this.activeLog, this.limit)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
const parsed = this.parseLogsWithLevel(result)
|
const parsed = this.parseLogsWithLevel(result)
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
parsed.length !== this.logs().length ||
|
parsed.length !== this.logs.length ||
|
||||||
parsed.some((log, idx) => {
|
parsed.some((log, idx) => {
|
||||||
const current = this.logs()[idx]
|
const current = this.logs[idx]
|
||||||
return (
|
return (
|
||||||
!current ||
|
!current ||
|
||||||
current.message !== log.message ||
|
current.message !== log.message ||
|
||||||
@@ -106,16 +105,16 @@ export class LogsComponent
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
this.logs.set(parsed)
|
this.logs = parsed
|
||||||
if (shouldStickToBottom) {
|
if (shouldStickToBottom) {
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
}
|
}
|
||||||
this.showJumpToBottom.set(!shouldStickToBottom)
|
this.showJumpToBottom = !shouldStickToBottom
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.logs.set([])
|
this.logs = []
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -150,7 +149,7 @@ export class LogsComponent
|
|||||||
}
|
}
|
||||||
this.changedetectorRef.detectChanges()
|
this.changedetectorRef.detectChanges()
|
||||||
viewport.scrollTop = viewport.scrollHeight
|
viewport.scrollTop = viewport.scrollHeight
|
||||||
this.showJumpToBottom.set(false)
|
this.showJumpToBottom = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private isNearBottom(): boolean {
|
private isNearBottom(): boolean {
|
||||||
@@ -163,6 +162,6 @@ export class LogsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
onScroll(): void {
|
onScroll(): void {
|
||||||
this.showJumpToBottom.set(!this.isNearBottom())
|
this.showJumpToBottom = !this.isNearBottom()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ describe('SettingsComponent', () => {
|
|||||||
viewportScroller = TestBed.inject(ViewportScroller)
|
viewportScroller = TestBed.inject(ViewportScroller)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set(users[0])
|
settingsService.currentUser = users[0]
|
||||||
userService = TestBed.inject(UserService)
|
userService = TestBed.inject(UserService)
|
||||||
permissionsService = TestBed.inject(PermissionsService)
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="visibleTasks.length === 0">
|
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="visibleTasks.length === 0">
|
||||||
<i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}}
|
<i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissAllTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="totalTasks() === 0">
|
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissAllTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="totalTasks === 0">
|
||||||
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
|
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<div class="form-check form-switch mb-0 ms-2">
|
<div class="form-check form-switch mb-0 ms-2">
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
|
|
||||||
<ngb-pagination
|
<ngb-pagination
|
||||||
[pageSize]="pageSize"
|
[pageSize]="pageSize"
|
||||||
[collectionSize]="totalTasks()"
|
[collectionSize]="totalTasks"
|
||||||
[page]="page"
|
[page]="page"
|
||||||
[maxSize]="5"
|
[maxSize]="5"
|
||||||
[rotate]="true"
|
[rotate]="true"
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ describe('TasksComponent', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
req.flush({ count: 2, results: [tasks[0], tasks[1]] })
|
req.flush({ count: 2, results: [tasks[0], tasks[1]] })
|
||||||
expect(component.totalTasks()).toBe(2)
|
expect(component.totalTasks).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should apply task type and trigger source filters to the server-side task query', () => {
|
it('should apply task type and trigger source filters to the server-side task query', () => {
|
||||||
@@ -435,8 +435,8 @@ describe('TasksComponent', () => {
|
|||||||
.flush(pageTwoTasks)
|
.flush(pageTwoTasks)
|
||||||
|
|
||||||
expect(component.page).toBe(2)
|
expect(component.page).toBe(2)
|
||||||
expect(component.totalTasks()).toBe(30)
|
expect(component.totalTasks).toBe(30)
|
||||||
expect(component.pagedTasks()).toEqual([tasks[0]])
|
expect(component.pagedTasks).toEqual([tasks[0]])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not replace section counts with current-page counts', () => {
|
it('should not replace section counts with current-page counts', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { JsonPipe, NgTemplateOutlet } from '@angular/common'
|
import { JsonPipe, NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'
|
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { Router, RouterLink } from '@angular/router'
|
import { Router, RouterLink } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
@@ -166,14 +166,14 @@ export class TasksComponent
|
|||||||
public autoRefreshEnabled: boolean = true
|
public autoRefreshEnabled: boolean = true
|
||||||
public readonly pageSize = 25
|
public readonly pageSize = 25
|
||||||
public page: number = 1
|
public page: number = 1
|
||||||
readonly totalTasks = signal(0)
|
public totalTasks: number = 0
|
||||||
readonly sectionCounts = signal<Record<TaskSection, number>>({
|
public sectionCounts: Record<TaskSection, number> = {
|
||||||
[TaskSection.All]: 0,
|
[TaskSection.All]: 0,
|
||||||
[TaskSection.NeedsAttention]: 0,
|
[TaskSection.NeedsAttention]: 0,
|
||||||
[TaskSection.InProgress]: 0,
|
[TaskSection.InProgress]: 0,
|
||||||
[TaskSection.Completed]: 0,
|
[TaskSection.Completed]: 0,
|
||||||
})
|
}
|
||||||
readonly pagedTasks = signal<PaperlessTask[]>([])
|
public pagedTasks: PaperlessTask[] = []
|
||||||
public selectedSection: TaskSection = TaskSection.All
|
public selectedSection: TaskSection = TaskSection.All
|
||||||
public selectedTaskType: PaperlessTaskType | null = null
|
public selectedTaskType: PaperlessTaskType | null = null
|
||||||
public selectedTriggerSource: PaperlessTaskTriggerSource | null = null
|
public selectedTriggerSource: PaperlessTaskTriggerSource | null = null
|
||||||
@@ -346,7 +346,7 @@ export class TasksComponent
|
|||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.title = $localize`Confirm Dismiss All`
|
modal.componentInstance.title = $localize`Confirm Dismiss All`
|
||||||
modal.componentInstance.messageBold = $localize`Dismiss all ${this.totalTasks()} tasks?`
|
modal.componentInstance.messageBold = $localize`Dismiss all ${this.totalTasks} tasks?`
|
||||||
modal.componentInstance.btnClass = 'btn-warning'
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
modal.componentInstance.btnCaption = $localize`Dismiss`
|
modal.componentInstance.btnCaption = $localize`Dismiss`
|
||||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
@@ -465,7 +465,7 @@ export class TasksComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
tasksForSection(section: TaskSection): PaperlessTask[] {
|
tasksForSection(section: TaskSection): PaperlessTask[] {
|
||||||
let tasks = this.pagedTasks().filter((task) =>
|
let tasks = this.pagedTasks.filter((task) =>
|
||||||
this.taskBelongsToSection(task, section)
|
this.taskBelongsToSection(task, section)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -477,14 +477,7 @@ export class TasksComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
sectionCount(section: TaskSection): number {
|
sectionCount(section: TaskSection): number {
|
||||||
return this.sectionCounts()[section]
|
return this.sectionCounts[section]
|
||||||
}
|
|
||||||
|
|
||||||
private setSectionCount(section: TaskSection, count: number) {
|
|
||||||
this.sectionCounts.update((counts) => ({
|
|
||||||
...counts,
|
|
||||||
[section]: count,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sectionShowsResults(section: TaskSection): boolean {
|
sectionShowsResults(section: TaskSection): boolean {
|
||||||
@@ -659,7 +652,7 @@ export class TasksComponent
|
|||||||
? this.sections
|
? this.sections
|
||||||
: [this.selectedSection]
|
: [this.selectedSection]
|
||||||
|
|
||||||
return this.pagedTasks().filter(
|
return this.pagedTasks.filter(
|
||||||
(task) =>
|
(task) =>
|
||||||
sections.some((section) => this.taskBelongsToSection(task, section)) &&
|
sections.some((section) => this.taskBelongsToSection(task, section)) &&
|
||||||
this.taskMatchesFilters(task, { taskType, triggerSource })
|
this.taskMatchesFilters(task, { taskType, triggerSource })
|
||||||
@@ -671,12 +664,10 @@ export class TasksComponent
|
|||||||
.statusCounts(this.getParamsForSection(TaskSection.All))
|
.statusCounts(this.getParamsForSection(TaskSection.All))
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((counts) => {
|
.subscribe((counts) => {
|
||||||
this.sectionCounts.set({
|
this.sectionCounts[TaskSection.All] = counts.all
|
||||||
[TaskSection.All]: counts.all,
|
this.sectionCounts[TaskSection.NeedsAttention] = counts.needs_attention
|
||||||
[TaskSection.NeedsAttention]: counts.needs_attention,
|
this.sectionCounts[TaskSection.InProgress] = counts.in_progress
|
||||||
[TaskSection.InProgress]: counts.in_progress,
|
this.sectionCounts[TaskSection.Completed] = counts.completed
|
||||||
[TaskSection.Completed]: counts.completed,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,7 +723,7 @@ export class TasksComponent
|
|||||||
|
|
||||||
this.reloadSectionCounts()
|
this.reloadSectionCounts()
|
||||||
|
|
||||||
this.loading.set(true)
|
this.loading = true
|
||||||
this.tasksService
|
this.tasksService
|
||||||
.list(
|
.list(
|
||||||
this.page,
|
this.page,
|
||||||
@@ -742,24 +733,24 @@ export class TasksComponent
|
|||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.pagedTasks.set(result.results)
|
this.pagedTasks = result.results
|
||||||
this.totalTasks.set(result.count)
|
this.totalTasks = result.count
|
||||||
this.setSectionCount(TaskSection.All, result.count)
|
this.sectionCounts[TaskSection.All] = result.count
|
||||||
if (this.selectedSection !== TaskSection.All) {
|
if (this.selectedSection !== TaskSection.All) {
|
||||||
this.setSectionCount(this.selectedSection, result.count)
|
this.sectionCounts[this.selectedSection] = result.count
|
||||||
}
|
}
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
if (
|
if (
|
||||||
this.page > 1 &&
|
this.page > 1 &&
|
||||||
this.pagedTasks().length === 0 &&
|
this.pagedTasks.length === 0 &&
|
||||||
this.totalTasks() > 0
|
this.totalTasks > 0
|
||||||
) {
|
) {
|
||||||
this.page -= 1
|
this.page -= 1
|
||||||
this.reloadPage()
|
this.reloadPage()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,22 @@
|
|||||||
info="Manage trashed documents that are pending deletion."
|
info="Manage trashed documents that are pending deletion."
|
||||||
i18n-info
|
i18n-info
|
||||||
infoLink="usage/#document-trash">
|
infoLink="usage/#document-trash">
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedDocuments().size === 0">
|
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedDocuments.size === 0">
|
||||||
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container>
|
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="restoreAll(selectedDocuments())" [disabled]="selectedDocuments().size === 0">
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="restoreAll(selectedDocuments)" [disabled]="selectedDocuments.size === 0">
|
||||||
<i-bs name="arrow-counterclockwise" class="me-1"></i-bs><ng-container i18n>Restore selected</ng-container>
|
<i-bs name="arrow-counterclockwise" class="me-1"></i-bs><ng-container i18n>Restore selected</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash(selectedDocuments())" [disabled]="selectedDocuments().size === 0">
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash(selectedDocuments)" [disabled]="selectedDocuments.size === 0">
|
||||||
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete selected</ng-container>
|
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete selected</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash()" [disabled]="documentsInTrash().length === 0">
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash()" [disabled]="documentsInTrash.length === 0">
|
||||||
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Empty trash</ng-container>
|
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Empty trash</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="totalDocuments()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="totalDocuments" [(page)]="page" [maxSize]="5" (pageChange)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card border table-responsive mb-3">
|
<div class="card border table-responsive mb-3">
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col">
|
<th scope="col">
|
||||||
<div class="form-check m-0 ms-2 me-n2">
|
<div class="form-check m-0 ms-2 me-n2">
|
||||||
<input type="checkbox" class="form-check-input" id="all-objects" [ngModel]="allToggled()" (ngModelChange)="allToggled.set($event)" [disabled]="documentsInTrash().length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="allToggled" [disabled]="documentsInTrash.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
||||||
<label class="form-check-label" for="all-objects"></label>
|
<label class="form-check-label" for="all-objects"></label>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@if (loading()) {
|
@if (loading) {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
@@ -46,11 +46,11 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@for (document of documentsInTrash(); track document.id) {
|
@for (document of documentsInTrash; track document.id) {
|
||||||
<tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()" class="data-row fade" [class.show]="show">
|
<tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()" class="data-row fade" [class.show]="show">
|
||||||
<td>
|
<td>
|
||||||
<div class="form-check m-0 ms-2 me-n2">
|
<div class="form-check m-0 ms-2 me-n2">
|
||||||
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments().has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">
|
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments.has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">
|
||||||
<label class="form-check-label" for="{{document.id}}"></label>
|
<label class="form-check-label" for="{{document.id}}"></label>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -91,13 +91,13 @@
|
|||||||
@if (!loading) {
|
@if (!loading) {
|
||||||
<div class="d-flex mb-2">
|
<div class="d-flex mb-2">
|
||||||
<div>
|
<div>
|
||||||
<ng-container i18n>{totalDocuments(), plural, =1 {One document in trash} other {{{totalDocuments() || 0}} total documents in trash}}</ng-container>
|
<ng-container i18n>{totalDocuments, plural, =1 {One document in trash} other {{{totalDocuments || 0}} total documents in trash}}</ng-container>
|
||||||
@if (selectedDocuments().size > 0) {
|
@if (selectedDocuments.size > 0) {
|
||||||
({{selectedDocuments().size}} selected)
|
({{selectedDocuments.size}} selected)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (documentsInTrash().length > 20) {
|
@if (documentsInTrash.length > 20) {
|
||||||
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="totalDocuments()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="totalDocuments" [(page)]="page" [maxSize]="5" (pageChange)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ describe('TrashComponent', () => {
|
|||||||
component.reload()
|
component.reload()
|
||||||
jest.advanceTimersByTime(100)
|
jest.advanceTimersByTime(100)
|
||||||
expect(trashSpy).toHaveBeenCalled()
|
expect(trashSpy).toHaveBeenCalled()
|
||||||
expect(component.documentsInTrash()).toEqual(documentsInTrash)
|
expect(component.documentsInTrash).toEqual(documentsInTrash)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support delete document, show error if needed', () => {
|
it('should support delete document, show error if needed', () => {
|
||||||
@@ -179,8 +179,8 @@ describe('TrashComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support toggle all items in view', () => {
|
it('should support toggle all items in view', () => {
|
||||||
component.documentsInTrash.set(documentsInTrash)
|
component.documentsInTrash = documentsInTrash
|
||||||
expect(component.selectedDocuments().size).toEqual(0)
|
expect(component.selectedDocuments.size).toEqual(0)
|
||||||
const toggleAllSpy = jest.spyOn(component, 'toggleAll')
|
const toggleAllSpy = jest.spyOn(component, 'toggleAll')
|
||||||
const checkButton = fixture.debugElement.queryAll(
|
const checkButton = fixture.debugElement.queryAll(
|
||||||
By.css('input.form-check-input')
|
By.css('input.form-check-input')
|
||||||
@@ -189,21 +189,21 @@ describe('TrashComponent', () => {
|
|||||||
checkButton.nativeElement.checked = true
|
checkButton.nativeElement.checked = true
|
||||||
checkButton.nativeElement.dispatchEvent(new Event('click'))
|
checkButton.nativeElement.dispatchEvent(new Event('click'))
|
||||||
expect(toggleAllSpy).toHaveBeenCalled()
|
expect(toggleAllSpy).toHaveBeenCalled()
|
||||||
expect(component.selectedDocuments().size).toEqual(documentsInTrash.length)
|
expect(component.selectedDocuments.size).toEqual(documentsInTrash.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support toggle item', () => {
|
it('should support toggle item', () => {
|
||||||
component.selectedDocuments.set(new Set([1]))
|
component.selectedDocuments = new Set([1])
|
||||||
component.toggleSelected(documentsInTrash[0])
|
component.toggleSelected(documentsInTrash[0])
|
||||||
expect(component.selectedDocuments().size).toEqual(0)
|
expect(component.selectedDocuments.size).toEqual(0)
|
||||||
component.toggleSelected(documentsInTrash[0])
|
component.toggleSelected(documentsInTrash[0])
|
||||||
expect(component.selectedDocuments().size).toEqual(1)
|
expect(component.selectedDocuments.size).toEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support clear selection', () => {
|
it('should support clear selection', () => {
|
||||||
component.selectedDocuments.set(new Set([1]))
|
component.selectedDocuments = new Set([1])
|
||||||
component.clearSelection()
|
component.clearSelection()
|
||||||
expect(component.selectedDocuments().size).toEqual(0)
|
expect(component.selectedDocuments.size).toEqual(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should correctly display days remaining', () => {
|
it('should correctly display days remaining', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnDestroy, inject, signal } from '@angular/core'
|
import { Component, OnDestroy, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { takeUntil, tap } from 'rxjs'
|
import { delay, takeUntil, tap } from 'rxjs'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
@@ -42,11 +42,11 @@ export class TrashComponent
|
|||||||
private settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
private router = inject(Router)
|
private router = inject(Router)
|
||||||
|
|
||||||
readonly documentsInTrash = signal<Document[]>([])
|
public documentsInTrash: Document[] = []
|
||||||
readonly selectedDocuments = signal<Set<number>>(new Set())
|
public selectedDocuments: Set<number> = new Set()
|
||||||
readonly allToggled = signal(false)
|
public allToggled: boolean = false
|
||||||
readonly page = signal(1)
|
public page: number = 1
|
||||||
readonly totalDocuments = signal<number>(undefined)
|
public totalDocuments: number
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -54,19 +54,20 @@ export class TrashComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
reload() {
|
reload() {
|
||||||
this.loading.set(true)
|
this.loading = true
|
||||||
this.trashService
|
this.trashService
|
||||||
.getTrash(this.page())
|
.getTrash(this.page)
|
||||||
.pipe(
|
.pipe(
|
||||||
tap((r) => {
|
tap((r) => {
|
||||||
this.documentsInTrash.set(r.results)
|
this.documentsInTrash = r.results
|
||||||
this.totalDocuments.set(r.count)
|
this.totalDocuments = r.count
|
||||||
this.selectedDocuments.set(new Set())
|
this.selectedDocuments.clear()
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
})
|
}),
|
||||||
|
delay(100)
|
||||||
)
|
)
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.show.set(true)
|
this.show = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ export class TrashComponent
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo($localize`Document(s) deleted`)
|
this.toastService.showInfo($localize`Document(s) deleted`)
|
||||||
this.allToggled.set(false)
|
this.allToggled = false
|
||||||
modal.close()
|
modal.close()
|
||||||
this.reload()
|
this.reload()
|
||||||
},
|
},
|
||||||
@@ -164,7 +165,7 @@ export class TrashComponent
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo($localize`Document(s) restored`)
|
this.toastService.showInfo($localize`Document(s) restored`)
|
||||||
this.allToggled.set(false)
|
this.allToggled = false
|
||||||
this.reload()
|
this.reload()
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
@@ -178,29 +179,24 @@ export class TrashComponent
|
|||||||
|
|
||||||
toggleAll(event: PointerEvent) {
|
toggleAll(event: PointerEvent) {
|
||||||
if ((event.target as HTMLInputElement).checked) {
|
if ((event.target as HTMLInputElement).checked) {
|
||||||
this.selectedDocuments.set(
|
this.selectedDocuments = new Set(this.documentsInTrash.map((t) => t.id))
|
||||||
new Set(this.documentsInTrash().map((t) => t.id))
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSelected(object: Document) {
|
toggleSelected(object: Document) {
|
||||||
const selectedDocuments = new Set(this.selectedDocuments())
|
this.selectedDocuments.has(object.id)
|
||||||
selectedDocuments.has(object.id)
|
? this.selectedDocuments.delete(object.id)
|
||||||
? selectedDocuments.delete(object.id)
|
: this.selectedDocuments.add(object.id)
|
||||||
: selectedDocuments.add(object.id)
|
|
||||||
this.selectedDocuments.set(selectedDocuments)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
this.allToggled.set(false)
|
this.allToggled = false
|
||||||
this.selectedDocuments.set(new Set())
|
this.selectedDocuments.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
getDaysRemaining(document: Document): number {
|
getDaysRemaining(document: Document): number {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
||||||
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
||||||
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
>
|
>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
@if (canViewUsers && users()) {
|
@if (canViewUsers && users) {
|
||||||
<h4 class="d-flex">
|
<h4 class="d-flex">
|
||||||
<ng-container i18n>Users</ng-container>
|
<ng-container i18n>Users</ng-container>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
|
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="col" i18n>Actions</div>
|
<div class="col" i18n>Actions</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@for (user of users(); track user) {
|
@for (user of users; track user) {
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col d-flex align-items-center" [class.opacity-50]="!user.is_active"><button class="btn btn-link p-0 text-start" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
|
<div class="col d-flex align-items-center" [class.opacity-50]="!user.is_active"><button class="btn btn-link p-0 text-start" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (canViewGroups && groups()) {
|
@if (canViewGroups && groups) {
|
||||||
<h4 class="mt-4 d-flex">
|
<h4 class="mt-4 d-flex">
|
||||||
<ng-container i18n>Groups</ng-container>
|
<ng-container i18n>Groups</ng-container>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
<div class="col" i18n>Actions</div>
|
<div class="col" i18n>Actions</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@for (group of groups(); track group) {
|
@for (group of groups; track group) {
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
||||||
@@ -80,13 +80,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@if (groups().length === 0) {
|
@if (groups.length === 0) {
|
||||||
<li class="list-group-item" i18n>No groups defined</li>
|
<li class="list-group-item" i18n>No groups defined</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if ((canViewUsers && !users()) || (canViewGroups && !groups())) {
|
@if ((canViewUsers && !users) || (canViewGroups && !groups)) {
|
||||||
<div>
|
<div>
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||||
<div class="visually-hidden" i18n>Loading...</div>
|
<div class="visually-hidden" i18n>Loading...</div>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
fixture = TestBed.createComponent(UsersAndGroupsComponent)
|
fixture = TestBed.createComponent(UsersAndGroupsComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set(users[0])
|
settingsService.currentUser = users[0]
|
||||||
userService = TestBed.inject(UserService)
|
userService = TestBed.inject(UserService)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
@@ -109,7 +109,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
editDialog.failed.emit()
|
editDialog.failed.emit()
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
settingsService.currentUser.set(users[1]) // simulate logged in as different user
|
settingsService.currentUser = users[1] // simulate logged in as different user
|
||||||
editDialog.succeeded.emit(users[0])
|
editDialog.succeeded.emit(users[0])
|
||||||
expect(toastInfoSpy).toHaveBeenCalledWith(
|
expect(toastInfoSpy).toHaveBeenCalledWith(
|
||||||
`Saved user "${users[0].username}".`
|
`Saved user "${users[0].username}".`
|
||||||
@@ -148,7 +148,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
.mockImplementation(() => {})
|
.mockImplementation(() => {})
|
||||||
const editDialog = modal.componentInstance as UserEditDialogComponent
|
const editDialog = modal.componentInstance as UserEditDialogComponent
|
||||||
editDialog.passwordIsSet = true
|
editDialog.passwordIsSet = true
|
||||||
settingsService.currentUser.set(users[0]) // simulate logged in as same user
|
settingsService.currentUser = users[0] // simulate logged in as same user
|
||||||
editDialog.succeeded.emit(users[0])
|
editDialog.succeeded.emit(users[0])
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(2600)
|
tick(2600)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
|
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, first, takeUntil } from 'rxjs'
|
import { Subject, first, takeUntil } from 'rxjs'
|
||||||
@@ -43,8 +43,8 @@ export class UsersAndGroupsComponent
|
|||||||
permissionsService = inject(PermissionsService)
|
permissionsService = inject(PermissionsService)
|
||||||
private settings = inject(SettingsService)
|
private settings = inject(SettingsService)
|
||||||
|
|
||||||
readonly users = signal<User[]>(null)
|
users: User[]
|
||||||
readonly groups = signal<Group[]>(null)
|
groups: Group[]
|
||||||
|
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export class UsersAndGroupsComponent
|
|||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (r) => {
|
next: (r) => {
|
||||||
this.users.set(r.results)
|
this.users = r.results
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.toastService.showError($localize`Error retrieving users`, e)
|
this.toastService.showError($localize`Error retrieving users`, e)
|
||||||
@@ -83,7 +83,7 @@ export class UsersAndGroupsComponent
|
|||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (r) => {
|
next: (r) => {
|
||||||
this.groups.set(r.results)
|
this.groups = r.results
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.toastService.showError($localize`Error retrieving groups`, e)
|
this.toastService.showError($localize`Error retrieving groups`, e)
|
||||||
@@ -109,7 +109,7 @@ export class UsersAndGroupsComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((newUser: User) => {
|
.subscribe((newUser: User) => {
|
||||||
if (
|
if (
|
||||||
newUser.id === this.settings.currentUser().id &&
|
newUser.id === this.settings.currentUser.id &&
|
||||||
(modal.componentInstance as UserEditDialogComponent).passwordIsSet
|
(modal.componentInstance as UserEditDialogComponent).passwordIsSet
|
||||||
) {
|
) {
|
||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
@@ -125,7 +125,7 @@ export class UsersAndGroupsComponent
|
|||||||
$localize`Saved user "${newUser.username}".`
|
$localize`Saved user "${newUser.username}".`
|
||||||
)
|
)
|
||||||
this.usersService.listAll().subscribe((r) => {
|
this.usersService.listAll().subscribe((r) => {
|
||||||
this.users.set(r.results)
|
this.users = r.results
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -152,7 +152,7 @@ export class UsersAndGroupsComponent
|
|||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showInfo($localize`Deleted user "${user.username}"`)
|
this.toastService.showInfo($localize`Deleted user "${user.username}"`)
|
||||||
this.usersService.listAll().subscribe((r) => {
|
this.usersService.listAll().subscribe((r) => {
|
||||||
this.users.set(r.results)
|
this.users = r.results
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
@@ -179,7 +179,7 @@ export class UsersAndGroupsComponent
|
|||||||
.subscribe((newGroup) => {
|
.subscribe((newGroup) => {
|
||||||
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
|
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
|
||||||
this.groupsService.listAll().subscribe((r) => {
|
this.groupsService.listAll().subscribe((r) => {
|
||||||
this.groups.set(r.results)
|
this.groups = r.results
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
modal.componentInstance.failed
|
modal.componentInstance.failed
|
||||||
@@ -205,7 +205,7 @@ export class UsersAndGroupsComponent
|
|||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showInfo($localize`Deleted group "${group.name}"`)
|
this.toastService.showInfo($localize`Deleted group "${group.name}"`)
|
||||||
this.groupsService.listAll().subscribe((r) => {
|
this.groupsService.listAll().subscribe((r) => {
|
||||||
this.groups.set(r.results)
|
this.groups = r.results
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
@@ -219,6 +219,6 @@ export class UsersAndGroupsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
getGroupName(id: number): string {
|
getGroupName(id: number): string {
|
||||||
return this.groups()?.find((g) => g.id === id)?.name ?? ''
|
return this.groups?.find((g) => g.id === id)?.name ?? ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<nav class="navbar navbar-dark fixed-top bg-primary flex-md-nowrap p-0 shadow-sm">
|
<nav class="navbar navbar-dark fixed-top bg-primary flex-md-nowrap p-0 shadow-sm">
|
||||||
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
||||||
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
||||||
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
(click)="mobileSearchHidden = false; isMenuCollapsed = !isMenuCollapsed">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
|
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
|
||||||
[class.mobile-hidden]="mobileSearchHidden()">
|
[class.mobile-hidden]="mobileSearchHidden">
|
||||||
<div class="col-12 col-md-7">
|
<div class="col-12 col-md-7">
|
||||||
<pngx-global-search></pngx-global-search>
|
<pngx-global-search></pngx-global-search>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
||||||
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating()"
|
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating"
|
||||||
[ngbCollapse]="isMenuCollapsed()">
|
[ngbCollapse]="isMenuCollapsed">
|
||||||
@if (canSaveSettings) {
|
@if (canSaveSettings) {
|
||||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
|
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
|
||||||
@if (slimSidebarEnabled) {
|
@if (slimSidebarEnabled) {
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||||
@for (view of savedViewService.sidebarViews; track view.id) {
|
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews()"
|
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
|
||||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||||
(cdkDragEnded)="onDragEnd($event)">
|
(cdkDragEnded)="onDragEnd($event)">
|
||||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
<a class="nav-link" routerLink="view/{{view.id}}"
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
|
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarSavedViews()) {
|
@if (settingsService.organizingSidebarSavedViews) {
|
||||||
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
||||||
<i-bs name="grip-vertical"></i-bs>
|
<i-bs name="grip-vertical"></i-bs>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,10 +329,10 @@
|
|||||||
{{ versionString }}
|
{{ versionString }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) {
|
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
|
||||||
<div class="version-check">
|
<div class="version-check">
|
||||||
<ng-template #updateAvailablePopContent>
|
<ng-template #updateAvailablePopContent>
|
||||||
<span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is
|
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
|
||||||
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template #updateCheckingNotEnabledPopContent>
|
<ng-template #updateCheckingNotEnabledPopContent>
|
||||||
@@ -350,13 +350,13 @@
|
|||||||
</p>
|
</p>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@if (settingsService.updateCheckingIsSet) {
|
@if (settingsService.updateCheckingIsSet) {
|
||||||
@if (appRemoteVersion().update_available) {
|
@if (appRemoteVersion.update_available) {
|
||||||
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
|
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||||
href="https://github.com/paperless-ngx/paperless-ngx/releases"
|
href="https://github.com/paperless-ngx/paperless-ngx/releases"
|
||||||
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
|
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
|
||||||
container="body">
|
container="body">
|
||||||
<i-bs width="1.2em" height="1.2em" name="info-circle" class="me-1"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="info-circle" class="me-1"></i-bs>
|
||||||
@if (appRemoteVersion()?.update_available) {
|
@if (appRemoteVersion?.update_available) {
|
||||||
<ng-container i18n>Update available</ng-container>
|
<ng-container i18n>Update available</ng-container>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
@@ -377,7 +377,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden()"
|
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden"
|
||||||
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
|
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ describe('AppFrameComponent', () => {
|
|||||||
const saveSettingSpy = jest.spyOn(settingsService, 'set')
|
const saveSettingSpy = jest.spyOn(settingsService, 'set')
|
||||||
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
||||||
expect(component.slimSidebarEnabled).toBeFalsy()
|
expect(component.slimSidebarEnabled).toBeFalsy()
|
||||||
expect(component.slimSidebarAnimating()).toBeFalsy()
|
expect(component.slimSidebarAnimating).toBeFalsy()
|
||||||
component.toggleSlimSidebar()
|
component.toggleSlimSidebar()
|
||||||
const requests = httpTestingController.match(
|
const requests = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}ui_settings/`
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
@@ -259,9 +259,9 @@ describe('AppFrameComponent', () => {
|
|||||||
requests[0].request.body.settings.attributes_sections_collapsed
|
requests[0].request.body.settings.attributes_sections_collapsed
|
||||||
).toEqual(['attributes'])
|
).toEqual(['attributes'])
|
||||||
requests[0].flush({ success: true })
|
requests[0].flush({ success: true })
|
||||||
expect(component.slimSidebarAnimating()).toBeTruthy()
|
expect(component.slimSidebarAnimating).toBeTruthy()
|
||||||
tick(200)
|
tick(200)
|
||||||
expect(component.slimSidebarAnimating()).toBeFalsy()
|
expect(component.slimSidebarAnimating).toBeFalsy()
|
||||||
expect(component.slimSidebarEnabled).toBeTruthy()
|
expect(component.slimSidebarEnabled).toBeTruthy()
|
||||||
expect(saveSettingSpy).toHaveBeenCalledWith(
|
expect(saveSettingSpy).toHaveBeenCalledWith(
|
||||||
SETTINGS_KEYS.SLIM_SIDEBAR,
|
SETTINGS_KEYS.SLIM_SIDEBAR,
|
||||||
@@ -291,9 +291,9 @@ describe('AppFrameComponent', () => {
|
|||||||
fixture.nativeElement as HTMLDivElement
|
fixture.nativeElement as HTMLDivElement
|
||||||
).querySelector('button[data-toggle=collapse]')
|
).querySelector('button[data-toggle=collapse]')
|
||||||
button.dispatchEvent(new MouseEvent('click'))
|
button.dispatchEvent(new MouseEvent('click'))
|
||||||
expect(component.isMenuCollapsed()).toBeFalsy()
|
expect(component.isMenuCollapsed).toBeFalsy()
|
||||||
component.closeMenu()
|
component.closeMenu()
|
||||||
expect(component.isMenuCollapsed()).toBeTruthy()
|
expect(component.isMenuCollapsed).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should hide mobile search when scrolling down and show it when scrolling up', () => {
|
it('should hide mobile search when scrolling down and show it when scrolling up', () => {
|
||||||
@@ -308,14 +308,14 @@ describe('AppFrameComponent', () => {
|
|||||||
value: 40,
|
value: 40,
|
||||||
})
|
})
|
||||||
component.onWindowScroll()
|
component.onWindowScroll()
|
||||||
expect(component.mobileSearchHidden()).toBe(true)
|
expect(component.mobileSearchHidden).toBe(true)
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'scrollY', {
|
Object.defineProperty(globalThis, 'scrollY', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: 0,
|
value: 0,
|
||||||
})
|
})
|
||||||
component.onWindowScroll()
|
component.onWindowScroll()
|
||||||
expect(component.mobileSearchHidden()).toBe(false)
|
expect(component.mobileSearchHidden).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep mobile search visible on desktop scroll or resize', () => {
|
it('should keep mobile search visible on desktop scroll or resize', () => {
|
||||||
@@ -323,13 +323,13 @@ describe('AppFrameComponent', () => {
|
|||||||
value: 1024,
|
value: 1024,
|
||||||
})
|
})
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.mobileSearchHidden.set(true)
|
component.mobileSearchHidden = true
|
||||||
|
|
||||||
component.onWindowScroll()
|
component.onWindowScroll()
|
||||||
|
|
||||||
expect(component.mobileSearchHidden()).toBe(false)
|
expect(component.mobileSearchHidden).toBe(false)
|
||||||
|
|
||||||
component.mobileSearchHidden.set(true)
|
component.mobileSearchHidden = true
|
||||||
component.onWindowResize()
|
component.onWindowResize()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -338,7 +338,7 @@ describe('AppFrameComponent', () => {
|
|||||||
value: 767,
|
value: 767,
|
||||||
})
|
})
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.isMenuCollapsed.set(false)
|
component.isMenuCollapsed = false
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'scrollY', {
|
Object.defineProperty(globalThis, 'scrollY', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -346,7 +346,7 @@ describe('AppFrameComponent', () => {
|
|||||||
})
|
})
|
||||||
component.onWindowScroll()
|
component.onWindowScroll()
|
||||||
|
|
||||||
expect(component.mobileSearchHidden()).toBe(false)
|
expect(component.mobileSearchHidden).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support close document & navigate on close current doc', () => {
|
it('should support close document & navigate on close current doc', () => {
|
||||||
@@ -379,11 +379,11 @@ describe('AppFrameComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should disable global dropzone on start drag + drop, re-enable after', () => {
|
it('should disable global dropzone on start drag + drop, re-enable after', () => {
|
||||||
expect(settingsService.globalDropzoneEnabled()).toBeTruthy()
|
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
|
||||||
component.onDragStart(null)
|
component.onDragStart(null)
|
||||||
expect(settingsService.globalDropzoneEnabled()).toBeFalsy()
|
expect(settingsService.globalDropzoneEnabled).toBeFalsy()
|
||||||
component.onDragEnd(null)
|
component.onDragEnd(null)
|
||||||
expect(settingsService.globalDropzoneEnabled()).toBeTruthy()
|
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update saved view sorting on drag + drop, show info', () => {
|
it('should update saved view sorting on drag + drop, show info', () => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
moveItemInArray,
|
moveItemInArray,
|
||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
import { Component, HostListener, inject, OnInit } from '@angular/core'
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
NgbCollapseModule,
|
NgbCollapseModule,
|
||||||
@@ -90,10 +90,14 @@ export class AppFrameComponent
|
|||||||
permissionsService = inject(PermissionsService)
|
permissionsService = inject(PermissionsService)
|
||||||
private djangoMessagesService = inject(DjangoMessagesService)
|
private djangoMessagesService = inject(DjangoMessagesService)
|
||||||
|
|
||||||
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
|
appRemoteVersion: AppRemoteVersion
|
||||||
readonly isMenuCollapsed = signal(true)
|
|
||||||
readonly slimSidebarAnimating = signal(false)
|
isMenuCollapsed: boolean = true
|
||||||
readonly mobileSearchHidden = signal(false)
|
|
||||||
|
slimSidebarAnimating: boolean = false
|
||||||
|
|
||||||
|
public mobileSearchHidden: boolean = false
|
||||||
|
|
||||||
private lastScrollY: number = 0
|
private lastScrollY: number = 0
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -143,7 +147,7 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleSlimSidebar(): void {
|
toggleSlimSidebar(): void {
|
||||||
this.slimSidebarAnimating.set(true)
|
this.slimSidebarAnimating = true
|
||||||
const slimSidebarEnabled = !this.slimSidebarEnabled
|
const slimSidebarEnabled = !this.slimSidebarEnabled
|
||||||
this.settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, slimSidebarEnabled)
|
this.settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, slimSidebarEnabled)
|
||||||
if (slimSidebarEnabled) {
|
if (slimSidebarEnabled) {
|
||||||
@@ -163,7 +167,7 @@ export class AppFrameComponent
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.slimSidebarAnimating.set(false)
|
this.slimSidebarAnimating = false
|
||||||
}, 200) // slightly longer than css animation for slim sidebar
|
}, 200) // slightly longer than css animation for slim sidebar
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,25 +177,11 @@ export class AppFrameComponent
|
|||||||
this.attributesSectionsCollapsed = !this.attributesSectionsCollapsed
|
this.attributesSectionsCollapsed = !this.attributesSectionsCollapsed
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleMenuCollapsed(): void {
|
|
||||||
this.isMenuCollapsed.set(!this.isMenuCollapsed())
|
|
||||||
}
|
|
||||||
|
|
||||||
closeMobileSearch(): void {
|
|
||||||
this.mobileSearchHidden.set(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
setMobileSearchHidden(hidden: boolean): void {
|
|
||||||
this.mobileSearchHidden.set(hidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
get versionString(): string {
|
get versionString(): string {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||||
}
|
}
|
||||||
|
|
||||||
get customAppTitle(): string {
|
get customAppTitle(): string {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +224,6 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get slimSidebarEnabled(): boolean {
|
get slimSidebarEnabled(): boolean {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +243,6 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get attributesSectionsCollapsed(): boolean {
|
get attributesSectionsCollapsed(): boolean {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return this.settingsService
|
return this.settingsService
|
||||||
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
|
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
|
||||||
?.includes(CollapsibleSection.ATTRIBUTES)
|
?.includes(CollapsibleSection.ATTRIBUTES)
|
||||||
@@ -280,14 +268,13 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get aiEnabled(): boolean {
|
get aiEnabled(): boolean {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:resize')
|
@HostListener('window:resize')
|
||||||
onWindowResize(): void {
|
onWindowResize(): void {
|
||||||
if (!this.isMobileViewport()) {
|
if (!this.isMobileViewport()) {
|
||||||
this.mobileSearchHidden.set(false)
|
this.mobileSearchHidden = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,8 +282,8 @@ export class AppFrameComponent
|
|||||||
onWindowScroll(): void {
|
onWindowScroll(): void {
|
||||||
const currentScrollY = window.scrollY
|
const currentScrollY = window.scrollY
|
||||||
|
|
||||||
if (!this.isMobileViewport() || this.isMenuCollapsed() === false) {
|
if (!this.isMobileViewport() || this.isMenuCollapsed === false) {
|
||||||
this.mobileSearchHidden.set(false)
|
this.mobileSearchHidden = false
|
||||||
this.lastScrollY = currentScrollY
|
this.lastScrollY = currentScrollY
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -304,9 +291,9 @@ export class AppFrameComponent
|
|||||||
const delta = currentScrollY - this.lastScrollY
|
const delta = currentScrollY - this.lastScrollY
|
||||||
|
|
||||||
if (currentScrollY <= 0 || delta < -SCROLL_THRESHOLD) {
|
if (currentScrollY <= 0 || delta < -SCROLL_THRESHOLD) {
|
||||||
this.mobileSearchHidden.set(false)
|
this.mobileSearchHidden = false
|
||||||
} else if (currentScrollY > SCROLL_THRESHOLD && delta > SCROLL_THRESHOLD) {
|
} else if (currentScrollY > SCROLL_THRESHOLD && delta > SCROLL_THRESHOLD) {
|
||||||
this.mobileSearchHidden.set(true)
|
this.mobileSearchHidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastScrollY = currentScrollY
|
this.lastScrollY = currentScrollY
|
||||||
@@ -317,7 +304,7 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
closeMenu() {
|
closeMenu() {
|
||||||
this.isMenuCollapsed.set(true)
|
this.isMenuCollapsed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
editProfile() {
|
editProfile() {
|
||||||
@@ -380,11 +367,11 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
onDragStart(event: CdkDragStart) {
|
onDragStart(event: CdkDragStart) {
|
||||||
this.settingsService.globalDropzoneEnabled.set(false)
|
this.settingsService.globalDropzoneEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onDragEnd(event: CdkDragEnd) {
|
onDragEnd(event: CdkDragEnd) {
|
||||||
this.settingsService.globalDropzoneEnabled.set(true)
|
this.settingsService.globalDropzoneEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
onDrop(event: CdkDragDrop<SavedView[]>) {
|
onDrop(event: CdkDragDrop<SavedView[]>) {
|
||||||
@@ -405,7 +392,7 @@ export class AppFrameComponent
|
|||||||
this.remoteVersionService
|
this.remoteVersionService
|
||||||
.checkForUpdates()
|
.checkForUpdates()
|
||||||
.subscribe((appRemoteVersion: AppRemoteVersion) => {
|
.subscribe((appRemoteVersion: AppRemoteVersion) => {
|
||||||
this.appRemoteVersion.set(appRemoteVersion)
|
this.appRemoteVersion = appRemoteVersion
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,10 +419,9 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get showSidebarCounts(): boolean {
|
get showSidebarCounts(): boolean {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return (
|
return (
|
||||||
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
|
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
|
||||||
!this.settingsService.organizingSidebarSavedViews()
|
!this.settingsService.organizingSidebarSavedViews
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,16 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
[(ngModel)]="query"
|
[(ngModel)]="query"
|
||||||
(ngModelChange)="queryDebounce.next($event)"
|
(ngModelChange)="this.queryDebounce.next($event)"
|
||||||
(keydown)="searchInputKeyDown($event)"
|
(keydown)="searchInputKeyDown($event)"
|
||||||
ngbDropdownAnchor>
|
ngbDropdownAnchor>
|
||||||
<div class="position-absolute top-50 end-0 translate-middle">
|
<div class="position-absolute top-50 end-0 translate-middle">
|
||||||
@if (loading()) {
|
@if (loading) {
|
||||||
<div class="spinner-border spinner-border-sm text-muted mt-1"></div>
|
<div class="spinner-border spinner-border-sm text-muted mt-1"></div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (query()) {
|
@if (query) {
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="runFullSearch()">
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="runFullSearch()">
|
||||||
@if (useAdvancedForFullSearch) {
|
@if (useAdvancedForFullSearch) {
|
||||||
<ng-container i18n>Advanced search</ng-container>
|
<ng-container i18n>Advanced search</ng-container>
|
||||||
@@ -77,88 +77,88 @@
|
|||||||
|
|
||||||
<div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg">
|
<div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg">
|
||||||
<div (keydown)="dropdownKeyDown($event)">
|
<div (keydown)="dropdownKeyDown($event)">
|
||||||
@if (searchResults()?.total === 0) {
|
@if (searchResults?.total === 0) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
|
||||||
} @else {
|
} @else {
|
||||||
@if (searchResults()?.documents.length) {
|
@if (searchResults?.documents.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
|
||||||
@for (document of searchResults().documents; track document.id) {
|
@for (document of searchResults.documents; track document.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.created}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.created}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (searchResults()?.saved_views.length) {
|
@if (searchResults?.saved_views.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
|
||||||
@for (saved_view of searchResults().saved_views; track saved_view.id) {
|
@for (saved_view of searchResults.saved_views; track saved_view.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.tags.length) {
|
@if (searchResults?.tags.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
|
||||||
@for (tag of searchResults().tags; track tag.id) {
|
@for (tag of searchResults.tags; track tag.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.correspondents.length) {
|
@if (searchResults?.correspondents.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
|
||||||
@for (correspondent of searchResults().correspondents; track correspondent.id) {
|
@for (correspondent of searchResults.correspondents; track correspondent.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.document_types.length) {
|
@if (searchResults?.document_types.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
|
||||||
@for (documentType of searchResults().document_types; track documentType.id) {
|
@for (documentType of searchResults.document_types; track documentType.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.storage_paths.length) {
|
@if (searchResults?.storage_paths.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
|
||||||
@for (storagePath of searchResults().storage_paths; track storagePath.id) {
|
@for (storagePath of searchResults.storage_paths; track storagePath.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.users.length) {
|
@if (searchResults?.users.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
|
||||||
@for (user of searchResults().users; track user.id) {
|
@for (user of searchResults.users; track user.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.groups.length) {
|
@if (searchResults?.groups.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
|
||||||
@for (group of searchResults().groups; track group.id) {
|
@for (group of searchResults.groups; track group.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.custom_fields.length) {
|
@if (searchResults?.custom_fields.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
|
||||||
@for (customField of searchResults().custom_fields; track customField.id) {
|
@for (customField of searchResults.custom_fields; track customField.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.mail_accounts.length) {
|
@if (searchResults?.mail_accounts.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
|
||||||
@for (mailAccount of searchResults().mail_accounts; track mailAccount.id) {
|
@for (mailAccount of searchResults.mail_accounts; track mailAccount.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.mail_rules.length) {
|
@if (searchResults?.mail_rules.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
|
||||||
@for (mailRule of searchResults().mail_rules; track mailRule.id) {
|
@for (mailRule of searchResults.mail_rules; track mailRule.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults()?.workflows.length) {
|
@if (searchResults?.workflows.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
|
||||||
@for (workflow of searchResults().workflows; track workflow.id) {
|
@for (workflow of searchResults.workflows; track workflow.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
|
||||||
expect(focusSpy).toHaveBeenCalled()
|
expect(focusSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
component.searchResults.set(searchResults as any)
|
component.searchResults = searchResults as any
|
||||||
component.resultsDropdown.open()
|
component.resultsDropdown.open()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -238,26 +238,26 @@ describe('GlobalSearchComponent', () => {
|
|||||||
)
|
)
|
||||||
expect(component['currentItemIndex']).toBe(0)
|
expect(component['currentItemIndex']).toBe(0)
|
||||||
|
|
||||||
component.searchResults.set({ total: 1 } as any)
|
component.searchResults = { total: 1 } as any
|
||||||
const primaryActionSpy = jest.spyOn(component, 'primaryAction')
|
const primaryActionSpy = jest.spyOn(component, 'primaryAction')
|
||||||
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||||
expect(primaryActionSpy).toHaveBeenCalled()
|
expect(primaryActionSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
component.query.set('test')
|
component.query = 'test'
|
||||||
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
|
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
|
||||||
component.searchInputKeyDown(
|
component.searchInputKeyDown(
|
||||||
new KeyboardEvent('keydown', { key: 'Escape' })
|
new KeyboardEvent('keydown', { key: 'Escape' })
|
||||||
)
|
)
|
||||||
expect(resetSpy).toHaveBeenCalled()
|
expect(resetSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
component.query.set('')
|
component.query = ''
|
||||||
const blurSpy = jest.spyOn(component.searchInput.nativeElement, 'blur')
|
const blurSpy = jest.spyOn(component.searchInput.nativeElement, 'blur')
|
||||||
component.searchInputKeyDown(
|
component.searchInputKeyDown(
|
||||||
new KeyboardEvent('keydown', { key: 'Escape' })
|
new KeyboardEvent('keydown', { key: 'Escape' })
|
||||||
)
|
)
|
||||||
expect(blurSpy).toHaveBeenCalled()
|
expect(blurSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
component.searchResults.set({ total: 1 } as any)
|
component.searchResults = { total: 1 } as any
|
||||||
component.resultsDropdown.open()
|
component.resultsDropdown.open()
|
||||||
|
|
||||||
component.searchInputKeyDown(
|
component.searchInputKeyDown(
|
||||||
@@ -268,9 +268,9 @@ describe('GlobalSearchComponent', () => {
|
|||||||
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }))
|
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
component.searchResults.set(searchResults as any)
|
component.searchResults = searchResults as any
|
||||||
component.resultsDropdown.open()
|
component.resultsDropdown.open()
|
||||||
component.query.set('test')
|
component.query = 'test'
|
||||||
const advancedSearchSpy = jest.spyOn(component, 'runFullSearch')
|
const advancedSearchSpy = jest.spyOn(component, 'runFullSearch')
|
||||||
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||||
expect(advancedSearchSpy).toHaveBeenCalled()
|
expect(advancedSearchSpy).toHaveBeenCalled()
|
||||||
@@ -465,13 +465,13 @@ describe('GlobalSearchComponent', () => {
|
|||||||
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
|
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
|
||||||
component['reset'](true)
|
component['reset'](true)
|
||||||
expect(debounce).toHaveBeenCalledWith(null)
|
expect(debounce).toHaveBeenCalledWith(null)
|
||||||
expect(component.searchResults()).toBeNull()
|
expect(component.searchResults).toBeNull()
|
||||||
expect(component['currentItemIndex']).toBe(-1)
|
expect(component['currentItemIndex']).toBe(-1)
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support focus current item', () => {
|
it('should support focus current item', () => {
|
||||||
component.searchResults.set(searchResults as any)
|
component.searchResults = searchResults as any
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
const focusSpy = jest.spyOn(
|
const focusSpy = jest.spyOn(
|
||||||
component.primaryButtons.get(0).nativeElement,
|
component.primaryButtons.get(0).nativeElement,
|
||||||
@@ -489,7 +489,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should focus button on dropdown item hover', () => {
|
it('should focus button on dropdown item hover', () => {
|
||||||
component.searchResults.set(searchResults as any)
|
component.searchResults = searchResults as any
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
const item: ElementRef = component.resultItems.first
|
const item: ElementRef = component.resultItems.first
|
||||||
const focusSpy = jest.spyOn(
|
const focusSpy = jest.spyOn(
|
||||||
@@ -515,7 +515,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
component.primaryAction(DataType.Document, { id: 2 }, event as any)
|
component.primaryAction(DataType.Document, { id: 2 }, event as any)
|
||||||
expect(openSpy).toHaveBeenCalledWith('/documents/2', '_blank')
|
expect(openSpy).toHaveBeenCalledWith('/documents/2', '_blank')
|
||||||
|
|
||||||
component.searchResults.set(searchResults as any)
|
component.searchResults = searchResults as any
|
||||||
component.resultsDropdown.open()
|
component.resultsDropdown.open()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -542,7 +542,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
|
|
||||||
it('should support title content search and advanced search', () => {
|
it('should support title content search and advanced search', () => {
|
||||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||||
component.query.set('test')
|
component.query = 'test'
|
||||||
component.runFullSearch()
|
component.runFullSearch()
|
||||||
expect(qfSpy).toHaveBeenCalledWith([
|
expect(qfSpy).toHaveBeenCalledWith([
|
||||||
{ rule_type: FILTER_SIMPLE_TEXT, value: 'test' },
|
{ rule_type: FILTER_SIMPLE_TEXT, value: 'test' },
|
||||||
@@ -552,7 +552,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
SETTINGS_KEYS.SEARCH_FULL_TYPE,
|
SETTINGS_KEYS.SEARCH_FULL_TYPE,
|
||||||
GlobalSearchType.ADVANCED
|
GlobalSearchType.ADVANCED
|
||||||
)
|
)
|
||||||
component.query.set('test')
|
component.query = 'test'
|
||||||
component.runFullSearch()
|
component.runFullSearch()
|
||||||
expect(qfSpy).toHaveBeenCalledWith([
|
expect(qfSpy).toHaveBeenCalledWith([
|
||||||
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
|
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
ViewChild,
|
ViewChild,
|
||||||
ViewChildren,
|
ViewChildren,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
@@ -83,12 +82,12 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
private locationStrategy = inject(LocationStrategy)
|
private locationStrategy = inject(LocationStrategy)
|
||||||
|
|
||||||
public DataType = DataType
|
public DataType = DataType
|
||||||
readonly query = signal<string>(null)
|
public query: string
|
||||||
public queryDebounce: Subject<string>
|
public queryDebounce: Subject<string>
|
||||||
readonly searchResults = signal<GlobalSearchResult>(null)
|
public searchResults: GlobalSearchResult
|
||||||
private currentItemIndex: number = -1
|
private currentItemIndex: number = -1
|
||||||
private domIndex: number = -1
|
private domIndex: number = -1
|
||||||
readonly loading = signal(false)
|
public loading: boolean = false
|
||||||
|
|
||||||
@ViewChild('searchInput') searchInput: ElementRef
|
@ViewChild('searchInput') searchInput: ElementRef
|
||||||
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
|
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
|
||||||
@@ -97,7 +96,6 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
||||||
|
|
||||||
get useAdvancedForFullSearch(): boolean {
|
get useAdvancedForFullSearch(): boolean {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return (
|
return (
|
||||||
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
|
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
|
||||||
GlobalSearchType.ADVANCED
|
GlobalSearchType.ADVANCED
|
||||||
@@ -114,7 +112,7 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
distinctUntilChanged()
|
distinctUntilChanged()
|
||||||
)
|
)
|
||||||
.subscribe((text) => {
|
.subscribe((text) => {
|
||||||
this.query.set(text)
|
this.query = text
|
||||||
if (text) this.search(text)
|
if (text) this.search(text)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -128,10 +126,10 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private search(query: string) {
|
private search(query: string) {
|
||||||
this.loading.set(true)
|
this.loading = true
|
||||||
this.searchService.globalSearch(query.trim()).subscribe((results) => {
|
this.searchService.globalSearch(query.trim()).subscribe((results) => {
|
||||||
this.searchResults.set(results)
|
this.searchResults = results
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.resultsDropdown.open()
|
this.resultsDropdown.open()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -258,8 +256,8 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
|
|
||||||
private reset(close: boolean = false) {
|
private reset(close: boolean = false) {
|
||||||
this.queryDebounce.next(null)
|
this.queryDebounce.next(null)
|
||||||
this.query.set(null)
|
this.query = null
|
||||||
this.searchResults.set(null)
|
this.searchResults = null
|
||||||
this.currentItemIndex = -1
|
this.currentItemIndex = -1
|
||||||
if (close) {
|
if (close) {
|
||||||
this.resultsDropdown.close()
|
this.resultsDropdown.close()
|
||||||
@@ -294,7 +292,7 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
public searchInputKeyDown(event: KeyboardEvent) {
|
public searchInputKeyDown(event: KeyboardEvent) {
|
||||||
if (
|
if (
|
||||||
event.key === 'ArrowDown' &&
|
event.key === 'ArrowDown' &&
|
||||||
this.searchResults()?.total &&
|
this.searchResults?.total &&
|
||||||
this.resultsDropdown.isOpen()
|
this.resultsDropdown.isOpen()
|
||||||
) {
|
) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -302,22 +300,22 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
this.setCurrentItem()
|
this.setCurrentItem()
|
||||||
} else if (
|
} else if (
|
||||||
event.key === 'ArrowUp' &&
|
event.key === 'ArrowUp' &&
|
||||||
this.searchResults()?.total &&
|
this.searchResults?.total &&
|
||||||
this.resultsDropdown.isOpen()
|
this.resultsDropdown.isOpen()
|
||||||
) {
|
) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
this.currentItemIndex = this.searchResults()?.total - 1
|
this.currentItemIndex = this.searchResults.total - 1
|
||||||
this.setCurrentItem()
|
this.setCurrentItem()
|
||||||
} else if (event.key === 'Enter') {
|
} else if (event.key === 'Enter') {
|
||||||
if (this.searchResults()?.total === 1 && this.resultsDropdown.isOpen()) {
|
if (this.searchResults?.total === 1 && this.resultsDropdown.isOpen()) {
|
||||||
this.primaryButtons.first.nativeElement.click()
|
this.primaryButtons.first.nativeElement.click()
|
||||||
this.searchInput.nativeElement.blur()
|
this.searchInput.nativeElement.blur()
|
||||||
} else if (this.query()?.length) {
|
} else if (this.query?.length) {
|
||||||
this.runFullSearch()
|
this.runFullSearch()
|
||||||
this.reset(true)
|
this.reset(true)
|
||||||
}
|
}
|
||||||
} else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
|
} else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
|
||||||
if (this.query()?.length) {
|
if (this.query?.length) {
|
||||||
this.reset(true)
|
this.reset(true)
|
||||||
} else {
|
} else {
|
||||||
this.searchInput.nativeElement.blur()
|
this.searchInput.nativeElement.blur()
|
||||||
@@ -327,14 +325,14 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
|
|
||||||
public dropdownKeyDown(event: KeyboardEvent) {
|
public dropdownKeyDown(event: KeyboardEvent) {
|
||||||
if (
|
if (
|
||||||
this.searchResults()?.total &&
|
this.searchResults?.total &&
|
||||||
this.resultsDropdown.isOpen() &&
|
this.resultsDropdown.isOpen() &&
|
||||||
document.activeElement !== this.searchInput.nativeElement
|
document.activeElement !== this.searchInput.nativeElement
|
||||||
) {
|
) {
|
||||||
if (event.key === 'ArrowDown') {
|
if (event.key === 'ArrowDown') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopImmediatePropagation()
|
event.stopImmediatePropagation()
|
||||||
if (this.currentItemIndex < this.searchResults()?.total - 1) {
|
if (this.currentItemIndex < this.searchResults.total - 1) {
|
||||||
this.currentItemIndex++
|
this.currentItemIndex++
|
||||||
this.setCurrentItem()
|
this.setCurrentItem()
|
||||||
} else {
|
} else {
|
||||||
@@ -414,10 +412,10 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
? FILTER_FULLTEXT_QUERY
|
? FILTER_FULLTEXT_QUERY
|
||||||
: FILTER_SIMPLE_TEXT
|
: FILTER_SIMPLE_TEXT
|
||||||
this.documentService.searchQuery = this.useAdvancedForFullSearch
|
this.documentService.searchQuery = this.useAdvancedForFullSearch
|
||||||
? this.query()
|
? this.query
|
||||||
: ''
|
: ''
|
||||||
this.documentListViewService.quickFilter([
|
this.documentListViewService.quickFilter([
|
||||||
{ rule_type: ruleType, value: this.query() },
|
{ rule_type: ruleType, value: this.query },
|
||||||
])
|
])
|
||||||
this.reset(true)
|
this.reset(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item mx-1" (openChange)="onOpenChange($event)">
|
<li ngbDropdown class="nav-item mx-1" (openChange)="onOpenChange($event)">
|
||||||
@if (toasts().length) {
|
@if (toasts.length) {
|
||||||
<span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts().length }}</span>
|
<span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts.length }}</span>
|
||||||
}
|
}
|
||||||
<button class="btn border-0" id="notificationsDropdown" ngbDropdownToggle>
|
<button class="btn border-0" id="notificationsDropdown" ngbDropdownToggle>
|
||||||
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
|
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
|
||||||
@@ -12,15 +12,15 @@
|
|||||||
<div class="btn-group ms-auto">
|
<div class="btn-group ms-auto">
|
||||||
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
|
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
|
||||||
(click)="toastService.clearToasts()"
|
(click)="toastService.clearToasts()"
|
||||||
[disabled]="toasts().length === 0"
|
[disabled]="toasts.length === 0"
|
||||||
i18n>Clear All</button>
|
i18n>Clear All</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (toasts().length === 0) {
|
@if (toasts.length === 0) {
|
||||||
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
|
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
|
||||||
}
|
}
|
||||||
<div class="scroll-list">
|
<div class="scroll-list">
|
||||||
@for (toast of toasts(); track toast.id) {
|
@for (toast of toasts; track toast.id) {
|
||||||
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
|
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+15
-10
@@ -42,8 +42,7 @@ describe('ToastsDropdownComponent', () => {
|
|||||||
let component: ToastsDropdownComponent
|
let component: ToastsDropdownComponent
|
||||||
let fixture: ComponentFixture<ToastsDropdownComponent>
|
let fixture: ComponentFixture<ToastsDropdownComponent>
|
||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
let toastsSubject: Subject<Toast[]>
|
let toastsSubject: Subject<Toast[]> = new Subject()
|
||||||
let getToastsSpy: jest.SpyInstance
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -57,43 +56,48 @@ describe('ToastsDropdownComponent', () => {
|
|||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
toastService = TestBed.inject(ToastService)
|
|
||||||
toastsSubject = new Subject()
|
|
||||||
getToastsSpy = jest
|
|
||||||
.spyOn(toastService, 'getToasts')
|
|
||||||
.mockReturnValue(toastsSubject)
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ToastsDropdownComponent)
|
fixture = TestBed.createComponent(ToastsDropdownComponent)
|
||||||
|
toastService = TestBed.inject(ToastService)
|
||||||
|
jest.spyOn(toastService, 'getToasts').mockReturnValue(toastsSubject)
|
||||||
|
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call getToasts and return toasts', fakeAsync(() => {
|
it('should call getToasts and return toasts', fakeAsync(() => {
|
||||||
|
const spy = jest.spyOn(toastService, 'getToasts')
|
||||||
|
|
||||||
|
component.ngOnInit()
|
||||||
toastsSubject.next(toasts)
|
toastsSubject.next(toasts)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(getToastsSpy).toHaveBeenCalled()
|
expect(spy).toHaveBeenCalled()
|
||||||
expect(component.toasts()).toContainEqual({
|
expect(component.toasts).toContainEqual({
|
||||||
id: 'abc-123',
|
id: 'abc-123',
|
||||||
content: 'foo bar',
|
content: 'foo bar',
|
||||||
delay: 5000,
|
delay: 5000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
component.ngOnDestroy()
|
||||||
flush()
|
flush()
|
||||||
discardPeriodicTasks()
|
discardPeriodicTasks()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
it('should show a toast', fakeAsync(() => {
|
it('should show a toast', fakeAsync(() => {
|
||||||
|
component.ngOnInit()
|
||||||
toastsSubject.next(toasts)
|
toastsSubject.next(toasts)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('foo bar')
|
expect(fixture.nativeElement.textContent).toContain('foo bar')
|
||||||
|
|
||||||
|
component.ngOnDestroy()
|
||||||
flush()
|
flush()
|
||||||
discardPeriodicTasks()
|
discardPeriodicTasks()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
it('should toggle suppressPopupToasts', fakeAsync((finish) => {
|
it('should toggle suppressPopupToasts', fakeAsync((finish) => {
|
||||||
|
component.ngOnInit()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
toastsSubject.next(toasts)
|
toastsSubject.next(toasts)
|
||||||
|
|
||||||
@@ -101,6 +105,7 @@ describe('ToastsDropdownComponent', () => {
|
|||||||
component.onOpenChange(true)
|
component.onOpenChange(true)
|
||||||
expect(spy).toHaveBeenCalledWith(true)
|
expect(spy).toHaveBeenCalledWith(true)
|
||||||
|
|
||||||
|
component.ngOnDestroy()
|
||||||
flush()
|
flush()
|
||||||
discardPeriodicTasks()
|
discardPeriodicTasks()
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Component, inject } from '@angular/core'
|
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||||
import { toSignal } from '@angular/core/rxjs-interop'
|
|
||||||
import {
|
import {
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbProgressbarModule,
|
NgbProgressbarModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { map } from 'rxjs'
|
import { Subscription } from 'rxjs'
|
||||||
import { Toast, ToastService } from 'src/app/services/toast.service'
|
import { Toast, ToastService } from 'src/app/services/toast.service'
|
||||||
import { ToastComponent } from '../../common/toast/toast.component'
|
import { ToastComponent } from '../../common/toast/toast.component'
|
||||||
|
|
||||||
@@ -20,13 +19,22 @@ import { ToastComponent } from '../../common/toast/toast.component'
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ToastsDropdownComponent {
|
export class ToastsDropdownComponent implements OnInit, OnDestroy {
|
||||||
toastService = inject(ToastService)
|
toastService = inject(ToastService)
|
||||||
|
|
||||||
readonly toasts = toSignal(
|
private subscription: Subscription
|
||||||
this.toastService.getToasts().pipe(map((toasts) => [...toasts])),
|
|
||||||
{ initialValue: [] as Toast[] }
|
public toasts: Toast[] = []
|
||||||
)
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.subscription?.unsubscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.subscription = this.toastService.getToasts().subscribe((toasts) => {
|
||||||
|
this.toasts = [...toasts]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onOpenChange(open: boolean): void {
|
onOpenChange(open: boolean): void {
|
||||||
this.toastService.suppressPopupToasts = open
|
this.toastService.suppressPopupToasts = open
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
@if (active()) {
|
@if (active) {
|
||||||
<button class="position-absolute top-0 start-100 translate-middle badge bg-secondary border border-light rounded-pill p-1" title="Clear" i18n-title (click)="onClick($event)">
|
<button class="position-absolute top-0 start-100 translate-middle badge bg-secondary border border-light rounded-pill p-1" title="Clear" i18n-title (click)="onClick($event)">
|
||||||
@if (!isNumbered() && selected()) {
|
@if (!isNumbered && selected) {
|
||||||
<i-bs class="check" width="1em" height="1em" name="check-lg"></i-bs>
|
<i-bs class="check" width="1em" height="1em" name="check-lg"></i-bs>
|
||||||
}
|
}
|
||||||
@if (isNumbered()) {
|
@if (isNumbered) {
|
||||||
<div class="number">{{number()}}<span class="visually-hidden">selected</span></div>
|
<div class="number">{{number}}<span class="visually-hidden">selected</span></div>
|
||||||
}
|
}
|
||||||
<i-bs class="x" width=".9em" height="1em" name="x-lg"></i-bs>
|
<i-bs class="x" width=".9em" height="1em" name="x-lg"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -21,20 +21,20 @@ describe('ClearableBadgeComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support selected', () => {
|
it('should support selected', () => {
|
||||||
fixture.componentRef.setInput('selected', true)
|
component.selected = true
|
||||||
expect(component.active()).toBeTruthy()
|
expect(component.active).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support numbered', () => {
|
it('should support numbered', () => {
|
||||||
fixture.componentRef.setInput('number', 3)
|
component.number = 3
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.active()).toBeTruthy()
|
expect(component.active).toBeTruthy()
|
||||||
expect((fixture.nativeElement as HTMLDivElement).textContent).toContain('3')
|
expect((fixture.nativeElement as HTMLDivElement).textContent).toContain('3')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support selected', () => {
|
it('should support selected', () => {
|
||||||
let clearedResult
|
let clearedResult
|
||||||
fixture.componentRef.setInput('selected', true)
|
component.selected = true
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.cleared.subscribe((clear) => {
|
component.cleared.subscribe((clear) => {
|
||||||
clearedResult = clear
|
clearedResult = clear
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, computed, EventEmitter, input, Output } from '@angular/core'
|
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -8,15 +8,24 @@ import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|||||||
imports: [NgxBootstrapIconsModule],
|
imports: [NgxBootstrapIconsModule],
|
||||||
})
|
})
|
||||||
export class ClearableBadgeComponent {
|
export class ClearableBadgeComponent {
|
||||||
readonly number = input<number>(undefined)
|
constructor() {}
|
||||||
readonly selected = input<boolean>(undefined)
|
|
||||||
|
@Input()
|
||||||
|
number: number
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
selected: boolean
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
cleared: EventEmitter<boolean> = new EventEmitter()
|
cleared: EventEmitter<boolean> = new EventEmitter()
|
||||||
|
|
||||||
readonly active = computed(() => this.selected() || this.number() > -1)
|
get active(): boolean {
|
||||||
|
return this.selected || this.number > -1
|
||||||
|
}
|
||||||
|
|
||||||
readonly isNumbered = computed(() => this.number() > -1)
|
get isNumbered(): boolean {
|
||||||
|
return this.number > -1
|
||||||
|
}
|
||||||
|
|
||||||
onClick(event: PointerEvent) {
|
onClick(event: PointerEvent) {
|
||||||
this.cleared.emit(true)
|
this.cleared.emit(true)
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import { DecimalPipe } from '@angular/common'
|
import { DecimalPipe } from '@angular/common'
|
||||||
import {
|
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
|
||||||
Component,
|
|
||||||
EventEmitter,
|
|
||||||
Input,
|
|
||||||
Output,
|
|
||||||
inject,
|
|
||||||
signal,
|
|
||||||
} from '@angular/core'
|
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
@@ -20,17 +13,6 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||||||
export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
|
export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
|
||||||
activeModal = inject(NgbActiveModal)
|
activeModal = inject(NgbActiveModal)
|
||||||
|
|
||||||
private titleSignal = signal($localize`Confirmation`)
|
|
||||||
private messageBoldSignal = signal<string>(undefined)
|
|
||||||
private messageSignal = signal<string>(undefined)
|
|
||||||
private btnClassSignal = signal('btn-primary')
|
|
||||||
private btnCaptionSignal = signal($localize`Confirm`)
|
|
||||||
private alternativeBtnClassSignal = signal('btn-secondary')
|
|
||||||
private alternativeBtnCaptionSignal = signal<string>(undefined)
|
|
||||||
private cancelBtnClassSignal = signal('btn-outline-secondary')
|
|
||||||
private cancelBtnCaptionSignal = signal($localize`Cancel`)
|
|
||||||
private buttonsEnabledSignal = signal(true)
|
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
public confirmClicked = new EventEmitter()
|
public confirmClicked = new EventEmitter()
|
||||||
|
|
||||||
@@ -38,94 +20,34 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
|
|||||||
public alternativeClicked = new EventEmitter()
|
public alternativeClicked = new EventEmitter()
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
get title(): string {
|
title = $localize`Confirmation`
|
||||||
return this.titleSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set title(title: string) {
|
|
||||||
this.titleSignal.set(title)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
get messageBold(): string {
|
messageBold
|
||||||
return this.messageBoldSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set messageBold(messageBold: string) {
|
|
||||||
this.messageBoldSignal.set(messageBold)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
get message(): string {
|
message
|
||||||
return this.messageSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set message(message: string) {
|
|
||||||
this.messageSignal.set(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
get btnClass(): string {
|
btnClass = 'btn-primary'
|
||||||
return this.btnClassSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set btnClass(btnClass: string) {
|
|
||||||
this.btnClassSignal.set(btnClass)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
get btnCaption(): string {
|
btnCaption = $localize`Confirm`
|
||||||
return this.btnCaptionSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set btnCaption(btnCaption: string) {
|
|
||||||
this.btnCaptionSignal.set(btnCaption)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
get alternativeBtnClass(): string {
|
alternativeBtnClass = 'btn-secondary'
|
||||||
return this.alternativeBtnClassSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set alternativeBtnClass(alternativeBtnClass: string) {
|
|
||||||
this.alternativeBtnClassSignal.set(alternativeBtnClass)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
get alternativeBtnCaption(): string {
|
alternativeBtnCaption
|
||||||
return this.alternativeBtnCaptionSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set alternativeBtnCaption(alternativeBtnCaption: string) {
|
|
||||||
this.alternativeBtnCaptionSignal.set(alternativeBtnCaption)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
get cancelBtnClass(): string {
|
cancelBtnClass = 'btn-outline-secondary'
|
||||||
return this.cancelBtnClassSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set cancelBtnClass(cancelBtnClass: string) {
|
|
||||||
this.cancelBtnClassSignal.set(cancelBtnClass)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
get cancelBtnCaption(): string {
|
cancelBtnCaption = $localize`Cancel`
|
||||||
return this.cancelBtnCaptionSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set cancelBtnCaption(cancelBtnCaption: string) {
|
|
||||||
this.cancelBtnCaptionSignal.set(cancelBtnCaption)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
get buttonsEnabled(): boolean {
|
buttonsEnabled = true
|
||||||
return this.buttonsEnabledSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set buttonsEnabled(buttonsEnabled: boolean) {
|
|
||||||
this.buttonsEnabledSignal.set(buttonsEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmButtonEnabled = true
|
confirmButtonEnabled = true
|
||||||
alternativeButtonEnabled = true
|
alternativeButtonEnabled = true
|
||||||
|
|||||||
+9
-43
@@ -4,7 +4,7 @@ import {
|
|||||||
moveItemInArray,
|
moveItemInArray,
|
||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { AsyncPipe } from '@angular/common'
|
import { AsyncPipe } from '@angular/common'
|
||||||
import { Component, OnInit, inject, signal } from '@angular/core'
|
import { Component, OnInit, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { takeUntil } from 'rxjs'
|
import { takeUntil } from 'rxjs'
|
||||||
@@ -36,47 +36,15 @@ export class MergeConfirmDialogComponent
|
|||||||
private documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
private permissionService = inject(PermissionsService)
|
private permissionService = inject(PermissionsService)
|
||||||
|
|
||||||
private documentIDsSignal = signal<number[]>([])
|
public documentIDs: number[] = []
|
||||||
private archiveFallbackSignal = signal(false)
|
public archiveFallback: boolean = false
|
||||||
private deleteOriginalsSignal = signal(false)
|
public deleteOriginals: boolean = false
|
||||||
private documentsSignal = signal<Document[]>([])
|
private _documents: Document[] = []
|
||||||
private metadataDocumentIDSignal = signal(-1)
|
|
||||||
|
|
||||||
public get documentIDs(): number[] {
|
|
||||||
return this.documentIDsSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
public set documentIDs(documentIDs: number[]) {
|
|
||||||
this.documentIDsSignal.set(documentIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
public get archiveFallback(): boolean {
|
|
||||||
return this.archiveFallbackSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
public set archiveFallback(archiveFallback: boolean) {
|
|
||||||
this.archiveFallbackSignal.set(archiveFallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
public get deleteOriginals(): boolean {
|
|
||||||
return this.deleteOriginalsSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
public set deleteOriginals(deleteOriginals: boolean) {
|
|
||||||
this.deleteOriginalsSignal.set(deleteOriginals)
|
|
||||||
}
|
|
||||||
|
|
||||||
get documents(): Document[] {
|
get documents(): Document[] {
|
||||||
return this.documentsSignal()
|
return this._documents
|
||||||
}
|
}
|
||||||
|
|
||||||
public get metadataDocumentID(): number {
|
public metadataDocumentID: number = -1
|
||||||
return this.metadataDocumentIDSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
public set metadataDocumentID(metadataDocumentID: number) {
|
|
||||||
this.metadataDocumentIDSignal.set(metadataDocumentID)
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -87,14 +55,12 @@ export class MergeConfirmDialogComponent
|
|||||||
.getFew(this.documentIDs)
|
.getFew(this.documentIDs)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((r) => {
|
.subscribe((r) => {
|
||||||
this.documentsSignal.set(r.results)
|
this._documents = r.results
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onDrop(event: CdkDragDrop<number[]>) {
|
onDrop(event: CdkDragDrop<number[]>) {
|
||||||
const documentIDs = this.documentIDs.concat()
|
moveItemInArray(this.documentIDs, event.previousIndex, event.currentIndex)
|
||||||
moveItemInArray(documentIDs, event.previousIndex, event.currentIndex)
|
|
||||||
this.documentIDs = documentIDs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getDocument(documentID: number): Document {
|
getDocument(documentID: number): Document {
|
||||||
|
|||||||
+11
-4
@@ -1,4 +1,4 @@
|
|||||||
import { Component } from '@angular/core'
|
import { Component, Input } from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||||
@@ -14,11 +14,18 @@ export class PasswordRemovalConfirmDialogComponent extends ConfirmDialogComponen
|
|||||||
includeMetadata: boolean = true
|
includeMetadata: boolean = true
|
||||||
deleteOriginal: boolean = false
|
deleteOriginal: boolean = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
override title = $localize`Remove password protection`
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
override message =
|
||||||
|
$localize`Create an unprotected copy or replace the existing file.`
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
override btnCaption = $localize`Start`
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.title = $localize`Remove password protection`
|
|
||||||
this.message = $localize`Create an unprotected copy or replace the existing file.`
|
|
||||||
this.btnCaption = $localize`Start`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onUpdateDocumentChange(updateDocument: boolean) {
|
onUpdateDocumentChange(updateDocument: boolean) {
|
||||||
|
|||||||
+3
-3
@@ -11,8 +11,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-8 d-flex align-items-center">
|
<div class="col-8 d-flex align-items-center">
|
||||||
@if (documentID()) {
|
@if (documentID) {
|
||||||
<img class="w-75 m-auto" [ngStyle]="{'transform': 'rotate('+rotation()+'deg)'}" [src]="documentService.getThumbUrl(documentID())" />
|
<img class="w-75 m-auto" [ngStyle]="{'transform': 'rotate('+rotation+'deg)'}" [src]="documentService.getThumbUrl(documentID)" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2 d-flex">
|
<div class="col-2 d-flex">
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (showPDFNote()) {
|
@if (showPDFNote) {
|
||||||
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be rotated.</p>
|
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be rotated.</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ describe('RotateConfirmDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support rotating the image', () => {
|
it('should support rotating the image', () => {
|
||||||
component.documentID.set(1)
|
component.documentID = 1
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.rotate()
|
component.rotate()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|||||||
+8
-6
@@ -1,5 +1,5 @@
|
|||||||
import { NgStyle } from '@angular/common'
|
import { NgStyle } from '@angular/common'
|
||||||
import { Component, inject, signal } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||||
@@ -13,12 +13,14 @@ import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
|||||||
export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
|
export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
|
||||||
documentService = inject(DocumentService)
|
documentService = inject(DocumentService)
|
||||||
|
|
||||||
readonly documentID = signal<number>(undefined)
|
public documentID: number
|
||||||
readonly showPDFNote = signal(true)
|
public showPDFNote: boolean = true
|
||||||
readonly rotation = signal(0)
|
|
||||||
|
// animation is better if we dont normalize yet
|
||||||
|
public rotation: number = 0
|
||||||
|
|
||||||
public get degrees(): number {
|
public get degrees(): number {
|
||||||
let degrees = this.rotation() % 360
|
let degrees = this.rotation % 360
|
||||||
if (degrees < 0) degrees += 360
|
if (degrees < 0) degrees += 360
|
||||||
return degrees
|
return degrees
|
||||||
}
|
}
|
||||||
@@ -28,6 +30,6 @@ export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rotate(clockwise: boolean = true) {
|
rotate(clockwise: boolean = true) {
|
||||||
this.rotation.update((rotation) => rotation + (clockwise ? 90 : -90))
|
this.rotation += clockwise ? 90 : -90
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -74,7 +74,7 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 1, username: 'test' })
|
settingsService.currentUser = { id: 1, username: 'test' }
|
||||||
fixture = TestBed.createComponent(CustomFieldsDropdownComponent)
|
fixture = TestBed.createComponent(CustomFieldsDropdownComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|||||||
+3
-2
@@ -23,7 +23,6 @@ import {
|
|||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||||
import { matchesSearchText } from 'src/app/utils/text-search'
|
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
|
|
||||||
@@ -70,7 +69,9 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
|
|||||||
|
|
||||||
public get filteredFields(): CustomField[] {
|
public get filteredFields(): CustomField[] {
|
||||||
return this.unusedFields.filter(
|
return this.unusedFields.filter(
|
||||||
(f) => !this.filterText || matchesSearchText(f.name, this.filterText)
|
(f) =>
|
||||||
|
!this.filterText ||
|
||||||
|
f.name.toLowerCase().includes(this.filterText.toLowerCase())
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -63,7 +63,6 @@
|
|||||||
[(ngModel)]="atom.value"
|
[(ngModel)]="atom.value"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[virtualScroll]="getSelectOptionsForField(atom.field)?.length > 100"
|
[virtualScroll]="getSelectOptionsForField(atom.field)?.length > 100"
|
||||||
[searchFn]="selectOptionSearchFn"
|
|
||||||
(mousedown)="$event.stopImmediatePropagation()"
|
(mousedown)="$event.stopImmediatePropagation()"
|
||||||
></ng-select>
|
></ng-select>
|
||||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) {
|
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) {
|
||||||
@@ -75,14 +74,13 @@
|
|||||||
|
|
||||||
<ng-template #queryAtom let-atom="atom">
|
<ng-template #queryAtom let-atom="atom">
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<ng-select #fieldSelects
|
<ng-select
|
||||||
class="paperless-input-select"
|
class="paperless-input-select"
|
||||||
[items]="customFields"
|
[items]="customFields"
|
||||||
[(ngModel)]="atom.field"
|
[(ngModel)]="atom.field"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
bindLabel="name"
|
bindLabel="name"
|
||||||
bindValue="id"
|
bindValue="id"
|
||||||
[searchFn]="customFieldSearchFn"
|
|
||||||
(mousedown)="$event.stopImmediatePropagation()"
|
(mousedown)="$event.stopImmediatePropagation()"
|
||||||
></ng-select>
|
></ng-select>
|
||||||
<select class="w-25 form-select" [(ngModel)]="atom.operator" [disabled]="disabled">
|
<select class="w-25 form-select" [(ngModel)]="atom.operator" [disabled]="disabled">
|
||||||
@@ -127,7 +125,6 @@
|
|||||||
[(ngModel)]="atom.value"
|
[(ngModel)]="atom.value"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[multiple]="true"
|
[multiple]="true"
|
||||||
[searchFn]="selectOptionSearchFn"
|
|
||||||
(mousedown)="$event.stopImmediatePropagation()"
|
(mousedown)="$event.stopImmediatePropagation()"
|
||||||
></ng-select>
|
></ng-select>
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -210,12 +210,13 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
expect(component.name).toBe('test_title')
|
expect(component.name).toBe('test_title')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should add a default atom on open', fakeAsync(() => {
|
it('should add a default atom on open and focus the select field', fakeAsync(() => {
|
||||||
expect(component.selectionModel.queries.length).toBe(0)
|
expect(component.selectionModel.queries.length).toBe(0)
|
||||||
component.onOpenChange(true)
|
component.onOpenChange(true)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick()
|
tick()
|
||||||
expect(component.selectionModel.queries.length).toBe(1)
|
expect(component.selectionModel.queries.length).toBe(1)
|
||||||
|
expect(window.document.activeElement.tagName).toBe('INPUT')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('CustomFieldQueriesModel', () => {
|
describe('CustomFieldQueriesModel', () => {
|
||||||
|
|||||||
-9
@@ -36,7 +36,6 @@ import {
|
|||||||
CustomFieldQueryExpression,
|
CustomFieldQueryExpression,
|
||||||
} from 'src/app/utils/custom-field-query-element'
|
} from 'src/app/utils/custom-field-query-element'
|
||||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||||
import { matchesSearchText } from 'src/app/utils/text-search'
|
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||||
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
||||||
@@ -282,14 +281,6 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
|
|
||||||
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
||||||
|
|
||||||
public customFieldSearchFn = (term: string, field: CustomField): boolean =>
|
|
||||||
matchesSearchText(field?.name, term)
|
|
||||||
|
|
||||||
public selectOptionSearchFn = (
|
|
||||||
term: string,
|
|
||||||
option: { id: string; label: string }
|
|
||||||
): boolean => matchesSearchText(option?.label, term)
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.selectionModel = new CustomFieldQueriesModel()
|
this.selectionModel = new CustomFieldQueriesModel()
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ describe('CorrespondentEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(CorrespondentEditDialogComponent)
|
fixture = TestBed.createComponent(CorrespondentEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ describe('CustomFieldEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(CustomFieldEditDialogComponent)
|
fixture = TestBed.createComponent(CustomFieldEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ describe('DocumentTypeEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(DocumentTypeEditDialogComponent)
|
fixture = TestBed.createComponent(DocumentTypeEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ describe('EditDialogComponent', () => {
|
|||||||
tagService = TestBed.inject(TagService)
|
tagService = TestBed.inject(TagService)
|
||||||
permissionsService = TestBed.inject(PermissionsService)
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set(currentUser as any)
|
settingsService.currentUser = currentUser
|
||||||
permissionsService.initialize([], currentUser as any)
|
permissionsService.initialize([], currentUser as any)
|
||||||
activeModal = TestBed.inject(NgbActiveModal)
|
activeModal = TestBed.inject(NgbActiveModal)
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
Output,
|
Output,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormGroup } from '@angular/forms'
|
import { FormGroup } from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -46,20 +45,10 @@ export abstract class EditDialogComponent<
|
|||||||
protected settingsService = inject(SettingsService)
|
protected settingsService = inject(SettingsService)
|
||||||
protected permissionsService = inject(PermissionsService)
|
protected permissionsService = inject(PermissionsService)
|
||||||
|
|
||||||
private usersSignal = signal<User[]>(undefined)
|
users: User[]
|
||||||
|
|
||||||
private dialogModeSignal = signal(EditDialogMode.CREATE, {
|
|
||||||
equal: () => false,
|
|
||||||
})
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
get dialogMode(): EditDialogMode {
|
dialogMode: EditDialogMode = EditDialogMode.CREATE
|
||||||
return this.dialogModeSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set dialogMode(dialogMode: EditDialogMode) {
|
|
||||||
this.dialogModeSignal.set(dialogMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
object: T
|
object: T
|
||||||
@@ -70,43 +59,11 @@ export abstract class EditDialogComponent<
|
|||||||
@Output()
|
@Output()
|
||||||
failed = new EventEmitter()
|
failed = new EventEmitter()
|
||||||
|
|
||||||
private networkActiveSignal = signal(false)
|
networkActive = false
|
||||||
|
|
||||||
private closeEnabledSignal = signal(false)
|
closeEnabled = false
|
||||||
|
|
||||||
private errorSignal = signal<any>(null)
|
error = null
|
||||||
|
|
||||||
get users(): User[] {
|
|
||||||
return this.usersSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set users(users: User[]) {
|
|
||||||
this.usersSignal.set(users)
|
|
||||||
}
|
|
||||||
|
|
||||||
get networkActive(): boolean {
|
|
||||||
return this.networkActiveSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set networkActive(networkActive: boolean) {
|
|
||||||
this.networkActiveSignal.set(networkActive)
|
|
||||||
}
|
|
||||||
|
|
||||||
get closeEnabled(): boolean {
|
|
||||||
return this.closeEnabledSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set closeEnabled(closeEnabled: boolean) {
|
|
||||||
this.closeEnabledSignal.set(closeEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
get error(): any {
|
|
||||||
return this.errorSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set error(error: any) {
|
|
||||||
this.errorSignal.set(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract getForm(): FormGroup
|
abstract getForm(): FormGroup
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ describe('GroupEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(GroupEditDialogComponent)
|
fixture = TestBed.createComponent(GroupEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|||||||
+2
-2
@@ -25,8 +25,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<div class="m-0 me-2">
|
<div class="m-0 me-2">
|
||||||
@if (testResult()) {
|
@if (testResult) {
|
||||||
<ngb-alert #testResultAlert [type]="testResult()" class="mb-0 py-2" (closed)="testResult.set(null)">{{testResultMessage}}</ngb-alert>
|
<ngb-alert #testResultAlert [type]="testResult" class="mb-0 py-2" (closed)="testResult = null">{{testResultMessage}}</ngb-alert>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-outline-primary" (click)="test()" [disabled]="networkActive || testActive">
|
<button type="button" class="btn btn-outline-primary" (click)="test()" [disabled]="networkActive || testActive">
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ describe('MailAccountEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(MailAccountEditDialogComponent)
|
fixture = TestBed.createComponent(MailAccountEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|||||||
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
import { Component, ViewChild, inject, signal } from '@angular/core'
|
import { Component, ViewChild, inject } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -38,7 +38,7 @@ const IMAP_SECURITY_OPTIONS = [
|
|||||||
})
|
})
|
||||||
export class MailAccountEditDialogComponent extends EditDialogComponent<MailAccount> {
|
export class MailAccountEditDialogComponent extends EditDialogComponent<MailAccount> {
|
||||||
testActive: boolean = false
|
testActive: boolean = false
|
||||||
readonly testResult = signal<string>(undefined)
|
testResult: string
|
||||||
alertTimeout
|
alertTimeout
|
||||||
|
|
||||||
@ViewChild('testResultAlert', { static: false }) testResultAlert: NgbAlert
|
@ViewChild('testResultAlert', { static: false }) testResultAlert: NgbAlert
|
||||||
@@ -77,7 +77,7 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<MailAcco
|
|||||||
|
|
||||||
test() {
|
test() {
|
||||||
this.testActive = true
|
this.testActive = true
|
||||||
this.testResult.set(null)
|
this.testResult = null
|
||||||
clearTimeout(this.alertTimeout)
|
clearTimeout(this.alertTimeout)
|
||||||
const mailService = this.service as MailAccountService
|
const mailService = this.service as MailAccountService
|
||||||
const newObject = Object.assign(
|
const newObject = Object.assign(
|
||||||
@@ -87,19 +87,19 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<MailAcco
|
|||||||
mailService.test(newObject).subscribe({
|
mailService.test(newObject).subscribe({
|
||||||
next: (result: { success: boolean }) => {
|
next: (result: { success: boolean }) => {
|
||||||
this.testActive = false
|
this.testActive = false
|
||||||
this.testResult.set(result.success ? 'success' : 'danger')
|
this.testResult = result.success ? 'success' : 'danger'
|
||||||
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
|
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.testActive = false
|
this.testActive = false
|
||||||
this.testResult.set('danger')
|
this.testResult = 'danger'
|
||||||
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
|
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
get testResultMessage() {
|
get testResultMessage() {
|
||||||
return this.testResult() === 'success'
|
return this.testResult === 'success'
|
||||||
? $localize`Successfully connected to the mail server`
|
? $localize`Successfully connected to the mail server`
|
||||||
: $localize`Unable to connect to the mail server`
|
: $localize`Unable to connect to the mail server`
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -75,7 +75,7 @@ describe('MailRuleEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(MailRuleEditDialogComponent)
|
fixture = TestBed.createComponent(MailRuleEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|||||||
+4
-28
@@ -1,4 +1,4 @@
|
|||||||
import { Component, inject, signal } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -158,33 +158,9 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
|
|||||||
private correspondentService: CorrespondentService
|
private correspondentService: CorrespondentService
|
||||||
private documentTypeService: DocumentTypeService
|
private documentTypeService: DocumentTypeService
|
||||||
|
|
||||||
private accountsSignal = signal<MailAccount[]>(undefined)
|
accounts: MailAccount[]
|
||||||
private correspondentsSignal = signal<Correspondent[]>(undefined)
|
correspondents: Correspondent[]
|
||||||
private documentTypesSignal = signal<DocumentType[]>(undefined)
|
documentTypes: DocumentType[]
|
||||||
|
|
||||||
get accounts(): MailAccount[] {
|
|
||||||
return this.accountsSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set accounts(accounts: MailAccount[]) {
|
|
||||||
this.accountsSignal.set(accounts)
|
|
||||||
}
|
|
||||||
|
|
||||||
get correspondents(): Correspondent[] {
|
|
||||||
return this.correspondentsSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set correspondents(correspondents: Correspondent[]) {
|
|
||||||
this.correspondentsSignal.set(correspondents)
|
|
||||||
}
|
|
||||||
|
|
||||||
get documentTypes(): DocumentType[] {
|
|
||||||
return this.documentTypesSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set documentTypes(documentTypes: DocumentType[]) {
|
|
||||||
this.documentTypesSignal.set(documentTypes)
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ describe('StoragePathEditDialogComponent', () => {
|
|||||||
documentService = TestBed.inject(DocumentService)
|
documentService = TestBed.inject(DocumentService)
|
||||||
fixture = TestBed.createComponent(StoragePathEditDialogComponent)
|
fixture = TestBed.createComponent(StoragePathEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|||||||
+3
-3
@@ -67,11 +67,11 @@ export class StoragePathEditDialogComponent
|
|||||||
private testDocument: Document
|
private testDocument: Document
|
||||||
public testResult: string
|
public testResult: string
|
||||||
public testFailed: boolean = false
|
public testFailed: boolean = false
|
||||||
|
public loading = false
|
||||||
public testLoading = false
|
public testLoading = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.loading.set(false)
|
|
||||||
this.service = inject(StoragePathService)
|
this.service = inject(StoragePathService)
|
||||||
this.userService = inject(UserService)
|
this.userService = inject(UserService)
|
||||||
this.settingsService = inject(SettingsService)
|
this.settingsService = inject(SettingsService)
|
||||||
@@ -138,7 +138,7 @@ export class StoragePathEditDialogComponent
|
|||||||
this.documentsInput$.pipe(
|
this.documentsInput$.pipe(
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
tap(() => this.loading.set(true)),
|
tap(() => (this.loading = true)),
|
||||||
switchMap((title) =>
|
switchMap((title) =>
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.listFiltered(
|
.listFiltered(
|
||||||
@@ -152,7 +152,7 @@ export class StoragePathEditDialogComponent
|
|||||||
.pipe(
|
.pipe(
|
||||||
map((result) => result.results),
|
map((result) => result.results),
|
||||||
catchError(() => of([])), // empty on error
|
catchError(() => of([])), // empty on error
|
||||||
tap(() => this.loading.set(false))
|
tap(() => (this.loading = false))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ describe('TagEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(TagEditDialogComponent)
|
fixture = TestBed.createComponent(TagEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ describe('UserEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(UserEditDialogComponent)
|
fixture = TestBed.createComponent(UserEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
permissionsService = TestBed.inject(PermissionsService)
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|||||||
+5
-29
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, inject, signal } from '@angular/core'
|
import { Component, OnInit, inject } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -40,33 +40,9 @@ export class UserEditDialogComponent
|
|||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
private groupsService: GroupService
|
private groupsService: GroupService
|
||||||
|
|
||||||
private groupsSignal = signal<Group[]>(undefined)
|
groups: Group[]
|
||||||
private passwordIsSetSignal = signal(false)
|
passwordIsSet: boolean = false
|
||||||
private totpLoadingSignal = signal(false)
|
public totpLoading: boolean = false
|
||||||
|
|
||||||
get groups(): Group[] {
|
|
||||||
return this.groupsSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set groups(groups: Group[]) {
|
|
||||||
this.groupsSignal.set(groups)
|
|
||||||
}
|
|
||||||
|
|
||||||
get passwordIsSet(): boolean {
|
|
||||||
return this.passwordIsSetSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set passwordIsSet(passwordIsSet: boolean) {
|
|
||||||
this.passwordIsSetSignal.set(passwordIsSet)
|
|
||||||
}
|
|
||||||
|
|
||||||
public get totpLoading(): boolean {
|
|
||||||
return this.totpLoadingSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
public set totpLoading(totpLoading: boolean) {
|
|
||||||
this.totpLoadingSignal.set(totpLoading)
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -127,7 +103,7 @@ export class UserEditDialogComponent
|
|||||||
if (!groupsVal) return []
|
if (!groupsVal) return []
|
||||||
else
|
else
|
||||||
return groupsVal.flatMap(
|
return groupsVal.flatMap(
|
||||||
(id) => this.groups?.find((g) => g.id == id)?.permissions
|
(id) => this.groups.find((g) => g.id == id)?.permissions
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -187,7 +187,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(WorkflowEditDialogComponent)
|
fixture = TestBed.createComponent(WorkflowEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|||||||
+9
-74
@@ -4,7 +4,7 @@ import {
|
|||||||
moveItemInArray,
|
moveItemInArray,
|
||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { NgTemplateOutlet } from '@angular/common'
|
import { NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, OnInit, inject, signal } from '@angular/core'
|
import { Component, OnInit, inject } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
AbstractControl,
|
AbstractControl,
|
||||||
FormArray,
|
FormArray,
|
||||||
@@ -476,73 +476,17 @@ export class WorkflowEditDialogComponent
|
|||||||
private mailRuleService: MailRuleService
|
private mailRuleService: MailRuleService
|
||||||
private customFieldsService: CustomFieldsService
|
private customFieldsService: CustomFieldsService
|
||||||
|
|
||||||
private templatesSignal = signal<Workflow[]>(undefined)
|
templates: Workflow[]
|
||||||
private correspondentsSignal = signal<Correspondent[]>(undefined)
|
correspondents: Correspondent[]
|
||||||
private documentTypesSignal = signal<DocumentType[]>(undefined)
|
documentTypes: DocumentType[]
|
||||||
private storagePathsSignal = signal<StoragePath[]>(undefined)
|
storagePaths: StoragePath[]
|
||||||
private mailRulesSignal = signal<MailRule[]>(undefined)
|
mailRules: MailRule[]
|
||||||
private customFieldsSignal = signal<CustomField[]>(undefined)
|
customFields: CustomField[]
|
||||||
private dateCustomFieldsSignal = signal<CustomField[]>(undefined)
|
dateCustomFields: CustomField[]
|
||||||
|
|
||||||
get templates(): Workflow[] {
|
|
||||||
return this.templatesSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set templates(templates: Workflow[]) {
|
|
||||||
this.templatesSignal.set(templates)
|
|
||||||
}
|
|
||||||
|
|
||||||
get correspondents(): Correspondent[] {
|
|
||||||
return this.correspondentsSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set correspondents(correspondents: Correspondent[]) {
|
|
||||||
this.correspondentsSignal.set(correspondents)
|
|
||||||
}
|
|
||||||
|
|
||||||
get documentTypes(): DocumentType[] {
|
|
||||||
return this.documentTypesSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set documentTypes(documentTypes: DocumentType[]) {
|
|
||||||
this.documentTypesSignal.set(documentTypes)
|
|
||||||
}
|
|
||||||
|
|
||||||
get storagePaths(): StoragePath[] {
|
|
||||||
return this.storagePathsSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set storagePaths(storagePaths: StoragePath[]) {
|
|
||||||
this.storagePathsSignal.set(storagePaths)
|
|
||||||
}
|
|
||||||
|
|
||||||
get mailRules(): MailRule[] {
|
|
||||||
return this.mailRulesSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set mailRules(mailRules: MailRule[]) {
|
|
||||||
this.mailRulesSignal.set(mailRules)
|
|
||||||
}
|
|
||||||
|
|
||||||
get customFields(): CustomField[] {
|
|
||||||
return this.customFieldsSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set customFields(customFields: CustomField[]) {
|
|
||||||
this.customFieldsSignal.set(customFields)
|
|
||||||
}
|
|
||||||
|
|
||||||
get dateCustomFields(): CustomField[] {
|
|
||||||
return this.dateCustomFieldsSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set dateCustomFields(dateCustomFields: CustomField[]) {
|
|
||||||
this.dateCustomFieldsSignal.set(dateCustomFields)
|
|
||||||
}
|
|
||||||
|
|
||||||
expandedItem: number = null
|
expandedItem: number = null
|
||||||
|
|
||||||
private allowedActionTypesSignal = signal([])
|
private allowedActionTypes = []
|
||||||
|
|
||||||
private readonly triggerFilterOptionsMap = new WeakMap<
|
private readonly triggerFilterOptionsMap = new WeakMap<
|
||||||
FormArray,
|
FormArray,
|
||||||
@@ -1350,18 +1294,9 @@ export class WorkflowEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get actionTypeOptions() {
|
get actionTypeOptions() {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return this.allowedActionTypes
|
return this.allowedActionTypes
|
||||||
}
|
}
|
||||||
|
|
||||||
get allowedActionTypes() {
|
|
||||||
return this.allowedActionTypesSignal()
|
|
||||||
}
|
|
||||||
|
|
||||||
set allowedActionTypes(allowedActionTypes) {
|
|
||||||
this.allowedActionTypesSignal.set(allowedActionTypes)
|
|
||||||
}
|
|
||||||
|
|
||||||
getActionTypeOptionName(type: WorkflowActionType): string {
|
getActionTypeOptionName(type: WorkflowActionType): string {
|
||||||
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
|
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -1,8 +1,8 @@
|
|||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title" i18n>{
|
<h4 class="modal-title" id="modal-basic-title" i18n>{
|
||||||
documentIds().length,
|
documentIds.length,
|
||||||
plural,
|
plural,
|
||||||
=1 {Email Document} other {Email {{documentIds().length}} Documents}
|
=1 {Email Document} other {Email {{documentIds.length}} Documents}
|
||||||
}</h4>
|
}</h4>
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,11 +23,11 @@
|
|||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<div class="input-group-text flex-grow-1">
|
<div class="input-group-text flex-grow-1">
|
||||||
<input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion()" [ngModel]="useArchiveVersion()" (ngModelChange)="useArchiveVersion.set($event)">
|
<input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
|
||||||
<label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label>
|
<label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-outline-primary" (click)="emailDocuments()" [disabled]="loading() || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0">
|
<button type="submit" class="btn btn-outline-primary" (click)="emailDocuments()" [disabled]="loading || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0">
|
||||||
@if (loading()) {
|
@if (loading) {
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
}
|
}
|
||||||
<ng-container i18n>Send email</ng-container>
|
<ng-container i18n>Send email</ng-container>
|
||||||
|
|||||||
+8
-9
@@ -36,24 +36,23 @@ describe('EmailDocumentDialogComponent', () => {
|
|||||||
documentService = TestBed.inject(DocumentService)
|
documentService = TestBed.inject(DocumentService)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
component.documentIds.set([1])
|
component.documentIds = [1]
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set hasArchiveVersion and useArchiveVersion', () => {
|
it('should set hasArchiveVersion and useArchiveVersion', () => {
|
||||||
expect(component.hasArchiveVersion()).toBeTruthy()
|
expect(component.hasArchiveVersion).toBeTruthy()
|
||||||
expect(component.useArchiveVersion()).toBeTruthy()
|
expect(component.useArchiveVersion).toBeTruthy()
|
||||||
|
|
||||||
component.hasArchiveVersion.set(false)
|
component.hasArchiveVersion = false
|
||||||
fixture.detectChanges()
|
expect(component.hasArchiveVersion).toBeFalsy()
|
||||||
expect(component.hasArchiveVersion()).toBeFalsy()
|
expect(component.useArchiveVersion).toBeFalsy()
|
||||||
expect(component.useArchiveVersion()).toBeFalsy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support sending single document via email, showing error if needed', () => {
|
it('should support sending single document via email, showing error if needed', () => {
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
component.documentIds.set([1])
|
component.documentIds = [1]
|
||||||
component.emailAddress = 'hello@paperless-ngx.com'
|
component.emailAddress = 'hello@paperless-ngx.com'
|
||||||
component.emailSubject = 'Hello'
|
component.emailSubject = 'Hello'
|
||||||
component.emailMessage = 'World'
|
component.emailMessage = 'World'
|
||||||
@@ -74,7 +73,7 @@ describe('EmailDocumentDialogComponent', () => {
|
|||||||
it('should support sending multiple documents via email, showing appropriate messages', () => {
|
it('should support sending multiple documents via email, showing appropriate messages', () => {
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
component.documentIds.set([1, 2, 3])
|
component.documentIds = [1, 2, 3]
|
||||||
component.emailAddress = 'hello@paperless-ngx.com'
|
component.emailAddress = 'hello@paperless-ngx.com'
|
||||||
component.emailSubject = 'Hello'
|
component.emailSubject = 'Hello'
|
||||||
component.emailMessage = 'World'
|
component.emailMessage = 'World'
|
||||||
|
|||||||
+24
-14
@@ -1,4 +1,4 @@
|
|||||||
import { Component, effect, inject, signal } from '@angular/core'
|
import { Component, Input, inject } from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
@@ -17,9 +17,22 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
|
|||||||
private documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
|
|
||||||
readonly documentIds = signal<number[]>(undefined)
|
@Input()
|
||||||
readonly hasArchiveVersion = signal(true)
|
documentIds: number[]
|
||||||
readonly useArchiveVersion = signal(true)
|
|
||||||
|
private _hasArchiveVersion: boolean = true
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
set hasArchiveVersion(value: boolean) {
|
||||||
|
this._hasArchiveVersion = value
|
||||||
|
this.useArchiveVersion = value
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasArchiveVersion(): boolean {
|
||||||
|
return this._hasArchiveVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
public useArchiveVersion: boolean = true
|
||||||
|
|
||||||
public emailAddress: string = ''
|
public emailAddress: string = ''
|
||||||
public emailSubject: string = ''
|
public emailSubject: string = ''
|
||||||
@@ -27,25 +40,22 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
effect(() => {
|
|
||||||
this.useArchiveVersion.set(this.hasArchiveVersion())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public emailDocuments() {
|
public emailDocuments() {
|
||||||
this.loading.set(true)
|
this.loading = true
|
||||||
this.documentService
|
this.documentService
|
||||||
.emailDocuments(
|
.emailDocuments(
|
||||||
this.documentIds(),
|
this.documentIds,
|
||||||
this.emailAddress,
|
this.emailAddress,
|
||||||
this.emailSubject,
|
this.emailSubject,
|
||||||
this.emailMessage,
|
this.emailMessage,
|
||||||
this.useArchiveVersion()
|
this.useArchiveVersion
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
this.emailAddress = ''
|
this.emailAddress = ''
|
||||||
this.emailSubject = ''
|
this.emailSubject = ''
|
||||||
this.emailMessage = ''
|
this.emailMessage = ''
|
||||||
@@ -53,9 +63,9 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
|
|||||||
this.toastService.showInfo($localize`Email sent`)
|
this.toastService.showInfo($localize`Email sent`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading.set(false)
|
this.loading = false
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
this.documentIds().length > 1
|
this.documentIds.length > 1
|
||||||
? $localize`Error emailing documents`
|
? $localize`Error emailing documents`
|
||||||
: $localize`Error emailing document`
|
: $localize`Error emailing document`
|
||||||
this.toastService.showError(errorMessage, e)
|
this.toastService.showError(errorMessage, e)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{title()}}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (key of hotkeys().entries(); track key[0]) {
|
@for (key of hotkeys.entries(); track key[0]) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ key[1] }}</td>
|
<td>{{ key[1] }}</td>
|
||||||
<td class="d-flex justify-content-end">
|
<td class="d-flex justify-content-end">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user