mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-20 01:33:22 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
507889f06a | ||
|
|
4971be9b37 | ||
|
|
219cda569c | ||
|
|
de227d0f71 | ||
|
|
5cdc82ae35 | ||
|
|
976eb3beaa | ||
|
|
6f1673e0ae |
+35
-37
@@ -1,51 +1,49 @@
|
||||
categories:
|
||||
- type: 'pre-include'
|
||||
when:
|
||||
- label: 'enhancement'
|
||||
- label: 'bug'
|
||||
- label: 'chore'
|
||||
- label: 'deployment'
|
||||
- label: 'translation'
|
||||
- label: 'dependencies'
|
||||
- label: 'documentation'
|
||||
- label: 'frontend'
|
||||
- label: 'backend'
|
||||
- label: 'ci-cd'
|
||||
- label: 'breaking-change'
|
||||
- label: 'notable'
|
||||
- type: 'pre-exclude'
|
||||
when:
|
||||
- label: 'skip-changelog'
|
||||
- title: 'Breaking Changes'
|
||||
when:
|
||||
- label: 'breaking-change'
|
||||
labels:
|
||||
- 'breaking-change'
|
||||
- title: 'Notable Changes'
|
||||
when:
|
||||
- label: 'notable'
|
||||
labels:
|
||||
- 'notable'
|
||||
- title: 'Features / Enhancements'
|
||||
when:
|
||||
- label: 'enhancement'
|
||||
labels:
|
||||
- 'enhancement'
|
||||
- title: 'Bug Fixes'
|
||||
when:
|
||||
- label: 'bug'
|
||||
labels:
|
||||
- 'bug'
|
||||
- title: 'Documentation'
|
||||
when:
|
||||
- label: 'documentation'
|
||||
labels:
|
||||
- 'documentation'
|
||||
- title: 'Maintenance'
|
||||
when:
|
||||
- label: 'chore'
|
||||
- label: 'deployment'
|
||||
- label: 'translation'
|
||||
- label: 'ci-cd'
|
||||
labels:
|
||||
- 'chore'
|
||||
- 'deployment'
|
||||
- 'translation'
|
||||
- 'ci-cd'
|
||||
- title: 'Dependencies'
|
||||
collapse-after: 3
|
||||
when:
|
||||
- label: 'dependencies'
|
||||
labels:
|
||||
- 'dependencies'
|
||||
- title: 'All App Changes'
|
||||
labels:
|
||||
- 'frontend'
|
||||
- 'backend'
|
||||
collapse-after: 1
|
||||
when:
|
||||
- label: 'frontend'
|
||||
- label: 'backend'
|
||||
include-labels:
|
||||
- 'enhancement'
|
||||
- 'bug'
|
||||
- 'chore'
|
||||
- 'deployment'
|
||||
- 'translation'
|
||||
- 'dependencies'
|
||||
- 'documentation'
|
||||
- 'frontend'
|
||||
- 'backend'
|
||||
- 'ci-cd'
|
||||
- 'breaking-change'
|
||||
- 'notable'
|
||||
exclude-labels:
|
||||
- 'skip-changelog'
|
||||
filter-by-commitish: true
|
||||
category-template: '### $TITLE'
|
||||
change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))'
|
||||
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
group: backend-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.12.x"
|
||||
DEFAULT_UV_VERSION: "0.11.x"
|
||||
NLTK_DATA: "/usr/share/nltk_data"
|
||||
permissions: {}
|
||||
jobs:
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
- name: Detect changes
|
||||
id: filter
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
with:
|
||||
base: ${{ steps.range.outputs.base }}
|
||||
ref: ${{ steps.range.outputs.ref }}
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Start containers
|
||||
@@ -96,11 +96,11 @@ jobs:
|
||||
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
@@ -169,16 +169,16 @@ jobs:
|
||||
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "${{ env.DEFAULT_PYTHON }}"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
ref-name: ${{ steps.ref.outputs.name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Determine ref name
|
||||
@@ -106,9 +106,9 @@ jobs:
|
||||
echo "repository=${repo_name}"
|
||||
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||
- name: Docker metadata
|
||||
id: docker-meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -182,29 +182,29 @@ jobs:
|
||||
echo "Downloaded digests:"
|
||||
ls -la /tmp/digests/
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Login to Docker Hub
|
||||
if: needs.build-arch.outputs.push-external == 'true'
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to Quay.io
|
||||
if: needs.build-arch.outputs.push-external == 'true'
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
||||
- name: Docker metadata
|
||||
id: docker-meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
|
||||
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.12.x"
|
||||
DEFAULT_UV_VERSION: "0.11.x"
|
||||
DEFAULT_PYTHON_VERSION: "3.12"
|
||||
jobs:
|
||||
changes:
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
- name: Detect changes
|
||||
id: filter
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
with:
|
||||
base: ${{ steps.range.outputs.base }}
|
||||
ref: ${{ steps.range.outputs.ref }}
|
||||
@@ -69,16 +69,16 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
- name: Detect changes
|
||||
id: filter
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
with:
|
||||
base: ${{ steps.range.outputs.base }}
|
||||
ref: ${{ steps.range.outputs.ref }}
|
||||
@@ -77,15 +77,15 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
version: 10
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -109,15 +109,15 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
version: 10
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -129,8 +129,8 @@ jobs:
|
||||
~/.pnpm-store
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Install dependencies
|
||||
run: cd src-ui && pnpm install --frozen-lockfile
|
||||
- name: Re-link Angular CLI
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
- name: Run lint
|
||||
run: cd src-ui && pnpm run lint
|
||||
unit-tests:
|
||||
@@ -148,15 +148,15 @@ jobs:
|
||||
shard-count: [4]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
version: 10
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -168,8 +168,8 @@ jobs:
|
||||
~/.pnpm-store
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Install dependencies
|
||||
run: cd src-ui && pnpm install --frozen-lockfile
|
||||
- name: Re-link Angular CLI
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
- name: Run Jest unit tests
|
||||
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
- name: Upload test results to Codecov
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
container: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||
@@ -203,15 +203,15 @@ jobs:
|
||||
shard-count: [2]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
version: 10
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -223,29 +223,32 @@ jobs:
|
||||
~/.pnpm-store
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Re-link Angular CLI
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
- name: Install dependencies
|
||||
run: cd src-ui && pnpm install --frozen-lockfile
|
||||
run: cd src-ui && pnpm install --no-frozen-lockfile
|
||||
- name: Run Playwright E2E tests
|
||||
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
frontend-build:
|
||||
name: Frontend Build
|
||||
bundle-analysis:
|
||||
name: Bundle Analysis
|
||||
needs: [changes, unit-tests, e2e-tests]
|
||||
if: needs.changes.outputs.frontend_changed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
environment: bundle-analysis
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
version: 10
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -257,19 +260,21 @@ jobs:
|
||||
~/.pnpm-store
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Install dependencies
|
||||
run: cd src-ui && pnpm install --frozen-lockfile
|
||||
- name: Build
|
||||
- name: Re-link Angular CLI
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
- name: Build and analyze
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
run: cd src-ui && pnpm run build --configuration=production
|
||||
gate:
|
||||
name: Frontend CI Gate
|
||||
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, frontend-build]
|
||||
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, bundle-analysis]
|
||||
if: always()
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- name: Check gate
|
||||
env:
|
||||
BUILD_RESULT: ${{ needs['frontend-build'].result }}
|
||||
BUNDLE_ANALYSIS_RESULT: ${{ needs['bundle-analysis'].result }}
|
||||
E2E_RESULT: ${{ needs['e2e-tests'].result }}
|
||||
FRONTEND_CHANGED: ${{ needs.changes.outputs.frontend_changed }}
|
||||
INSTALL_RESULT: ${{ needs['install-dependencies'].result }}
|
||||
@@ -301,8 +306,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${BUILD_RESULT}" != "success" ]]; then
|
||||
echo "::error::Frontend build job result: ${BUILD_RESULT}"
|
||||
if [[ "${BUNDLE_ANALYSIS_RESULT}" != "success" ]]; then
|
||||
echo "::error::Frontend bundle-analysis job result: ${BUNDLE_ANALYSIS_RESULT}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@ jobs:
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6
|
||||
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
|
||||
|
||||
@@ -8,7 +8,7 @@ concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.12.x"
|
||||
DEFAULT_UV_VERSION: "0.11.x"
|
||||
DEFAULT_PYTHON_VERSION: "3.12"
|
||||
permissions: {}
|
||||
jobs:
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
statuses: read
|
||||
steps:
|
||||
- name: Wait for Docker build
|
||||
uses: lewagon/wait-on-check-action@2271c86c146b96545b4e871b855e10ffa6f50773 # v1.9.0
|
||||
uses: lewagon/wait-on-check-action@96d9100b431964d10e0136aff8b9ccb92470505e # v1.8.0
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
check-name: 'Merge and Push Manifest'
|
||||
@@ -35,16 +35,16 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# ---- Frontend Build ----
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
version: 10
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
package-manager-cache: false
|
||||
@@ -55,11 +55,11 @@ jobs:
|
||||
# ---- Backend Setup ----
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: false
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
fi
|
||||
- name: Create release and changelog
|
||||
id: create-release
|
||||
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
||||
with:
|
||||
name: Paperless-ngx ${{ steps.get-version.outputs.version }}
|
||||
tag: ${{ steps.get-version.outputs.version }}
|
||||
@@ -202,17 +202,17 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: true # for pushing changelog branch
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: false
|
||||
|
||||
@@ -22,11 +22,11 @@ jobs:
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
|
||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
||||
semgrep:
|
||||
name: Semgrep CE
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -38,13 +38,13 @@ jobs:
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run Semgrep
|
||||
run: semgrep scan --config auto --sarif-output results.sarif
|
||||
- name: Upload results to GitHub code scanning
|
||||
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -34,12 +34,12 @@ jobs:
|
||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# 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.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
@@ -17,12 +17,12 @@ jobs:
|
||||
environment: translation-sync
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||
persist-credentials: false
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
with:
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
steps:
|
||||
- name: Label PR by file path or branch name
|
||||
# see .github/labeler.yml for the labeler config
|
||||
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- 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'
|
||||
steps:
|
||||
- name: Label PR with release-drafter
|
||||
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
days-before-stale: 7
|
||||
days-before-close: 14
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.12.x"
|
||||
DEFAULT_UV_VERSION: "0.11.x"
|
||||
jobs:
|
||||
generate-translate-strings:
|
||||
name: Generate Translation Strings
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
env:
|
||||
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
|
||||
with:
|
||||
@@ -23,13 +23,13 @@ jobs:
|
||||
persist-credentials: true # for pushing translation branch
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -qq --no-install-recommends gettext
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
@@ -45,9 +45,9 @@ jobs:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
version: 10
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
@@ -61,13 +61,16 @@ jobs:
|
||||
~/.cache
|
||||
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Install frontend dependencies
|
||||
run: cd src-ui && pnpm install --frozen-lockfile
|
||||
if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
|
||||
run: cd src-ui && pnpm install
|
||||
- name: Re-link Angular cli
|
||||
run: cd src-ui && pnpm link @angular/cli
|
||||
- name: Generate frontend translation strings
|
||||
run: |
|
||||
cd src-ui
|
||||
pnpm run ng extract-i18n
|
||||
- name: Commit changes
|
||||
uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0
|
||||
uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
|
||||
with:
|
||||
file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po'
|
||||
commit_message: "Auto translate strings"
|
||||
|
||||
@@ -29,7 +29,7 @@ repos:
|
||||
- id: check-case-conflict
|
||||
- id: detect-private-key
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.4.3
|
||||
rev: v2.4.2
|
||||
hooks:
|
||||
- id: codespell
|
||||
additional_dependencies: [tomli]
|
||||
@@ -38,7 +38,7 @@ repos:
|
||||
- json
|
||||
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
||||
- repo: https://github.com/rbubley/mirrors-prettier
|
||||
rev: 'v3.9.6'
|
||||
rev: 'v3.9.4'
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or:
|
||||
@@ -46,16 +46,16 @@ repos:
|
||||
- ts
|
||||
- markdown
|
||||
additional_dependencies:
|
||||
- prettier@3.9.6
|
||||
- prettier@3.9.4
|
||||
- 'prettier-plugin-organize-imports@4.3.0'
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.16.1
|
||||
rev: v0.16.0
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: "v2.26.0"
|
||||
rev: "v2.25.1"
|
||||
hooks:
|
||||
- id: pyproject-fmt
|
||||
additional_dependencies: [tomli]
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ RUN set -eux \
|
||||
# Purpose: Installs s6-overlay and rootfs
|
||||
# Comments:
|
||||
# - Don't leave anything extra in here either
|
||||
FROM ghcr.io/astral-sh/uv:0.12.5-python3.14-trixie-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.11.28-python3.12-trixie-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
greenmail:
|
||||
image: docker.io/greenmail/standalone:2.1.11
|
||||
image: docker.io/greenmail/standalone:2.1.9
|
||||
hostname: greenmail
|
||||
container_name: greenmail
|
||||
environment:
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
- "3143:3143" # IMAP
|
||||
restart: unless-stopped
|
||||
nginx:
|
||||
image: docker.io/nginx:1.31.3-alpine
|
||||
image: docker.io/nginx:1.31.2-alpine
|
||||
hostname: nginx
|
||||
container_name: nginx
|
||||
ports:
|
||||
|
||||
@@ -68,14 +68,7 @@
|
||||
<!-- <policy domain="resource" name="thread" value="4"/> -->
|
||||
<!-- <policy domain="resource" name="throttle" value="0"/> -->
|
||||
<!-- <policy domain="resource" name="time" value="3600"/> -->
|
||||
<!-- Paperless does not process SVG or ImageMagick scripting formats. -->
|
||||
<policy domain="coder" rights="none" pattern="SVG" />
|
||||
<policy domain="coder" rights="none" pattern="SVGZ" />
|
||||
<policy domain="coder" rights="none" pattern="MSVG" />
|
||||
<policy domain="coder" rights="none" pattern="RSVG" />
|
||||
<policy domain="coder" rights="none" pattern="MSL" />
|
||||
<policy domain="coder" rights="none" pattern="MVG" />
|
||||
<policy domain="coder" rights="none" pattern="EPHEMERAL" />
|
||||
<!-- <policy domain="coder" rights="none" pattern="MVG" /> -->
|
||||
<!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> -->
|
||||
<!-- <policy domain="delegate" rights="none" pattern="HTTPS" /> -->
|
||||
<!-- <policy domain="path" rights="none" pattern="@*" /> -->
|
||||
@@ -85,6 +78,8 @@
|
||||
<!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
|
||||
<!-- <policy domain="system" name="shred" value="2"/> -->
|
||||
<!-- <policy domain="system" name="precision" value="6"/> -->
|
||||
<!-- not needed due to the need to use explicitly by mvg: -->
|
||||
<!-- <policy domain="delegate" rights="none" pattern="MVG" /> -->
|
||||
<!-- use curl -->
|
||||
<policy domain="delegate" rights="none" pattern="URL" />
|
||||
<policy domain="delegate" rights="none" pattern="HTTPS" />
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/command/with-contenv /usr/bin/bash
|
||||
# shellcheck shell=bash
|
||||
|
||||
declare -r log_prefix="[init-llmindex-migrate]"
|
||||
|
||||
echo "${log_prefix} Checking for pending LLM index migrations..."
|
||||
cd "${PAPERLESS_SRC_DIR}"
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
python3 manage.py document_llmindex migrate
|
||||
else
|
||||
s6-setuidgid paperless python3 manage.py document_llmindex migrate
|
||||
fi
|
||||
@@ -1 +0,0 @@
|
||||
oneshot
|
||||
@@ -1 +0,0 @@
|
||||
/etc/s6-overlay/s6-rc.d/init-llmindex-migrate/run
|
||||
@@ -61,7 +61,7 @@ def replace_with_symlinks(
|
||||
total_duplicates = 0
|
||||
space_saved = 0
|
||||
|
||||
for file_hash, file_list in duplicate_groups.items():
|
||||
for file_list in duplicate_groups.values():
|
||||
# Keep the first file as the original, replace others with symlinks
|
||||
original_file = file_list[0]
|
||||
duplicates = file_list[1:]
|
||||
|
||||
+1
-44
@@ -151,23 +151,6 @@ the background.
|
||||
|
||||
### Bare Metal Route {#bare-metal-updating}
|
||||
|
||||
!!! warning
|
||||
|
||||
Extracting a new release archive on top of an existing installation
|
||||
(e.g. `tar -xf paperless-ngx-vX.Y.Z.tar.xz -C /opt/paperless`) only adds
|
||||
and overwrites files -- it does not remove files that were deleted in
|
||||
the new release. Leftover files from an old version, such as
|
||||
superseded database migrations, can remain on disk and cause errors
|
||||
like `NodeNotFoundError` during `manage.py migrate`.
|
||||
|
||||
Before unpacking a new release over an existing bare-metal
|
||||
installation, remove the previous source tree first (everything
|
||||
except your `media`, `data`, and `consume` directories and your
|
||||
`paperless.conf`/`.env`), or unpack into a fresh directory and move
|
||||
your persistent data and configuration over. Installations updated via
|
||||
`git pull` on a clean checkout are not affected, since Git removes
|
||||
files that no longer exist upstream.
|
||||
|
||||
After grabbing the new release and unpacking the contents, do the
|
||||
following:
|
||||
|
||||
@@ -212,16 +195,6 @@ following:
|
||||
This is a no-op if the index is already up to date, so it is safe to
|
||||
run on every upgrade.
|
||||
|
||||
5. Migrate the LLM index if needed.
|
||||
|
||||
```shell-session
|
||||
cd src
|
||||
python3 manage.py document_llmindex migrate
|
||||
```
|
||||
|
||||
This is a no-op if the index schema is already current, so it is safe
|
||||
to run on every upgrade.
|
||||
|
||||
### Database Upgrades
|
||||
|
||||
Paperless-ngx is compatible with Django-supported versions of PostgreSQL and MariaDB and it is generally
|
||||
@@ -299,8 +272,6 @@ optional arguments:
|
||||
-sm, --split-manifest
|
||||
-z, --zip
|
||||
-zn, --zip-name
|
||||
--zip-compression
|
||||
--zip-compression-level
|
||||
--data-only
|
||||
--no-progress-bar
|
||||
--passphrase
|
||||
@@ -363,19 +334,6 @@ If `-z` or `--zip` is provided, the export will be a zip file
|
||||
in the target directory, named according to the current local date or the
|
||||
value set in `-zn` or `--zip-name`.
|
||||
|
||||
The compression method for the zip can be set with `--zip-compression`
|
||||
(`stored`, `deflated` (default), `bzip2`, `lzma`, or `zstd`) and tuned with
|
||||
`--zip-compression-level` (deflated: 0–9, bzip2: 1–9, zstd: -22–22; ignored
|
||||
for `stored` and `lzma`). Both options require `--zip`.
|
||||
|
||||
!!! warning
|
||||
|
||||
`zstd` compression requires Python 3.14 or newer on **both** the machine
|
||||
creating the export and any machine importing it. An archive compressed with
|
||||
`zstd` (or `lzma`/`bzip2` where those modules are unavailable) cannot be
|
||||
imported on a runtime that lacks the codec; the importer will refuse it with
|
||||
a clear error. The default `deflated` is universally readable.
|
||||
|
||||
If `--data-only` is provided, only the database will be exported. This option is intended
|
||||
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
|
||||
|
||||
@@ -557,7 +515,7 @@ index is updated automatically on the schedule set by
|
||||
can manage it manually:
|
||||
|
||||
```
|
||||
document_llmindex {rebuild,update,compact,migrate}
|
||||
document_llmindex {rebuild,update,compact}
|
||||
```
|
||||
|
||||
Specify `rebuild` to build the index from scratch from all documents in the database. Use
|
||||
@@ -714,7 +672,6 @@ document_fuzzy_match [--ratio] [--processes N]
|
||||
| --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. |
|
||||
| --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes |
|
||||
| --delete | No | False | If provided, one document of a matched pair above the ratio will be deleted. |
|
||||
| --url | No | blank | If an instance URL is provided, the output table will show URLs to each documents instead of the document ID and name. |
|
||||
|
||||
!!! warning
|
||||
|
||||
|
||||
@@ -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.
|
||||
- `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/merge_as_versions/`: merges existing top-level documents as versions of a selected root. The JSON body must contain `documents` (at least two document IDs) and `root_document_id` (one of those IDs). When merging one source document, an optional `version_label` may be provided.
|
||||
- `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.
|
||||
|
||||
|
||||
@@ -1,204 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 3.0.5
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: accept Whoosh-era abbreviated relative-date units (yrs, mos, wks, etc) in search queries [@stumpylog](https://github.com/stumpylog) ([#13486](https://github.com/paperless-ngx/paperless-ngx/pull/13486))
|
||||
- Fix: fix edit dialog error change detection [@shamoon](https://github.com/shamoon) ([#13483](https://github.com/paperless-ngx/paperless-ngx/pull/13483))
|
||||
- Fix: key the AI suggestion cache by model and endpoint [@lunetics](https://github.com/lunetics) ([#13449](https://github.com/paperless-ngx/paperless-ngx/pull/13449))
|
||||
- Fix: validate custom field values in bulk operations [@shamoon](https://github.com/shamoon) ([#13457](https://github.com/paperless-ngx/paperless-ngx/pull/13457))
|
||||
- Fixhancement: better handle empty fields from AI suggestions [@shamoon](https://github.com/shamoon) ([#13454](https://github.com/paperless-ngx/paperless-ngx/pull/13454))
|
||||
- Fix: normalize monetary decimal symbol by locale [@shamoon](https://github.com/shamoon) ([#13427](https://github.com/paperless-ngx/paperless-ngx/pull/13427))
|
||||
- Fix: fold overflowing path text in sanity checker [@stumpylog](https://github.com/stumpylog) ([#13426](https://github.com/paperless-ngx/paperless-ngx/pull/13426))
|
||||
- Fix: correct delayed add field button for custom fields [@shamoon](https://github.com/shamoon) ([#13424](https://github.com/paperless-ngx/paperless-ngx/pull/13424))
|
||||
- Fix: hide attributes collapse / show button without UI settings [@shamoon](https://github.com/shamoon) ([#13425](https://github.com/paperless-ngx/paperless-ngx/pull/13425))
|
||||
- Fix: consolidate born-digital PDF detection between archive decision and OCR [@stumpylog](https://github.com/stumpylog) ([#13409](https://github.com/paperless-ngx/paperless-ngx/pull/13409))
|
||||
- Fix: prevent scale vs page loop in pngx PDF viewer [@shamoon](https://github.com/shamoon) ([#13406](https://github.com/paperless-ngx/paperless-ngx/pull/13406))
|
||||
- Fix: handle hidden line breaks in email subjects when sending email [@shamoon](https://github.com/shamoon) ([#13402](https://github.com/paperless-ngx/paperless-ngx/pull/13402))
|
||||
- Fix: avoid NotSupportedError from document\_importer on MariaDB [@stumpylog](https://github.com/stumpylog) ([#13400](https://github.com/paperless-ngx/paperless-ngx/pull/13400))
|
||||
- Fix: exclude next-period start from relative date-range filters [@stumpylog](https://github.com/stumpylog) ([#13381](https://github.com/paperless-ngx/paperless-ngx/pull/13381))
|
||||
- Fix: close non-atomic db connections in before\_task\_publish [@shamoon](https://github.com/shamoon) ([#13366](https://github.com/paperless-ngx/paperless-ngx/pull/13366))
|
||||
- Chore/fix: refactor frontend task service [@shamoon](https://github.com/shamoon) ([#13365](https://github.com/paperless-ngx/paperless-ngx/pull/13365))
|
||||
- Fix: fix Enter selection in search autocomplete [@shamoon](https://github.com/shamoon) ([#13361](https://github.com/paperless-ngx/paperless-ngx/pull/13361))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Documentation: PAPERLESS\_CONSUMER\_IGNORE\_PATTERNS clarifications [@shamoon](https://github.com/shamoon) ([#13489](https://github.com/paperless-ngx/paperless-ngx/pull/13489))
|
||||
- Documentation: Add Password Removal workflow action documentation [@stumpylog](https://github.com/stumpylog) ([#13377](https://github.com/paperless-ngx/paperless-ngx/pull/13377))
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Chore(deps): Bump pymdown-extensions from 10.21.3 to 11.0 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13378](https://github.com/paperless-ngx/paperless-ngx/pull/13378))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>21 changes</summary>
|
||||
|
||||
- Fix: accept Whoosh-era abbreviated relative-date units (yrs, mos, wks, etc) in search queries [@stumpylog](https://github.com/stumpylog) ([#13486](https://github.com/paperless-ngx/paperless-ngx/pull/13486))
|
||||
- Fix: fix edit dialog error change detection [@shamoon](https://github.com/shamoon) ([#13483](https://github.com/paperless-ngx/paperless-ngx/pull/13483))
|
||||
- Performance: sqlite-vec point-delete for document chunks [@stumpylog](https://github.com/stumpylog) ([#13438](https://github.com/paperless-ngx/paperless-ngx/pull/13438))
|
||||
- Fix: key the AI suggestion cache by model and endpoint [@lunetics](https://github.com/lunetics) ([#13449](https://github.com/paperless-ngx/paperless-ngx/pull/13449))
|
||||
- Fix: validate custom field values in bulk operations [@shamoon](https://github.com/shamoon) ([#13457](https://github.com/paperless-ngx/paperless-ngx/pull/13457))
|
||||
- Fixhancement: better handle empty fields from AI suggestions [@shamoon](https://github.com/shamoon) ([#13454](https://github.com/paperless-ngx/paperless-ngx/pull/13454))
|
||||
- Performance: Use server side iterators during LLM index updating [@stumpylog](https://github.com/stumpylog) ([#13430](https://github.com/paperless-ngx/paperless-ngx/pull/13430))
|
||||
- Fix: normalize monetary decimal symbol by locale [@shamoon](https://github.com/shamoon) ([#13427](https://github.com/paperless-ngx/paperless-ngx/pull/13427))
|
||||
- Fix: fold overflowing path text in sanity checker [@stumpylog](https://github.com/stumpylog) ([#13426](https://github.com/paperless-ngx/paperless-ngx/pull/13426))
|
||||
- Fix: correct delayed add field button for custom fields [@shamoon](https://github.com/shamoon) ([#13424](https://github.com/paperless-ngx/paperless-ngx/pull/13424))
|
||||
- Fix: hide attributes collapse / show button without UI settings [@shamoon](https://github.com/shamoon) ([#13425](https://github.com/paperless-ngx/paperless-ngx/pull/13425))
|
||||
- Fix: consolidate born-digital PDF detection between archive decision and OCR [@stumpylog](https://github.com/stumpylog) ([#13409](https://github.com/paperless-ngx/paperless-ngx/pull/13409))
|
||||
- Fix: prevent scale vs page loop in pngx PDF viewer [@shamoon](https://github.com/shamoon) ([#13406](https://github.com/paperless-ngx/paperless-ngx/pull/13406))
|
||||
- Fix: handle hidden line breaks in email subjects when sending email [@shamoon](https://github.com/shamoon) ([#13402](https://github.com/paperless-ngx/paperless-ngx/pull/13402))
|
||||
- Fix: avoid NotSupportedError from document\_importer on MariaDB [@stumpylog](https://github.com/stumpylog) ([#13400](https://github.com/paperless-ngx/paperless-ngx/pull/13400))
|
||||
- Tweak: adjust doc details button toolbar flow [@shamoon](https://github.com/shamoon) ([#13382](https://github.com/paperless-ngx/paperless-ngx/pull/13382))
|
||||
- Fix: exclude next-period start from relative date-range filters [@stumpylog](https://github.com/stumpylog) ([#13381](https://github.com/paperless-ngx/paperless-ngx/pull/13381))
|
||||
- Chore(deps): Bump pymdown-extensions from 10.21.3 to 11.0 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13378](https://github.com/paperless-ngx/paperless-ngx/pull/13378))
|
||||
- Fix: close non-atomic db connections in before\_task\_publish [@shamoon](https://github.com/shamoon) ([#13366](https://github.com/paperless-ngx/paperless-ngx/pull/13366))
|
||||
- Chore/fix: refactor frontend task service [@shamoon](https://github.com/shamoon) ([#13365](https://github.com/paperless-ngx/paperless-ngx/pull/13365))
|
||||
- Fix: fix Enter selection in search autocomplete [@shamoon](https://github.com/shamoon) ([#13361](https://github.com/paperless-ngx/paperless-ngx/pull/13361))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.0.4
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: prevent pdfjs highlight scrolling from affecting the entire page [@shamoon](https://github.com/shamoon) ([#13355](https://github.com/paperless-ngx/paperless-ngx/pull/13355))
|
||||
- Fix: don't skip OCR/archive for tagged PDFs with no actual text [@stumpylog](https://github.com/stumpylog) ([#13351](https://github.com/paperless-ngx/paperless-ngx/pull/13351))
|
||||
- Performance: more efficient diacritic normalization in dropdown filtering [@shamoon](https://github.com/shamoon) ([#13347](https://github.com/paperless-ngx/paperless-ngx/pull/13347))
|
||||
- Fix: dedupe permission-visible documents when combined with multi-tag filters [@stumpylog](https://github.com/stumpylog) ([#13345](https://github.com/paperless-ngx/paperless-ngx/pull/13345))
|
||||
- Fixhancement: pass LLM output language to chat if specified [@shamoon](https://github.com/shamoon) ([#13340](https://github.com/paperless-ngx/paperless-ngx/pull/13340))
|
||||
- Fix: ensure preview reload on live changes [@shamoon](https://github.com/shamoon) ([#13321](https://github.com/paperless-ngx/paperless-ngx/pull/13321))
|
||||
- Fix: clamp all out out-of-range possible fields [@stumpylog](https://github.com/stumpylog) ([#13316](https://github.com/paperless-ngx/paperless-ngx/pull/13316))
|
||||
- Fix: add docstring to DocumentClassifierSchema for cleaner LLM tool description [@stumpylog](https://github.com/stumpylog) ([#13315](https://github.com/paperless-ngx/paperless-ngx/pull/13315))
|
||||
- Fix: guard build\_document\_node against stale FK on deleted correspondent/doc type [@stumpylog](https://github.com/stumpylog) ([#13318](https://github.com/paperless-ngx/paperless-ngx/pull/13318))
|
||||
- Fix: prevent search filter loss when closing document with Escape key [@shamoon](https://github.com/shamoon) ([#13317](https://github.com/paperless-ngx/paperless-ngx/pull/13317))
|
||||
- Fix: fix frontend permissions display for stats/system perms [@shamoon](https://github.com/shamoon) ([#13305](https://github.com/paperless-ngx/paperless-ngx/pull/13305))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Documentation: fix PAPERLESS\_AI\_LLM\_OUTPUT\_LANGUAGE heading level [@SuperSandro2000](https://github.com/SuperSandro2000) ([#13341](https://github.com/paperless-ngx/paperless-ngx/pull/13341))
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Chore: resolve npm provenance issues with chokidar and semver [@shamoon](https://github.com/shamoon) ([#13323](https://github.com/paperless-ngx/paperless-ngx/pull/13323))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>14 changes</summary>
|
||||
|
||||
- Fix: prevent pdfjs highlight scrolling from affecting the entire page [@shamoon](https://github.com/shamoon) ([#13355](https://github.com/paperless-ngx/paperless-ngx/pull/13355))
|
||||
- Fix: don't skip OCR/archive for tagged PDFs with no actual text [@stumpylog](https://github.com/stumpylog) ([#13351](https://github.com/paperless-ngx/paperless-ngx/pull/13351))
|
||||
- Performance: more efficient diacritic normalization in dropdown filtering [@shamoon](https://github.com/shamoon) ([#13347](https://github.com/paperless-ngx/paperless-ngx/pull/13347))
|
||||
- Performance: prefetch notes and custom fields for LLM index text building [@stumpylog](https://github.com/stumpylog) ([#13350](https://github.com/paperless-ngx/paperless-ngx/pull/13350))
|
||||
- Fix: dedupe permission-visible documents when combined with multi-tag filters [@stumpylog](https://github.com/stumpylog) ([#13345](https://github.com/paperless-ngx/paperless-ngx/pull/13345))
|
||||
- Performance: Scope llm index updates to actually modified documents [@stumpylog](https://github.com/stumpylog) ([#13322](https://github.com/paperless-ngx/paperless-ngx/pull/13322))
|
||||
- Fixhancement: pass LLM output language to chat if specified [@shamoon](https://github.com/shamoon) ([#13340](https://github.com/paperless-ngx/paperless-ngx/pull/13340))
|
||||
- Chore: resolve npm provenance issues with chokidar and semver [@shamoon](https://github.com/shamoon) ([#13323](https://github.com/paperless-ngx/paperless-ngx/pull/13323))
|
||||
- Fix: ensure preview reload on live changes [@shamoon](https://github.com/shamoon) ([#13321](https://github.com/paperless-ngx/paperless-ngx/pull/13321))
|
||||
- Fix: clamp all out out-of-range possible fields [@stumpylog](https://github.com/stumpylog) ([#13316](https://github.com/paperless-ngx/paperless-ngx/pull/13316))
|
||||
- Fix: add docstring to DocumentClassifierSchema for cleaner LLM tool description [@stumpylog](https://github.com/stumpylog) ([#13315](https://github.com/paperless-ngx/paperless-ngx/pull/13315))
|
||||
- Fix: guard build\_document\_node against stale FK on deleted correspondent/doc type [@stumpylog](https://github.com/stumpylog) ([#13318](https://github.com/paperless-ngx/paperless-ngx/pull/13318))
|
||||
- Fix: prevent search filter loss when closing document with Escape key [@shamoon](https://github.com/shamoon) ([#13317](https://github.com/paperless-ngx/paperless-ngx/pull/13317))
|
||||
- Fix: fix frontend permissions display for stats/system perms [@shamoon](https://github.com/shamoon) ([#13305](https://github.com/paperless-ngx/paperless-ngx/pull/13305))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.0.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixhancement: PAPERLESS\_ALLAUTH\_TRUSTED\_PROXY\_COUNT [@shamoon](https://github.com/shamoon) ([#13281](https://github.com/paperless-ngx/paperless-ngx/pull/13281))
|
||||
- Fix: preserve document fields during Gotenberg conversion to PDF [@stumpylog](https://github.com/stumpylog) ([#13271](https://github.com/paperless-ngx/paperless-ngx/pull/13271))
|
||||
- Fix: Makes the email date aware as soon as possible during parsing [@stumpylog](https://github.com/stumpylog) ([#13266](https://github.com/paperless-ngx/paperless-ngx/pull/13266))
|
||||
- Fix: Handle a plain string as Celery sometimes provides for the traceback [@stumpylog](https://github.com/stumpylog) ([#13267](https://github.com/paperless-ngx/paperless-ngx/pull/13267))
|
||||
- Fix: Emit the torch index into the requirements.txt for people still using it [@stumpylog](https://github.com/stumpylog) ([#13265](https://github.com/paperless-ngx/paperless-ngx/pull/13265))
|
||||
- Fix: handle notes without a user when building the search index [@matthiasmast](https://github.com/matthiasmast) ([#13260](https://github.com/paperless-ngx/paperless-ngx/pull/13260))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Docs: warn bare-metal users about stale files when upgrading [@stumpylog](https://github.com/stumpylog) ([#13296](https://github.com/paperless-ngx/paperless-ngx/pull/13296))
|
||||
- Documentation: Add the NumPy CPU baseline increase to the migration guide [@stumpylog](https://github.com/stumpylog) ([#13269](https://github.com/paperless-ngx/paperless-ngx/pull/13269))
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Fix: Emit the torch index into the requirements.txt for people still using it [@stumpylog](https://github.com/stumpylog) ([#13265](https://github.com/paperless-ngx/paperless-ngx/pull/13265))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>6 changes</summary>
|
||||
|
||||
- Fixhancement: PAPERLESS\_ALLAUTH\_TRUSTED\_PROXY\_COUNT [@shamoon](https://github.com/shamoon) ([#13281](https://github.com/paperless-ngx/paperless-ngx/pull/13281))
|
||||
- Tweak: adjust top navbar wrapping when AI chat button visible [@shamoon](https://github.com/shamoon) ([#13280](https://github.com/paperless-ngx/paperless-ngx/pull/13280))
|
||||
- Fix: preserve document fields during Gotenberg conversion to PDF [@stumpylog](https://github.com/stumpylog) ([#13271](https://github.com/paperless-ngx/paperless-ngx/pull/13271))
|
||||
- Fix: Makes the email date aware as soon as possible during parsing [@stumpylog](https://github.com/stumpylog) ([#13266](https://github.com/paperless-ngx/paperless-ngx/pull/13266))
|
||||
- Fix: Handle a plain string as Celery sometimes provides for the traceback [@stumpylog](https://github.com/stumpylog) ([#13267](https://github.com/paperless-ngx/paperless-ngx/pull/13267))
|
||||
- Fix: handle notes without a user when building the search index [@matthiasmast](https://github.com/matthiasmast) ([#13260](https://github.com/paperless-ngx/paperless-ngx/pull/13260))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.0.2
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: fix broken migration in 3.0.1 [@shamoon](https://github.com/shamoon) ([#13242](https://github.com/paperless-ngx/paperless-ngx/pull/13242))
|
||||
|
||||
### All App Changes
|
||||
|
||||
- Fix: fix broken migration in 3.0.1 [@shamoon](https://github.com/shamoon) ([#13242](https://github.com/paperless-ngx/paperless-ngx/pull/13242))
|
||||
|
||||
## paperless-ngx 3.0.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: selection\_data re-derives the filtered document set 5 times over [@stumpylog](https://github.com/stumpylog) ([#13229](https://github.com/paperless-ngx/paperless-ngx/pull/13229))
|
||||
- Fix: exclude source document from its own RAG similarity results [@stumpylog](https://github.com/stumpylog) ([#13233](https://github.com/paperless-ngx/paperless-ngx/pull/13233))
|
||||
- Fix: split mailrule maximum\_age clamp into its own migration [@stumpylog](https://github.com/stumpylog) ([#13231](https://github.com/paperless-ngx/paperless-ngx/pull/13231))
|
||||
- Chore: mark yes in confirm button for translation [@shamoon](https://github.com/shamoon) ([#13225](https://github.com/paperless-ngx/paperless-ngx/pull/13225))
|
||||
- Fix: correct URL for W001 check [@shamoon](https://github.com/shamoon) ([#13220](https://github.com/paperless-ngx/paperless-ngx/pull/13220))
|
||||
- Fix: ensure create dialog uses correct attribute type [@shamoon](https://github.com/shamoon) ([#13221](https://github.com/paperless-ngx/paperless-ngx/pull/13221))
|
||||
- Fix: correct database engine from postgres to postgresql [@OberstVonGatow](https://github.com/OberstVonGatow) ([#13213](https://github.com/paperless-ngx/paperless-ngx/pull/13213))
|
||||
- Fix: remove unnecessary .distinct() dominating document list queries [@stumpylog](https://github.com/stumpylog) ([#13205](https://github.com/paperless-ngx/paperless-ngx/pull/13205))
|
||||
- Fix: also wrap non-breaking words in chat messages, handle whitespace [@shamoon](https://github.com/shamoon) ([#13211](https://github.com/paperless-ngx/paperless-ngx/pull/13211))
|
||||
- Fix: tag/custom-field document\_count scales badly with tag count [@stumpylog](https://github.com/stumpylog) ([#13203](https://github.com/paperless-ngx/paperless-ngx/pull/13203))
|
||||
- Fix: batch document user\_can\_change checks to avoid per-row N+1 [@stumpylog](https://github.com/stumpylog) ([#13204](https://github.com/paperless-ngx/paperless-ngx/pull/13204))
|
||||
- Fix: fix app title restoration [@shamoon](https://github.com/shamoon) ([#13208](https://github.com/paperless-ngx/paperless-ngx/pull/13208))
|
||||
- Fix: handle long wrapping titles in AI chat document list [@shamoon](https://github.com/shamoon) ([#13206](https://github.com/paperless-ngx/paperless-ngx/pull/13206))
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Chore(deps-dev): Bump postcss from 8.5.6 to 8.5.22 in /src/paperless\_mail/templates in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13236](https://github.com/paperless-ngx/paperless-ngx/pull/13236))
|
||||
- Chore(deps): Bump pyasn1 from 0.6.3 to 0.6.4 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13237](https://github.com/paperless-ngx/paperless-ngx/pull/13237))
|
||||
- Chore(deps): Bump the uv group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13189](https://github.com/paperless-ngx/paperless-ngx/pull/13189))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>17 changes</summary>
|
||||
|
||||
- Chore(deps-dev): Bump postcss from 8.5.6 to 8.5.22 in /src/paperless\_mail/templates in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13236](https://github.com/paperless-ngx/paperless-ngx/pull/13236))
|
||||
- Fix: selection\_data re-derives the filtered document set 5 times over [@stumpylog](https://github.com/stumpylog) ([#13229](https://github.com/paperless-ngx/paperless-ngx/pull/13229))
|
||||
- Chore: reword "Settings > Default permissions" description for clarity [@NotaInutilis](https://github.com/NotaInutilis) ([#13232](https://github.com/paperless-ngx/paperless-ngx/pull/13232))
|
||||
- Chore(deps): Bump pyasn1 from 0.6.3 to 0.6.4 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13237](https://github.com/paperless-ngx/paperless-ngx/pull/13237))
|
||||
- Chore(deps): Bump the uv group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13189](https://github.com/paperless-ngx/paperless-ngx/pull/13189))
|
||||
- Fix: exclude source document from its own RAG similarity results [@stumpylog](https://github.com/stumpylog) ([#13233](https://github.com/paperless-ngx/paperless-ngx/pull/13233))
|
||||
- Fix: split mailrule maximum\_age clamp into its own migration [@stumpylog](https://github.com/stumpylog) ([#13231](https://github.com/paperless-ngx/paperless-ngx/pull/13231))
|
||||
- Chore: mark yes in confirm button for translation [@shamoon](https://github.com/shamoon) ([#13225](https://github.com/paperless-ngx/paperless-ngx/pull/13225))
|
||||
- Fix: correct URL for W001 check [@shamoon](https://github.com/shamoon) ([#13220](https://github.com/paperless-ngx/paperless-ngx/pull/13220))
|
||||
- Fix: ensure create dialog uses correct attribute type [@shamoon](https://github.com/shamoon) ([#13221](https://github.com/paperless-ngx/paperless-ngx/pull/13221))
|
||||
- Fix: correct database engine from postgres to postgresql [@OberstVonGatow](https://github.com/OberstVonGatow) ([#13213](https://github.com/paperless-ngx/paperless-ngx/pull/13213))
|
||||
- Fix: remove unnecessary .distinct() dominating document list queries [@stumpylog](https://github.com/stumpylog) ([#13205](https://github.com/paperless-ngx/paperless-ngx/pull/13205))
|
||||
- Fix: also wrap non-breaking words in chat messages, handle whitespace [@shamoon](https://github.com/shamoon) ([#13211](https://github.com/paperless-ngx/paperless-ngx/pull/13211))
|
||||
- Fix: tag/custom-field document\_count scales badly with tag count [@stumpylog](https://github.com/stumpylog) ([#13203](https://github.com/paperless-ngx/paperless-ngx/pull/13203))
|
||||
- Fix: batch document user\_can\_change checks to avoid per-row N+1 [@stumpylog](https://github.com/stumpylog) ([#13204](https://github.com/paperless-ngx/paperless-ngx/pull/13204))
|
||||
- Fix: fix app title restoration [@shamoon](https://github.com/shamoon) ([#13208](https://github.com/paperless-ngx/paperless-ngx/pull/13208))
|
||||
- Fix: handle long wrapping titles in AI chat document list [@shamoon](https://github.com/shamoon) ([#13206](https://github.com/paperless-ngx/paperless-ngx/pull/13206))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.0.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
+17
-57
@@ -522,36 +522,22 @@ do CORS calls. Set this to your public domain name.
|
||||
fail2ban with log entries for failed authorization attempts. Value should be
|
||||
IP address(es).
|
||||
|
||||
By default, this setting also controls allauth's trusted proxy count,
|
||||
which is set to the number of proxies listed here. Override that default
|
||||
with [`PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT`](#PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT)
|
||||
when the list length does not match the number of proxy hops.
|
||||
This setting also controls allauth's
|
||||
[`ALLAUTH_TRUSTED_PROXY_COUNT`](https://docs.allauth.org/en/latest/account/configuration.html),
|
||||
which is set to the number of proxies listed here. Without this,
|
||||
allauth cannot determine the client IP address for rate limiting when
|
||||
running behind a reverse proxy, resulting in a `403 Forbidden` on login.
|
||||
|
||||
Defaults to empty string.
|
||||
|
||||
#### [`PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT=<integer>`](#PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT) {#PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT}
|
||||
|
||||
: Sets allauth's
|
||||
[`ALLAUTH_TRUSTED_PROXY_COUNT`](https://docs.allauth.org/en/latest/common/rate_limits.html#configuration).
|
||||
This is the number of trusted proxy **hops** represented in each
|
||||
`X-Forwarded-For` header, not the number of IP addresses through which those
|
||||
proxies may be reached. For example, a single dual-stack proxy is one hop even
|
||||
when its IPv4 and IPv6 addresses are both listed in
|
||||
[`PAPERLESS_TRUSTED_PROXIES`](#PAPERLESS_TRUSTED_PROXIES).
|
||||
|
||||
Only trust `X-Forwarded-For` when untrusted clients cannot connect directly
|
||||
to Paperless-ngx.
|
||||
|
||||
Defaults to the number of entries in `PAPERLESS_TRUSTED_PROXIES`.
|
||||
|
||||
#### [`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER=<header-name>`](#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER) {#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER}
|
||||
|
||||
: Sets allauth's
|
||||
[`ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](https://docs.allauth.org/en/latest/common/rate_limits.html#configuration).
|
||||
[`ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](https://docs.allauth.org/en/latest/account/configuration.html).
|
||||
Use this when your reverse proxy sets a dedicated header for the real
|
||||
client IP instead of `X-Forwarded-For`, for example `X-Real-IP` (nginx)
|
||||
or `CF-Connecting-IP` (Cloudflare). When set, this takes precedence over
|
||||
`PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT`.
|
||||
[`PAPERLESS_TRUSTED_PROXIES`](#PAPERLESS_TRUSTED_PROXIES).
|
||||
|
||||
Defaults to none.
|
||||
|
||||
@@ -776,24 +762,6 @@ system. See the corresponding
|
||||
|
||||
Defaults to "groups"
|
||||
|
||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP=<str>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP}
|
||||
|
||||
: Allows you to define a group name that, if present in the third-party authentication system's groups claim, will grant the user superuser (admin) and staff status in Paperless-ngx. If the group is not present in the claim, superuser status will be revoked upon next login.
|
||||
|
||||
!!! warning
|
||||
This is a direct reflection of the claim on every login, including the connecting user, with no exemption for the last remaining admin. If the group is missing or misconfigured on the identity provider side, the logged-in user will immediately lose their own superuser access. Fix the group membership or claim mapping on the identity provider to restore it. If the identity provider itself is unreachable or misconfigured and you are locked out, you can recover admin access locally with `manage.py createsuperuser`.
|
||||
|
||||
Defaults to None
|
||||
|
||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=<str>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP}
|
||||
|
||||
: Allows you to define a group name that, if present in the third-party authentication system's groups claim, will grant the user staff status in Paperless-ngx. If the group is not present in the claim and the user is not a superuser, staff status will be revoked upon next login.
|
||||
|
||||
!!! warning
|
||||
As with [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP), this is applied on every login unconditionally, including for the connecting user themselves.
|
||||
|
||||
Defaults to None
|
||||
|
||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
|
||||
|
||||
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
|
||||
@@ -950,8 +918,8 @@ for display in the web interface.
|
||||
| Document type | `never` | `auto` (default) | `always` |
|
||||
| -------------------------- | ------- | -------------------------- | -------- |
|
||||
| Scanned image (TIFF, JPEG) | No | **Yes** | Yes |
|
||||
| Image-based PDF | No | **Yes** (no embedded text) | Yes |
|
||||
| Born-digital PDF | No | No (has embedded text, optionally confirmed by tag) | Yes |
|
||||
| Image-based PDF | No | **Yes** (short/no text, untagged) | Yes |
|
||||
| Born-digital PDF | No | No (tagged or has embedded text) | Yes |
|
||||
| Plain text, email, HTML | No | No | No |
|
||||
| DOCX / ODT (via Tika) | Yes\* | Yes\* | Yes\* |
|
||||
|
||||
@@ -966,11 +934,10 @@ for display in the web interface.
|
||||
|
||||
!!! note
|
||||
|
||||
The **remote OCR parser** (Azure AI) also honors this setting: when
|
||||
no archive is requested (`never`, or `auto` with a born-digital PDF),
|
||||
the remote engine is skipped entirely and locally-extracted text is
|
||||
used instead, avoiding an unnecessary API call and a duplicate text
|
||||
layer.
|
||||
The **remote OCR parser** (Azure AI) always produces a searchable
|
||||
PDF and stores it as the archive copy, regardless of this setting.
|
||||
`ARCHIVE_FILE_GENERATION=never` has no effect when the remote
|
||||
parser handles a document.
|
||||
|
||||
#### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN}
|
||||
|
||||
@@ -1215,7 +1182,7 @@ should be a valid crontab(5) expression describing when to run.
|
||||
|
||||
: If set to the string "disable", no emails will be fetched automatically.
|
||||
|
||||
Defaults to every ten minutes, with an installation-specific minute offset.
|
||||
Defaults to `*/10 * * * *` or every ten minutes.
|
||||
|
||||
#### [`PAPERLESS_TRAIN_TASK_CRON=<cron expression>`](#PAPERLESS_TRAIN_TASK_CRON) {#PAPERLESS_TRAIN_TASK_CRON}
|
||||
|
||||
@@ -1379,16 +1346,13 @@ don't exist yet.
|
||||
#### [`PAPERLESS_CONSUMER_IGNORE_PATTERNS=<json>`](#PAPERLESS_CONSUMER_IGNORE_PATTERNS) {#PAPERLESS_CONSUMER_IGNORE_PATTERNS}
|
||||
|
||||
: Additional regex patterns for files to ignore in the consumption directory. Patterns are matched against filenames only (not full paths)
|
||||
using Python's `re.search()`. Use `^` to anchor a pattern to the start of the filename and `$` to anchor it to the end.
|
||||
using Python's `re.match()`, which anchors at the start of the filename.
|
||||
|
||||
See the [watchfiles documentation](https://watchfiles.helpmanual.io/api/filters/#watchfiles.BaseFilter.ignore_entity_patterns)
|
||||
|
||||
This setting is for additional patterns beyond the built-in defaults. Common system files and directories are already ignored automatically.
|
||||
The patterns will be compiled via Python's standard `re` module.
|
||||
|
||||
These are regular expressions, not glob patterns. For example, the glob pattern `._*` does not mean "starts with `._`" when used as a
|
||||
regular expression; it matches nearly any non-empty filename. Use `^\._.*` for that behavior instead.
|
||||
|
||||
Example custom patterns:
|
||||
|
||||
```json
|
||||
@@ -1403,11 +1367,7 @@ using Python's `re.search()`. Use `^` to anchor a pattern to the start of the fi
|
||||
|
||||
Defaults to `[]` (empty list, uses only built-in defaults).
|
||||
|
||||
The built-in file patterns are equivalent to the following regular expressions and cannot be overridden:
|
||||
|
||||
```json
|
||||
["^\\.DS_Store$", "^\\.DS_STORE$", "^\\._.*", "^desktop\\.ini$", "^Thumbs\\.db$"]
|
||||
```
|
||||
The default ignores are `[.DS_Store, .DS_STORE, ._*, desktop.ini, Thumbs.db]` and cannot be overridden.
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_IGNORE_DIRS=<json>`](#PAPERLESS_CONSUMER_IGNORE_DIRS) {#PAPERLESS_CONSUMER_IGNORE_DIRS}
|
||||
|
||||
@@ -2161,7 +2121,7 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
|
||||
|
||||
Defaults to None.
|
||||
|
||||
#### [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE=<str>`](#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE) {#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE}
|
||||
### [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE=<str>`](#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE) {#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE}
|
||||
|
||||
: The language to use for AI suggestions (results may vary by LLM model). If not supplied, defaults to the user's UI language setting or None.
|
||||
|
||||
|
||||
@@ -187,11 +187,10 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
|
||||
|
||||
### Remote OCR parser
|
||||
|
||||
If you use the **remote OCR parser** (Azure AI), `ARCHIVE_FILE_GENERATION` is
|
||||
honored the same way as for the local engine: when no archive is requested
|
||||
(`never`, or `auto` with a born-digital PDF), the remote engine is skipped
|
||||
entirely and locally-extracted text is used instead, avoiding an unnecessary
|
||||
API call and a duplicate text layer.
|
||||
If you use the **remote OCR parser** (Azure AI), note that it always produces a
|
||||
searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
|
||||
has no effect for documents handled by the remote parser - the archive is produced
|
||||
unconditionally by the remote engine.
|
||||
|
||||
## Search Index (Whoosh -> Tantivy)
|
||||
|
||||
@@ -325,11 +324,8 @@ option since v1.8.0.
|
||||
Allauth changed how it determines the client IP address for login rate limiting. Users running
|
||||
behind a reverse proxy may need to set
|
||||
[`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES),
|
||||
[`PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_PROXY_COUNT),
|
||||
[`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER),
|
||||
or a combination of these settings to avoid `403 Forbidden` errors on login.
|
||||
The proxy count is the number of proxy hops in `X-Forwarded-For`, which may
|
||||
differ from the number of configured proxy IP addresses.
|
||||
or both, to avoid `403 Forbidden` errors on login.
|
||||
|
||||
## Minimum CPU Requirements (NumPy Baseline)
|
||||
|
||||
|
||||
+15
-16
@@ -262,14 +262,6 @@ to a positive number to enable polling and disable native filesystem notificatio
|
||||
and copy the contents to the home directory of the user you created
|
||||
earlier (`/opt/paperless`).
|
||||
|
||||
!!! note
|
||||
|
||||
If you are updating an existing bare-metal installation rather than
|
||||
installing for the first time, see the
|
||||
[bare metal update instructions](administration.md#bare-metal-updating)
|
||||
instead -- extracting a new release on top of an old one can leave
|
||||
behind stale files from the previous version.
|
||||
|
||||
Optional: If you cloned the Git repository, you will need to
|
||||
compile the frontend yourself. See [here](development.md#front-end-development)
|
||||
and use the `build` step, not `serve`.
|
||||
@@ -416,15 +408,22 @@ to a positive number to enable polling and disable native filesystem notificatio
|
||||
You may need to change the path in the files. Example:
|
||||
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
|
||||
|
||||
12. Configure ImageMagick to allow processing of PDF documents and disable
|
||||
formats that Paperless-ngx does not use. Most distributions disable PDF
|
||||
processing by default, since PDF documents can contain malware. If you
|
||||
don't enable it, Paperless-ngx will fall back to Ghostscript for certain
|
||||
steps such as thumbnail generation.
|
||||
12. Configure ImageMagick to allow processing of PDF documents. Most
|
||||
distributions have this disabled by default, since PDF documents can
|
||||
contain malware. If you don't do this, Paperless-ngx will fall back to
|
||||
Ghostscript for certain steps such as thumbnail generation.
|
||||
|
||||
Configure the active ImageMagick policy file (commonly
|
||||
`/etc/ImageMagick-6/policy.xml` or `/etc/ImageMagick-7/policy.xml`) and
|
||||
adjust similar to [the docker policy file](https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/refs/heads/main/docker/rootfs/etc/ImageMagick-6/paperless-policy.xml). You should also include restrictions as noted there.
|
||||
Edit `/etc/ImageMagick-6/policy.xml` and adjust
|
||||
|
||||
```
|
||||
<policy domain="coder" rights="none" pattern="PDF" />
|
||||
```
|
||||
|
||||
to
|
||||
|
||||
```
|
||||
<policy domain="coder" rights="read|write" pattern="PDF" />
|
||||
```
|
||||
|
||||
**Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.**
|
||||
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
|
||||
|
||||
+1
-35
@@ -99,10 +99,6 @@ Think of versions as **file history** for a document.
|
||||
- By default, search and document content use the latest version.
|
||||
- In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version.
|
||||
- Deleting a non-root version keeps metadata and falls back to the latest remaining version.
|
||||
- From the document list, select two or more documents and choose **Merge as versions** to combine them under one entry. Select the root document whose metadata and permissions should be retained; the other selected documents become file versions. The root may already have versions, but documents being added as versions must not have version histories of their own.
|
||||
- From a document's **Versions** menu, choose **Existing** to search for another document and add it as a version of the current document.
|
||||
- Documents merged as versions give up their archive serial number. If the root has no ASN of its own it takes the first one, otherwise the ASNs are released and the removal is logged.
|
||||
- Merging as versions cannot be undone from the UI, and deleting the root document moves its versions to the trash as well.
|
||||
|
||||
### Management Lists
|
||||
|
||||
@@ -580,9 +576,7 @@ The following workflow action types are available:
|
||||
- Tags, correspondent, document type and storage path
|
||||
- Document owner
|
||||
- View and / or edit permissions to users or groups
|
||||
- Custom fields, optionally with a value. If no value is set, the field is only added to the
|
||||
document and any value it may already have is left untouched. If a value is set, it will
|
||||
overwrite an existing value of that field on the document.
|
||||
- Custom fields. Note that no value for the field will be set
|
||||
|
||||
##### Removal {#workflow-action-removal}
|
||||
|
||||
@@ -626,34 +620,6 @@ no other workflow will be executed on the document.
|
||||
If a "Move to Trash" action is executed in a consume pipeline, the consumption
|
||||
will be aborted and the file will be deleted.
|
||||
|
||||
##### Password Removal {#workflow-action-password-removal}
|
||||
|
||||
"Password Removal" actions attempt to remove password protection from encrypted PDF documents. You can specify:
|
||||
|
||||
- One or more passwords to try, separated by commas or new lines
|
||||
- Each password is tried in order until one successfully unlocks the document
|
||||
|
||||
Password removal never modifies a file in place. Instead, once a working password is found, the
|
||||
decrypted content is consumed as a new [document version](#document-file-versions), leaving the
|
||||
original (still encrypted) version in the document's version history.
|
||||
|
||||
**Consumption Started**: because this trigger fires before the document exists yet, the password
|
||||
removal itself is deferred until after the initial consumption of the encrypted file has completed.
|
||||
OCR engines cannot process an encrypted PDF, so this first version is typically stored with no
|
||||
extracted text (unless the file already contained extractable text outside of OCR). Immediately
|
||||
afterwards, the password is removed and the decrypted file is automatically re-consumed as a second,
|
||||
new version of the same document, this time with normal OCR/text extraction applied. In other words,
|
||||
a password-protected file added with this trigger will briefly exist as an un-OCR'd version before
|
||||
the properly processed version is created.
|
||||
|
||||
**Document Added**, **Document Updated**, **Scheduled**: these triggers run against a document that
|
||||
already exists, so password removal happens immediately: the decrypted content is queued for
|
||||
consumption as a new version right away. Note that if the document's initial consumption also
|
||||
happened while it was still encrypted, that original version will likewise be missing OCR text.
|
||||
|
||||
**Current limitation**: Passwords are stored as a simple list without descriptions. To handle
|
||||
multiple PDF types with different passwords, create separate workflows for each use case.
|
||||
|
||||
#### Workflow placeholders
|
||||
|
||||
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||
|
||||
@@ -34,8 +34,6 @@ PAPERLESS_SECRET_KEY=change-me
|
||||
#PAPERLESS_AUTO_LOGIN_USERNAME=
|
||||
#PAPERLESS_COOKIE_PREFIX=
|
||||
#PAPERLESS_ENABLE_HTTP_REMOTE_USER=false
|
||||
#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP=
|
||||
#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=
|
||||
|
||||
# OCR settings
|
||||
|
||||
|
||||
+39
-32
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "3.0.5"
|
||||
version = "3.0.2"
|
||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -21,7 +21,7 @@ dependencies = [
|
||||
"channels~=4.2",
|
||||
"channels-redis~=4.2",
|
||||
"concurrent-log-handler~=0.9.25",
|
||||
"dateparser~=1.4",
|
||||
"dateparser~=1.2",
|
||||
# WARNING: django does not use semver.
|
||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||
"django~=5.2.13",
|
||||
@@ -32,32 +32,33 @@ dependencies = [
|
||||
"django-cors-headers~=4.9.0",
|
||||
"django-extensions~=4.1",
|
||||
"django-filter~=25.1",
|
||||
"django-guardian~=3.3.3",
|
||||
"django-guardian~=3.3.0",
|
||||
"django-multiselectfield~=1.0.1",
|
||||
"django-rich~=2.2.0",
|
||||
"django-soft-delete~=1.0.18",
|
||||
"django-treenode>=0.24",
|
||||
"djangorestframework~=3.16",
|
||||
"drf-spectacular~=0.30",
|
||||
"drf-spectacular-sidecar~=2026.7.1",
|
||||
"djangorestframework-guardian~=0.4.0",
|
||||
"drf-spectacular~=0.28",
|
||||
"drf-spectacular-sidecar~=2026.5.1",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"filelock~=3.32.0",
|
||||
"filelock~=3.29.0",
|
||||
"flower~=2.0.1",
|
||||
"gotenberg-client~=0.14.0",
|
||||
"httpx-oauth~=0.17",
|
||||
"ijson>=3.5.1",
|
||||
"imap-tools~=1.14.0",
|
||||
"httpx-oauth~=0.16",
|
||||
"ijson>=3.2",
|
||||
"imap-tools~=1.13.0",
|
||||
"jinja2~=3.1.5",
|
||||
"langdetect~=1.0.9",
|
||||
"llama-index-core>=0.14.23",
|
||||
"llama-index-core>=0.14.22",
|
||||
"llama-index-embeddings-huggingface>=0.6.1",
|
||||
"llama-index-embeddings-ollama>=0.9",
|
||||
"llama-index-embeddings-openai-like>=0.2.2",
|
||||
"llama-index-llms-ollama>=0.9.1",
|
||||
"llama-index-llms-openai-like>=0.7.1",
|
||||
"nltk~=3.10.0",
|
||||
"ocrmypdf~=17.7.0",
|
||||
"openai>=2.48",
|
||||
"ocrmypdf~=17.4.2",
|
||||
"openai>=2.32",
|
||||
"pathvalidate~=3.3.1",
|
||||
"pdf2image~=1.17.0",
|
||||
"python-dateutil~=2.9.0",
|
||||
@@ -67,26 +68,26 @@ dependencies = [
|
||||
"python-magic~=0.4.27",
|
||||
"rapidfuzz~=3.14.5",
|
||||
"redis[hiredis]~=5.2.1",
|
||||
"regex>=2026.7.19",
|
||||
"scikit-learn~=1.9.0",
|
||||
"sentence-transformers>=5.6.1",
|
||||
"regex>=2026.4.4",
|
||||
"scikit-learn~=1.8.0",
|
||||
"sentence-transformers>=5.4.1",
|
||||
"setproctitle~=1.3.4",
|
||||
"sqlite-vec==0.1.9",
|
||||
"tantivy~=0.26.0",
|
||||
"tika-client~=0.11.0",
|
||||
"torch~=2.13.0",
|
||||
"watchfiles>=1.2",
|
||||
"watchfiles>=1.1.1",
|
||||
"whitenoise~=6.11",
|
||||
"zxing-cpp~=3.1.0",
|
||||
"zxing-cpp~=3.0.0",
|
||||
]
|
||||
[project.optional-dependencies]
|
||||
mariadb = [
|
||||
"mysqlclient~=2.2.7",
|
||||
]
|
||||
postgres = [
|
||||
"psycopg[c,pool]==3.3.4",
|
||||
"psycopg[c,pool]==3.3",
|
||||
# Direct dependency for proper resolution of the pre-built wheels
|
||||
"psycopg-c==3.3.4",
|
||||
"psycopg-c==3.3",
|
||||
"psycopg-pool==3.3.1",
|
||||
]
|
||||
webserver = [
|
||||
@@ -100,25 +101,25 @@ dev = [
|
||||
{ include-group = "testing" },
|
||||
]
|
||||
docs = [
|
||||
"zensical>=0.0.51",
|
||||
"zensical>=0.0.47",
|
||||
]
|
||||
lint = [
|
||||
"prek~=0.4.11",
|
||||
"ruff~=0.16.1",
|
||||
"prek~=0.3.10",
|
||||
"ruff~=0.16.0",
|
||||
]
|
||||
testing = [
|
||||
"daphne",
|
||||
"factory-boy~=3.3.1",
|
||||
"faker~=40.36.0",
|
||||
"faker~=40.15.0",
|
||||
"imagehash",
|
||||
"pytest~=9.1.1",
|
||||
"pytest~=9.0.3",
|
||||
"pytest-cov~=7.1.0",
|
||||
"pytest-django~=4.12.0",
|
||||
"pytest-env~=1.7.0",
|
||||
"pytest-env~=1.6.0",
|
||||
"pytest-httpx",
|
||||
"pytest-mock~=3.15.1",
|
||||
# "pytest-randomly~=4.0.1",
|
||||
"pytest-rerunfailures~=16.4",
|
||||
"pytest-rerunfailures~=16.1",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist~=3.8.0",
|
||||
"time-machine>=2.13",
|
||||
@@ -160,10 +161,8 @@ explicit = true
|
||||
[tool.uv.sources]
|
||||
# Markers are chosen to select these almost exclusively when building the Docker image
|
||||
psycopg-c = [
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp314-cp314-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.14'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp314-cp314-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.14'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
||||
]
|
||||
torch = [
|
||||
{ index = "pytorch-cpu" },
|
||||
@@ -184,14 +183,17 @@ output-format = "grouped"
|
||||
line-ending = "lf"
|
||||
[tool.ruff.lint]
|
||||
# https://docs.astral.sh/ruff/rules/
|
||||
select = [ "E4", "E7", "E9", "F" ]
|
||||
extend-select = [
|
||||
"B", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b
|
||||
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
|
||||
"DTZ", # https://docs.astral.sh/ruff/rules/#flake8-datetimez-dtz
|
||||
"PERF", # https://docs.astral.sh/ruff/rules/#perflint-perf
|
||||
"S324", # https://docs.astral.sh/ruff/rules/hashlib-insecure-hash-functions/
|
||||
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
|
||||
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
|
||||
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
|
||||
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
|
||||
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
|
||||
"G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
|
||||
"I", # https://docs.astral.sh/ruff/rules/#isort-i
|
||||
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
|
||||
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
|
||||
@@ -211,10 +213,15 @@ extend-select = [
|
||||
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
||||
]
|
||||
ignore = [
|
||||
"BLE001", # blind except: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
|
||||
"DJ001",
|
||||
"G004", # f-strings in logging: accepted style in this codebase
|
||||
"PLC0415",
|
||||
"RUF012",
|
||||
"SIM105",
|
||||
"TRY002", # create-your-own-exception: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
|
||||
"TRY201", # verbose-raise: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
|
||||
"TRY401", # redundant exception in logging.exception: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
|
||||
]
|
||||
# Migrations
|
||||
per-file-ignores."*/migrations/*.py" = [
|
||||
|
||||
+9
-12
@@ -56,13 +56,13 @@
|
||||
},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"builder": "@angular-builders/custom-webpack:browser",
|
||||
"options": {
|
||||
"outputPath": {
|
||||
"base": "dist/paperless-ui",
|
||||
"browser": ""
|
||||
"customWebpackConfig": {
|
||||
"path": "./extra-webpack.config.ts"
|
||||
},
|
||||
"browser": "src/main.ts",
|
||||
"outputPath": "dist/paperless-ui",
|
||||
"main": "src/main.ts",
|
||||
"outputHashing": "none",
|
||||
"index": "src/index.html",
|
||||
"polyfills": [
|
||||
@@ -97,7 +97,6 @@
|
||||
"scripts": [],
|
||||
"allowedCommonJsDependencies": [
|
||||
"file-saver",
|
||||
"mime-names",
|
||||
"utif"
|
||||
],
|
||||
"extractLicenses": false,
|
||||
@@ -118,13 +117,11 @@
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"outputPath": {
|
||||
"base": "../src/documents/static/frontend/",
|
||||
"browser": ""
|
||||
},
|
||||
"outputPath": "../src/documents/static/frontend/",
|
||||
"optimization": true,
|
||||
"outputHashing": "none",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"budgets": [
|
||||
{
|
||||
@@ -148,7 +145,7 @@
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"builder": "@angular-builders/custom-webpack:dev-server",
|
||||
"options": {
|
||||
"buildTarget": "paperless-ui:build:en-US"
|
||||
},
|
||||
@@ -159,7 +156,7 @@
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular/build:extract-i18n",
|
||||
"builder": "@angular-builders/custom-webpack:extract-i18n",
|
||||
"options": {
|
||||
"buildTarget": "paperless-ui:build"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type WebSocketRoute } from '@playwright/test'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import path from 'node:path'
|
||||
|
||||
const REQUESTS_HAR = path.join(__dirname, 'requests/api-document-detail.har')
|
||||
@@ -95,60 +95,3 @@ test('should support quick filters', async ({ page }) => {
|
||||
.click()
|
||||
await expect(page).toHaveURL(/tags__id__all=4&sort=created&reverse=1&page=1/)
|
||||
})
|
||||
|
||||
test('should finish reloading the preview after a remote document update', async ({
|
||||
page,
|
||||
}) => {
|
||||
let resolveStatusSocket: (socket: WebSocketRoute) => void
|
||||
const statusSocketReady = new Promise<WebSocketRoute>((resolve) => {
|
||||
resolveStatusSocket = resolve
|
||||
})
|
||||
await page.routeWebSocket(/\/ws\/status\/$/, (socket) => {
|
||||
resolveStatusSocket(socket)
|
||||
})
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
let previewRequestCount = 0
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/api/documents/175/preview/')) {
|
||||
previewRequestCount++
|
||||
}
|
||||
})
|
||||
await page.goto('/documents/175/details')
|
||||
|
||||
await page.locator('pngx-document-detail').waitFor()
|
||||
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
|
||||
/\w+/
|
||||
)
|
||||
const previewWasLoaded = await page.evaluate(() => {
|
||||
const detail = document.querySelector('pngx-document-detail')
|
||||
const component = (window as any).ng.getComponent(detail)
|
||||
component.pdfPreviewLoaded({ numPages: 1 })
|
||||
return component.previewLoaded()
|
||||
})
|
||||
expect(previewWasLoaded).toBe(true)
|
||||
const previewRequestsBeforeReload = previewRequestCount
|
||||
|
||||
const statusSocket = await statusSocketReady
|
||||
const documentReloaded = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/api/documents/175/?full_perms=true') &&
|
||||
response.request().method() === 'GET'
|
||||
)
|
||||
statusSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'document_updated',
|
||||
data: {
|
||||
document_id: 175,
|
||||
modified: '2026-07-26T20:00:00Z',
|
||||
},
|
||||
})
|
||||
)
|
||||
await documentReloaded
|
||||
|
||||
await expect(
|
||||
page.getByText('Document reloaded with latest changes.').first()
|
||||
).toBeVisible()
|
||||
await expect
|
||||
.poll(() => previewRequestCount)
|
||||
.toBeGreaterThan(previewRequestsBeforeReload + 1)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
CustomWebpackBrowserSchema,
|
||||
TargetOptions,
|
||||
} from '@angular-builders/custom-webpack'
|
||||
import * as webpack from 'webpack'
|
||||
const { codecovWebpackPlugin } = require('@codecov/webpack-plugin')
|
||||
|
||||
export default (
|
||||
config: webpack.Configuration,
|
||||
options: CustomWebpackBrowserSchema,
|
||||
targetOptions: TargetOptions
|
||||
) => {
|
||||
if (config.plugins) {
|
||||
config.plugins.push(
|
||||
codecovWebpackPlugin({
|
||||
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
|
||||
bundleName: 'paperless-ngx',
|
||||
uploadToken: process.env.CODECOV_TOKEN,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
+959
-1528
File diff suppressed because it is too large
Load Diff
+33
-32
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "3.0.5",
|
||||
"version": "3.0.2",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
@@ -11,21 +11,20 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.1.1",
|
||||
"@angular/common": "~22.1.1",
|
||||
"@angular/compiler": "~22.1.1",
|
||||
"@angular/core": "~22.1.1",
|
||||
"@angular/forms": "~22.1.1",
|
||||
"@angular/localize": "~22.1.1",
|
||||
"@angular/platform-browser": "~22.1.1",
|
||||
"@angular/router": "~22.1.1",
|
||||
"@angular/cdk": "^22.0.3",
|
||||
"@angular/common": "~22.0.5",
|
||||
"@angular/compiler": "~22.0.5",
|
||||
"@angular/core": "~22.0.5",
|
||||
"@angular/forms": "~22.0.5",
|
||||
"@angular/localize": "~22.0.5",
|
||||
"@angular/platform-browser": "~22.0.5",
|
||||
"@angular/router": "~22.0.5",
|
||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||
"@ng-select/ng-select": "~23.6.0",
|
||||
"@ng-select/ng-select": "^23.2.0",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
"file-saver": "^2.0.5",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mime-names": "^1.0.0",
|
||||
"ngx-bootstrap-icons": "^1.9.3",
|
||||
"ngx-color": "^10.1.0",
|
||||
@@ -33,40 +32,42 @@
|
||||
"ngx-device-detector": "^12.0.0",
|
||||
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
|
||||
"normalize-diacritics": "^5.0.0",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"pdfjs-dist": "^6.0.227",
|
||||
"rxjs": "^7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
"utif": "^3.1.0",
|
||||
"uuid": "^14.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/custom-webpack": "^22.0.1",
|
||||
"@angular-builders/jest": "^22.0.1",
|
||||
"@angular-devkit/core": "^22.1.3",
|
||||
"@angular-devkit/schematics": "^22.1.3",
|
||||
"@angular-eslint/builder": "22.1.0",
|
||||
"@angular-eslint/eslint-plugin": "22.1.0",
|
||||
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
||||
"@angular-eslint/schematics": "22.1.0",
|
||||
"@angular-eslint/template-parser": "22.1.0",
|
||||
"@angular/build": "22.1.3",
|
||||
"@angular/cli": "22.1.3",
|
||||
"@angular/compiler-cli": "~22.1.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@angular-devkit/core": "^22.0.5",
|
||||
"@angular-devkit/schematics": "^22.0.5",
|
||||
"@angular-eslint/builder": "22.0.0",
|
||||
"@angular-eslint/eslint-plugin": "22.0.0",
|
||||
"@angular-eslint/eslint-plugin-template": "22.0.0",
|
||||
"@angular-eslint/schematics": "22.0.0",
|
||||
"@angular-eslint/template-parser": "22.0.0",
|
||||
"@angular/build": "^22.0.5",
|
||||
"@angular/cli": "~22.0.5",
|
||||
"@angular/compiler-cli": "~22.0.5",
|
||||
"@codecov/webpack-plugin": "^2.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^26.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"@typescript-eslint/utils": "^8.67.0",
|
||||
"eslint": "^10.8.1",
|
||||
"@types/node": "^26.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.62.0",
|
||||
"@typescript-eslint/parser": "^8.62.0",
|
||||
"@typescript-eslint/utils": "^8.62.0",
|
||||
"eslint": "^10.5.0",
|
||||
"jest": "30.4.2",
|
||||
"jest-environment-jsdom": "^30.4.1",
|
||||
"jest-junit": "^17.0.0",
|
||||
"jest-preset-angular": "^17.0.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"ts-node": "~10.9.2",
|
||||
"typescript": "^6.0.3"
|
||||
"ts-node": "~10.9.1",
|
||||
"typescript": "^6.0.3",
|
||||
"webpack": "^5.107.2"
|
||||
},
|
||||
"packageManager": "pnpm@11.15.1"
|
||||
"packageManager": "pnpm@10.26.0"
|
||||
}
|
||||
|
||||
Generated
+6705
-2043
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,6 @@ packages:
|
||||
- "."
|
||||
minimumReleaseAge: 10080
|
||||
trustPolicy: no-downgrade
|
||||
trustPolicyExclude:
|
||||
- "chokidar@4.0.3"
|
||||
- "semver@6.3.1 || 5.7.2"
|
||||
blockExoticSubdeps: true
|
||||
autoInstallPeers: false
|
||||
allowBuilds:
|
||||
"@parcel/watcher": true
|
||||
canvas: true
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
</pngx-page-header>
|
||||
|
||||
@if (loading() && pagedTasks().length === 0) {
|
||||
@if (!tasksService.completedFileTasks && tasksService.loading) {
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
}
|
||||
|
||||
@@ -576,7 +576,7 @@ describe('TasksComponent', () => {
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id]))
|
||||
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
|
||||
expect(modal.componentInstance.buttonsEnabled()).toBe(true)
|
||||
expect(modal.componentInstance.buttonsEnabled).toBe(true)
|
||||
expect(component.selectedTasks.size).toBe(0)
|
||||
})
|
||||
|
||||
@@ -642,7 +642,7 @@ describe('TasksComponent', () => {
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
|
||||
expect(modal.componentInstance.buttonsEnabled()).toBe(true)
|
||||
expect(modal.componentInstance.buttonsEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should dismiss the currently visible scoped and filtered tasks', () => {
|
||||
|
||||
@@ -316,7 +316,7 @@ export class TasksComponent
|
||||
modal.componentInstance.btnClass = 'btn-warning'
|
||||
modal.componentInstance.btnCaption = $localize`Dismiss`
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.close()
|
||||
this.tasksService.dismissTasks(tasks).subscribe({
|
||||
next: () => {
|
||||
@@ -324,7 +324,7 @@ export class TasksComponent
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error dismissing tasks`, e)
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
},
|
||||
})
|
||||
this.clearSelection()
|
||||
@@ -350,7 +350,7 @@ export class TasksComponent
|
||||
modal.componentInstance.btnClass = 'btn-warning'
|
||||
modal.componentInstance.btnCaption = $localize`Dismiss`
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.close()
|
||||
this.tasksService.dismissAllTasks().subscribe({
|
||||
next: () => {
|
||||
@@ -358,7 +358,7 @@ export class TasksComponent
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error dismissing tasks`, e)
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
},
|
||||
})
|
||||
this.clearSelection()
|
||||
|
||||
@@ -2,8 +2,3 @@
|
||||
.d-block.d-sm-none .dropdown-toggle::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
tbody tr:last-child td,
|
||||
table:not(:has(tbody tr)) thead th {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export class TrashComponent
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.trashService.emptyTrash([document.id]).subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
|
||||
@@ -146,7 +146,7 @@ export class UsersAndGroupsComponent
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.usersService.delete(user).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
@@ -199,7 +199,7 @@ export class UsersAndGroupsComponent
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.groupsService.delete(group).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
|
||||
@@ -104,14 +104,14 @@
|
||||
</h6>
|
||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
|
||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews()"
|
||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||
(cdkDragEnded)="onDragEnd($event)">
|
||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||
popoverClass="popover-slim">
|
||||
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
|
||||
<i-bs class="me-2" name="funnel"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
|
||||
@if (showSidebarCounts && !slimSidebarEnabled) {
|
||||
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
|
||||
@if (settingsService.organizingSidebarSavedViews()) {
|
||||
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
||||
<i-bs name="grip-vertical"></i-bs>
|
||||
</div>
|
||||
@@ -182,7 +182,7 @@
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="stack"></i-bs><span><ng-container i18n>Attributes</ng-container></span>
|
||||
</a>
|
||||
@if (!slimSidebarEnabled && canSaveSettings) {
|
||||
@if (!slimSidebarEnabled) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link btn-sm text-muted p-0 me-3 attributes-expand-btn"
|
||||
@@ -195,32 +195,32 @@
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
class="attributes-submenu ms-3"
|
||||
class="attributes-submenu ms-2"
|
||||
[ngbCollapse]="slimSidebarEnabled || attributesSectionsCollapsed"
|
||||
>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
||||
<a class="nav-link" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
|
||||
<a class="nav-link py-1" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
|
||||
<i-bs class="me-2" name="tags"></i-bs><span><ng-container i18n>Tags</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||
<a class="nav-link" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
||||
<a class="nav-link py-1" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
||||
<i-bs class="me-2" name="person"></i-bs><span><ng-container i18n>Correspondents</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||
<a class="nav-link" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
||||
<a class="nav-link py-1" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
||||
<i-bs class="me-2" name="hash"></i-bs><span><ng-container i18n>Document types</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||
<a class="nav-link" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
||||
<a class="nav-link py-1" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
||||
<i-bs class="me-2" name="folder"></i-bs><span><ng-container i18n>Storage paths</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||
<a class="nav-link" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
||||
<a class="nav-link py-1" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
||||
<i-bs class="me-2" name="ui-radios"></i-bs><span><ng-container i18n>Custom fields</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -19,11 +19,6 @@
|
||||
height: 0.8em;
|
||||
}
|
||||
|
||||
.sidebar-heading,
|
||||
.text-uppercase {
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.view-name {
|
||||
max-width: calc(100% - 50px)
|
||||
}
|
||||
@@ -52,12 +47,11 @@
|
||||
|
||||
.search-container {
|
||||
max-height: 4.5rem;
|
||||
overflow: visible;
|
||||
overflow: hidden;
|
||||
transition: max-height .2s ease, opacity .2s ease, padding-top .2s ease, padding-bottom .2s ease;
|
||||
|
||||
&.mobile-hidden {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
@@ -86,7 +80,7 @@ main {
|
||||
|
||||
.sidebar li.nav-item span,
|
||||
.sidebar .sidebar-heading span {
|
||||
transition: opacity .1s ease;
|
||||
transition: all .1s ease;
|
||||
}
|
||||
|
||||
@media(min-width: 768px) {
|
||||
@@ -154,7 +148,7 @@ main {
|
||||
z-index: 996;
|
||||
--bs-btn-padding-x: 0.35rem;
|
||||
--bs-btn-padding-y: 0.125rem;
|
||||
transition: left .2s ease;
|
||||
transition: all .2s ease;
|
||||
}
|
||||
|
||||
.sidebar.slim .sidebar-slim-toggler {
|
||||
@@ -186,8 +180,6 @@ main {
|
||||
.sidebar .nav-link {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
border-left: 2px solid transparent;
|
||||
transition: color .15s ease-in-out;
|
||||
|
||||
&:hover, &.active, &:focus {
|
||||
color: var(--bs-primary);
|
||||
@@ -200,7 +192,6 @@ main {
|
||||
|
||||
&.active {
|
||||
font-weight: bold;
|
||||
border-left-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
i-bs {
|
||||
@@ -209,17 +200,6 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
// sub-page gets marker only
|
||||
.nav-item:has(.attributes-submenu.show .nav-link.active) > .attributes-row > .nav-link.active {
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
// bring sub-menu markers back out to L edge
|
||||
.attributes-submenu .nav-link {
|
||||
margin-left: -1rem;
|
||||
padding-left: calc(var(--bs-nav-link-padding-x) + 1rem);
|
||||
}
|
||||
|
||||
.attributes-row .attributes-expand-btn {
|
||||
opacity: 0.2;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
@@ -300,52 +280,10 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 575.98px) {
|
||||
.navbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-areas:
|
||||
"toggler brand actions"
|
||||
"search search search";
|
||||
}
|
||||
|
||||
@media screen and (min-width: 376px) and (max-width: 768px) {
|
||||
.navbar-toggler {
|
||||
grid-area: toggler;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
grid-area: brand;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
justify-self: center;
|
||||
overflow: hidden;
|
||||
|
||||
> div,
|
||||
.custom-title {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title,
|
||||
.byline {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
grid-area: search;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar > ul {
|
||||
grid-area: actions;
|
||||
justify-self: end;
|
||||
flex-wrap: nowrap;
|
||||
// compensate for 2 buttons on the right
|
||||
margin-right: 45px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +293,13 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 345px) {
|
||||
.custom-title {
|
||||
max-width: 110px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep .dropdown.show .dropdown-toggle,
|
||||
:host ::ng-deep .dropdown-toggle:hover {
|
||||
opacity: 0.7;
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||
<span>
|
||||
{{btnCaption}}
|
||||
<span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span>
|
||||
@@ -25,7 +25,7 @@
|
||||
}
|
||||
</button>
|
||||
@if (alternativeBtnCaption) {
|
||||
<button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled()">
|
||||
<button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled">
|
||||
{{alternativeBtnCaption}}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -64,22 +64,6 @@ describe('ConfirmDialogComponent', () => {
|
||||
expect(confirmSubjectResult).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should re-render the buttons when they are toggled from outside', async () => {
|
||||
const confirmButton: HTMLButtonElement =
|
||||
fixture.nativeElement.querySelectorAll('.modal-footer button')[1]
|
||||
expect(confirmButton.disabled).toBeFalsy()
|
||||
|
||||
// Deliberately no detectChanges: a request callback toggling this is all
|
||||
// that happens, and nothing else schedules a render for the modal
|
||||
component.buttonsEnabled.set(false)
|
||||
await fixture.whenStable()
|
||||
expect(confirmButton.disabled).toBeTruthy()
|
||||
|
||||
component.buttonsEnabled.set(true)
|
||||
await fixture.whenStable()
|
||||
expect(confirmButton.disabled).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should support cancel & close modal', () => {
|
||||
let confirmSubjectResult
|
||||
const closeModalSpy = jest.spyOn(modal, 'close')
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { DecimalPipe } from '@angular/common'
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject } from 'rxjs'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
@@ -53,7 +46,8 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
|
||||
@Input()
|
||||
cancelBtnCaption = $localize`Cancel`
|
||||
|
||||
readonly buttonsEnabled = signal(true)
|
||||
@Input()
|
||||
buttonsEnabled = true
|
||||
|
||||
confirmButtonEnabled = true
|
||||
alternativeButtonEnabled = true
|
||||
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{{message}}</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="rootDocumentID" i18n>Root document:</label>
|
||||
<select id="rootDocumentID" class="form-select" [ngModel]="rootDocumentID()" (ngModelChange)="rootDocumentID.set($event)">
|
||||
@for (document of documents(); track document.id) {
|
||||
<option [ngValue]="document.id">{{document.title}}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group mt-4">
|
||||
<span class="form-label d-inline-block" i18n>Versions (oldest first):</span>
|
||||
<ul class="list-group"
|
||||
cdkDropList
|
||||
[cdkDropListData]="versionDocumentIDs()"
|
||||
(cdkDropListDropped)="onDrop($event)">
|
||||
@for (documentID of versionDocumentIDs(); track documentID) {
|
||||
@let document = getDocument(documentID);
|
||||
@if (document) {
|
||||
<li class="list-group-item d-flex align-items-center" cdkDrag>
|
||||
<i-bs name="grip-vertical" class="me-2"></i-bs>
|
||||
<div class="d-flex flex-column">
|
||||
<div>
|
||||
@if (document.correspondent) {
|
||||
<b>{{document.correspondent | correspondentName | async}}: </b>
|
||||
}{{document.title}}
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{{document.created | customDate:'mediumDate'}}
|
||||
@if (document.page_count) {
|
||||
| {document.page_count, plural, =1 {One page} other {{{document.page_count}} pages}}
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
@if ($last) {
|
||||
<span class="badge bg-primary ms-auto" i18n>Current version</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
@if (versionDocumentIDs().length > 1) {
|
||||
<div class="form-text" i18n>Drag to reorder.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
|
||||
{{btnCaption}}
|
||||
</button>
|
||||
</div>
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
.list-group-item {
|
||||
cursor: move;
|
||||
}
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of } from 'rxjs'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { MergeAsVersionsConfirmDialogComponent } from './merge-as-versions-confirm-dialog.component'
|
||||
|
||||
describe('MergeAsVersionsConfirmDialogComponent', () => {
|
||||
let component: MergeAsVersionsConfirmDialogComponent
|
||||
let fixture: ComponentFixture<MergeAsVersionsConfirmDialogComponent>
|
||||
let documentService: DocumentService
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
MergeAsVersionsConfirmDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(MergeAsVersionsConfirmDialogComponent)
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should fetch selected documents', () => {
|
||||
const documents = [
|
||||
{ id: 1, title: 'Document 1' },
|
||||
{ id: 2, title: 'Document 2' },
|
||||
]
|
||||
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
||||
of({
|
||||
all: [1, 2],
|
||||
count: 2,
|
||||
results: documents,
|
||||
})
|
||||
)
|
||||
component.documentIDs.set([1, 2])
|
||||
|
||||
component.ngOnInit()
|
||||
|
||||
expect(component.documents()).toEqual(documents)
|
||||
expect(documentService.getFew).toHaveBeenCalledWith([1, 2])
|
||||
})
|
||||
|
||||
it('should exclude the root from the draggable documents', () => {
|
||||
component.documentIDs.set([1, 2, 3])
|
||||
component.rootDocumentID.set(2)
|
||||
|
||||
expect(component.versionDocumentIDs()).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('should move draggable documents while keeping the root fixed', () => {
|
||||
component.documentIDs.set([1, 2, 3])
|
||||
component.rootDocumentID.set(1)
|
||||
|
||||
component.onDrop({ previousIndex: 1, currentIndex: 0 } as any)
|
||||
|
||||
expect(component.documentIDs()).toEqual([1, 3, 2])
|
||||
expect(component.versionDocumentIDs()).toEqual([3, 2])
|
||||
})
|
||||
})
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
import {
|
||||
CdkDragDrop,
|
||||
DragDropModule,
|
||||
moveItemInArray,
|
||||
} from '@angular/cdk/drag-drop'
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { Component, OnInit, computed, inject, signal } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { takeUntil } from 'rxjs'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-merge-as-versions-confirm-dialog',
|
||||
templateUrl: './merge-as-versions-confirm-dialog.component.html',
|
||||
styleUrl: './merge-as-versions-confirm-dialog.component.scss',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
CorrespondentNamePipe,
|
||||
CustomDatePipe,
|
||||
DragDropModule,
|
||||
FormsModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class MergeAsVersionsConfirmDialogComponent
|
||||
extends ConfirmDialogComponent
|
||||
implements OnInit
|
||||
{
|
||||
private readonly documentService = inject(DocumentService)
|
||||
|
||||
readonly documentIDs = signal<number[]>([])
|
||||
readonly documents = signal<Document[]>([])
|
||||
readonly rootDocumentID = signal(-1)
|
||||
readonly versionDocumentIDs = computed(() =>
|
||||
this.documentIDs().filter(
|
||||
(documentID) => documentID !== this.rootDocumentID()
|
||||
)
|
||||
)
|
||||
|
||||
ngOnInit() {
|
||||
this.documentService
|
||||
.getFew(this.documentIDs())
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((response) => this.documents.set(response.results))
|
||||
}
|
||||
|
||||
onDrop(event: CdkDragDrop<number[]>) {
|
||||
const versionDocumentIDs = this.versionDocumentIDs().concat()
|
||||
moveItemInArray(versionDocumentIDs, event.previousIndex, event.currentIndex)
|
||||
|
||||
// The root keeps its place in the list, only the versions move around it
|
||||
let versionIndex = 0
|
||||
this.documentIDs.update((documentIDs) =>
|
||||
documentIDs.map((documentID) =>
|
||||
documentID === this.rootDocumentID()
|
||||
? documentID
|
||||
: versionDocumentIDs[versionIndex++]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
getDocument(documentID: number): Document | undefined {
|
||||
return this.documents().find((document) => document.id === documentID)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<div class="form-group mt-4">
|
||||
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
|
||||
<select id="metadataDocumentID" class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
|
||||
<select class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
|
||||
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
|
||||
@for (document of documents(); track document.id) {
|
||||
<option [ngValue]="document.id">{{document.title}}</option>
|
||||
@@ -56,10 +56,10 @@
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||
{{btnCaption}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -57,7 +57,7 @@
|
||||
class="btn"
|
||||
[class]="cancelBtnClass"
|
||||
(click)="cancel()"
|
||||
[disabled]="!buttonsEnabled()"
|
||||
[disabled]="!buttonsEnabled"
|
||||
>
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">
|
||||
{{cancelBtnCaption}}
|
||||
@@ -68,7 +68,7 @@
|
||||
class="btn"
|
||||
[class]="btnClass"
|
||||
(click)="confirm()"
|
||||
[disabled]="!confirmButtonEnabled || !buttonsEnabled()"
|
||||
[disabled]="!confirmButtonEnabled || !buttonsEnabled"
|
||||
>
|
||||
{{btnCaption}}
|
||||
</button>
|
||||
|
||||
+2
-2
@@ -34,10 +34,10 @@
|
||||
<p class="mb-0 small"><b>{{messageBold}}</b></p>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled() || degrees === 0">
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled || degrees === 0">
|
||||
{{btnCaption}}
|
||||
@if (!confirmButtonEnabled) {
|
||||
<ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
|
||||
|
||||
-5
@@ -68,11 +68,6 @@
|
||||
></ng-select>
|
||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) {
|
||||
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true"></pngx-input-document-link>
|
||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Monetary) {
|
||||
<input class="w-25 form-control rounded-end" type="text" inputmode="decimal"
|
||||
[ngModel]="atom.value"
|
||||
(ngModelChange)="setMonetaryValue(atom, $event)"
|
||||
[disabled]="disabled">
|
||||
} @else {
|
||||
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
|
||||
}
|
||||
|
||||
-24
@@ -1,6 +1,5 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { LOCALE_ID } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
@@ -42,12 +41,6 @@ const customFields = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Test Monetary Field',
|
||||
data_type: CustomFieldDataType.Monetary,
|
||||
extra_data: { default_currency: 'EUR' },
|
||||
},
|
||||
]
|
||||
|
||||
describe('CustomFieldsQueryDropdownComponent', () => {
|
||||
@@ -68,7 +61,6 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: LOCALE_ID, useValue: 'de' },
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
@@ -158,22 +150,6 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
||||
expect(options2).toEqual([])
|
||||
})
|
||||
|
||||
it('should normalize localized monetary comparison values', () => {
|
||||
const atom = new CustomFieldQueryAtom([3, 'exact', null])
|
||||
|
||||
component.setMonetaryValue(atom, '1.234,56')
|
||||
|
||||
expect(atom.value).toEqual('1234.56')
|
||||
})
|
||||
|
||||
it('should preserve API-formatted monetary comparison values', () => {
|
||||
const atom = new CustomFieldQueryAtom([3, 'exact', null])
|
||||
|
||||
component.setMonetaryValue(atom, '1234.56')
|
||||
|
||||
expect(atom.value).toEqual('1234.56')
|
||||
})
|
||||
|
||||
it('should remove an element from the selection model', () => {
|
||||
const expression = new CustomFieldQueryExpression()
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
|
||||
+1
-21
@@ -1,14 +1,9 @@
|
||||
import {
|
||||
getLocaleNumberSymbol,
|
||||
NgTemplateOutlet,
|
||||
NumberSymbol,
|
||||
} from '@angular/common'
|
||||
import { NgTemplateOutlet } from '@angular/common'
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
LOCALE_ID,
|
||||
Output,
|
||||
QueryList,
|
||||
signal,
|
||||
@@ -217,7 +212,6 @@ export class CustomFieldQueriesModel {
|
||||
})
|
||||
export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPermissions {
|
||||
protected customFieldsService = inject(CustomFieldsService)
|
||||
private readonly locale = inject(LOCALE_ID)
|
||||
|
||||
public CustomFieldQueryComponentType = CustomFieldQueryElementType
|
||||
public CustomFieldQueryOperator = CustomFieldQueryOperator
|
||||
@@ -382,18 +376,4 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
setMonetaryValue(atom: CustomFieldQueryAtom, value: string) {
|
||||
// Normalize the decimal symbol e.g. . vs , by locale
|
||||
const decimalSymbol = getLocaleNumberSymbol(
|
||||
this.locale,
|
||||
NumberSymbol.Decimal
|
||||
)
|
||||
if (decimalSymbol !== '.' && value.includes(decimalSymbol)) {
|
||||
const groupSymbol = getLocaleNumberSymbol(this.locale, NumberSymbol.Group)
|
||||
value = value.split(groupSymbol).join('').split(decimalSymbol).join('.')
|
||||
}
|
||||
|
||||
atom.value = value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,6 @@ import { EditDialogComponent, EditDialogMode } from './edit-dialog.component'
|
||||
template: `
|
||||
<div>
|
||||
<h4 class="modal-title" id="modal-basic-title">{{ getTitle() }}</h4>
|
||||
<span class="error">{{ error?.name }}</span>
|
||||
<button [disabled]="networkActive" (click)="save()">Save</button>
|
||||
</div>
|
||||
`,
|
||||
imports: [FormsModule, ReactiveFormsModule],
|
||||
@@ -278,22 +276,4 @@ describe('EditDialogComponent', () => {
|
||||
expect(failedSpy).toHaveBeenCalled()
|
||||
expect(component.error).toEqual('error')
|
||||
})
|
||||
|
||||
it('should update the view after a failed save', async () => {
|
||||
const button: HTMLButtonElement =
|
||||
fixture.nativeElement.querySelector('button')
|
||||
button.click()
|
||||
await fixture.whenStable()
|
||||
expect(button.disabled).toBe(true)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}tags/`)
|
||||
.flush({ name: ['Name is required.'] }, { status: 400, statusText: '' })
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(button.disabled).toBe(false)
|
||||
expect(fixture.nativeElement.querySelector('.error').textContent).toContain(
|
||||
'Name is required.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
Directive,
|
||||
EventEmitter,
|
||||
Input,
|
||||
@@ -46,7 +45,6 @@ export abstract class EditDialogComponent<
|
||||
protected userService = inject(UserService)
|
||||
protected settingsService = inject(SettingsService)
|
||||
protected permissionsService = inject(PermissionsService)
|
||||
protected changeDetector = inject(ChangeDetectorRef)
|
||||
|
||||
dialogMode = model(EditDialogMode.CREATE)
|
||||
|
||||
@@ -110,12 +108,10 @@ export abstract class EditDialogComponent<
|
||||
// wait to enable close button so it doesn't steal focus from input since its the first clickable element in the DOM
|
||||
setTimeout(() => {
|
||||
this.closeEnabled = true
|
||||
this.changeDetector.markForCheck()
|
||||
})
|
||||
|
||||
this.userService.listAll().subscribe((r) => {
|
||||
this.users = r.results
|
||||
this.changeDetector.markForCheck()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -195,7 +191,6 @@ export abstract class EditDialogComponent<
|
||||
this.error = error.error
|
||||
this.networkActive = false
|
||||
this.failed.next(error)
|
||||
this.changeDetector.markForCheck()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
-24
@@ -1019,28 +1019,4 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
'pass3',
|
||||
])
|
||||
})
|
||||
|
||||
it('should parse passwords again when retrying a failed save', () => {
|
||||
component.object = {
|
||||
name: 'Workflow with blank Passwords',
|
||||
id: 1,
|
||||
order: null,
|
||||
enabled: true,
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
id: 1,
|
||||
type: WorkflowActionType.PasswordRemoval,
|
||||
passwords: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
component.ngOnInit()
|
||||
|
||||
component.save()
|
||||
component.objectForm.get('order').setValue(1)
|
||||
|
||||
expect(() => component.save()).not.toThrow()
|
||||
expect(component.objectForm.get('actions').value[0].passwords).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
+3
-2
@@ -1207,8 +1207,9 @@ export class WorkflowEditDialogComponent
|
||||
return passwords.join('\n')
|
||||
}
|
||||
|
||||
private parsePasswords(value: string | string[] = ''): string[] {
|
||||
return (Array.isArray(value) ? value : value.split(/[\n,]+/))
|
||||
private parsePasswords(value: string = ''): string[] {
|
||||
return value
|
||||
.split(/[\n,]+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions" [autoClose]="!creating()">
|
||||
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions">
|
||||
<button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
|
||||
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
||||
@if (!editing && selectionModel.totalCount > 0) {
|
||||
@@ -49,7 +49,7 @@
|
||||
</cdk-virtual-scroll-viewport>
|
||||
}
|
||||
@if (editing) {
|
||||
@if (filteredItems.length === 0 && createRef !== undefined && filterText?.length > 0) {
|
||||
@if (filteredItems.length === 0 && createRef !== undefined) {
|
||||
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
|
||||
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
||||
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
||||
@@ -62,7 +62,7 @@
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@if (extraButtonTitle && (showExtraButtonIfEmpty || filteredItems?.length > 0)) {
|
||||
@if (extraButtonTitle) {
|
||||
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
|
||||
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
|
||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||
|
||||
+4
-63
@@ -3,7 +3,6 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { NEVER, Subject } from 'rxjs'
|
||||
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
||||
import {
|
||||
DEFAULT_MATCHING_ALGORITHM,
|
||||
@@ -49,7 +48,6 @@ const negativeNullItem = {
|
||||
|
||||
let selectionModel: FilterableDropdownSelectionModel
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const createModalRef = () => ({ closed: NEVER, dismissed: NEVER }) as any
|
||||
|
||||
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
|
||||
let component: FilterableDropdownComponent
|
||||
@@ -870,7 +868,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
||||
})
|
||||
|
||||
it('should keep the dropdown open while the create modal is active', async () => {
|
||||
it('should set support create, keep open model and call createRef method', async () => {
|
||||
component.selectionModel.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.selectionModel = selectionModel
|
||||
@@ -884,44 +882,20 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
fixture.detectChanges()
|
||||
|
||||
component.filterText = 'Test Filter Text'
|
||||
const modalClosed = new Subject<void>()
|
||||
component.createRef = jest.fn(
|
||||
() =>
|
||||
({
|
||||
closed: modalClosed,
|
||||
dismissed: NEVER,
|
||||
}) as any
|
||||
)
|
||||
component.createRef = jest.fn()
|
||||
component.createClicked()
|
||||
expect(component.creating()).toBeTruthy()
|
||||
expect(component.creating).toBeTruthy()
|
||||
expect(component.createRef).toHaveBeenCalledWith('Test Filter Text')
|
||||
fixture.detectChanges()
|
||||
expect(component.dropdown.autoClose).toBeFalsy()
|
||||
|
||||
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
document.body.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
|
||||
await wait(10)
|
||||
expect(component.dropdown.isOpen()).toBeTruthy()
|
||||
|
||||
// Also cover a close that was already scheduled before autoClose changed.
|
||||
const openSpy = jest.spyOn(component.dropdown, 'open')
|
||||
component.dropdownOpenChange(false)
|
||||
expect(openSpy).toHaveBeenCalled() // should keep open
|
||||
component.dropdownOpenChange(false)
|
||||
expect(openSpy).toHaveBeenCalledTimes(2) // modal interactions keep it open
|
||||
|
||||
modalClosed.next()
|
||||
fixture.detectChanges()
|
||||
expect(component.creating()).toBeFalsy()
|
||||
expect(component.dropdown.autoClose).toBeTruthy()
|
||||
expect(component.dropdown.isOpen()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should call create on enter inside filter field if 0 items remain while editing', async () => {
|
||||
component.selectionModel.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.editing = true
|
||||
component.createRef = jest.fn(createModalRef)
|
||||
component.createRef = jest.fn()
|
||||
const createSpy = jest.spyOn(component, 'createClicked')
|
||||
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
||||
fixture.nativeElement
|
||||
@@ -937,25 +911,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
expect(createSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should only show create when a non-empty filter has no matches', () => {
|
||||
component.selectionModel.items = []
|
||||
component.icon = 'tag-fill'
|
||||
component.editing = true
|
||||
component.createRef = jest.fn(createModalRef)
|
||||
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Create')
|
||||
component.listFilterEnter()
|
||||
expect(component.createRef).not.toHaveBeenCalled()
|
||||
|
||||
const filterInput: HTMLInputElement =
|
||||
fixture.nativeElement.querySelector('input[type="text"]')
|
||||
filterInput.value = 'FooBar'
|
||||
filterInput.dispatchEvent(new Event('input'))
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.textContent).toContain('Create "FooBar"')
|
||||
})
|
||||
|
||||
it('should exclude item and trigger change event', () => {
|
||||
const id = 1
|
||||
const state = ToggleableItemState.Selected
|
||||
@@ -1015,18 +970,4 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
expect(extraButtonClicked).toBeTruthy()
|
||||
expect(applied).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should only show the extra button for an empty result when enabled', () => {
|
||||
component.selectionModel.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.extraButtonTitle = 'Extra'
|
||||
component.filterText = 'FooBar'
|
||||
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Extra')
|
||||
|
||||
fixture.componentRef.setInput('showExtraButtonIfEmpty', true)
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.textContent).toContain('Extra')
|
||||
})
|
||||
})
|
||||
|
||||
+9
-25
@@ -15,13 +15,9 @@ import {
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
NgbDropdown,
|
||||
NgbDropdownModule,
|
||||
NgbModalRef,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subject, filter, first, merge, takeUntil } from 'rxjs'
|
||||
import { Subject, filter, takeUntil } from 'rxjs'
|
||||
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
||||
import { MatchingModel } from 'src/app/data/matching-model'
|
||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||
@@ -763,7 +759,7 @@ export class FilterableDropdownComponent
|
||||
disabled = false
|
||||
|
||||
@Input()
|
||||
createRef: (name: string) => NgbModalRef
|
||||
createRef: (name) => void
|
||||
|
||||
@Input()
|
||||
set documentCounts(counts: SelectionDataItem[]) {
|
||||
@@ -778,10 +774,7 @@ export class FilterableDropdownComponent
|
||||
@Input()
|
||||
extraButtonTitle: string
|
||||
|
||||
@Input()
|
||||
showExtraButtonIfEmpty: boolean = false
|
||||
|
||||
readonly creating = signal(false)
|
||||
creating: boolean = false
|
||||
|
||||
@Output()
|
||||
apply = new EventEmitter<ChangedItems>()
|
||||
@@ -858,18 +851,12 @@ export class FilterableDropdownComponent
|
||||
}
|
||||
|
||||
createClicked() {
|
||||
this.creating.set(true)
|
||||
const modal = this.createRef(this.filterText)
|
||||
merge(modal.closed, modal.dismissed)
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => this.creating.set(false))
|
||||
this.creating = true
|
||||
this.createRef(this.filterText)
|
||||
}
|
||||
|
||||
dropdownOpenChange(open: boolean): void {
|
||||
if (open) {
|
||||
// Dont let a create modal close this
|
||||
if (this.creating()) return
|
||||
|
||||
setTimeout(() => {
|
||||
this.listFilterTextInput?.nativeElement.focus()
|
||||
this.buttonsViewport?.checkViewportSize()
|
||||
@@ -882,8 +869,9 @@ export class FilterableDropdownComponent
|
||||
this.editing && !this.selectionModel.manyToOne
|
||||
this.opened.next(this)
|
||||
} else {
|
||||
if (this.creating()) {
|
||||
if (this.creating) {
|
||||
this.dropdown?.open()
|
||||
this.creating = false
|
||||
} else {
|
||||
this.filterText = ''
|
||||
if (this.applyOnClose && this.selectionModel.isDirty()) {
|
||||
@@ -904,11 +892,7 @@ export class FilterableDropdownComponent
|
||||
this.dropdown.close()
|
||||
}
|
||||
}, 200)
|
||||
} else if (
|
||||
filtered.length == 0 &&
|
||||
this.createRef &&
|
||||
this.filterText?.length > 0
|
||||
) {
|
||||
} else if (filtered.length == 0 && this.createRef) {
|
||||
this.createClicked()
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -52,10 +52,10 @@ describe('CustomFieldsValuesComponent', () => {
|
||||
})
|
||||
|
||||
it('should set selectedFields and map values correctly', () => {
|
||||
component.value = { 1: 'value1', 3: 0, 4: false }
|
||||
component.selectedFields = [1, 2, 3, 4]
|
||||
expect(component.selectedFields).toEqual([1, 2, 3, 4])
|
||||
expect(component.value).toEqual({ 1: 'value1', 2: null, 3: 0, 4: false })
|
||||
component.value = { 1: 'value1' }
|
||||
component.selectedFields = [1, 2]
|
||||
expect(component.selectedFields).toEqual([1, 2])
|
||||
expect(component.value).toEqual({ 1: 'value1', 2: null })
|
||||
})
|
||||
|
||||
it('should return the correct custom field by id', () => {
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
|
||||
this._selectedFields = newFields
|
||||
// map the selected fields to an object with field_id as key and value as value
|
||||
this.value = newFields.reduce((acc, fieldId) => {
|
||||
acc[fieldId] = this.value?.[fieldId] ?? null
|
||||
acc[fieldId] = this.value?.[fieldId] || null
|
||||
return acc
|
||||
}, {})
|
||||
this.onChange(this.value)
|
||||
|
||||
@@ -36,16 +36,7 @@
|
||||
(focus)="clearLastSearchTerm()"
|
||||
(clear)="clearLastSearchTerm()"
|
||||
(blur)="onBlur()">
|
||||
<ng-template ng-label-tmp let-item="item">
|
||||
@if (iconField && item[iconField]) {
|
||||
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
||||
}
|
||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||
</ng-template>
|
||||
<ng-template ng-option-tmp let-item="item">
|
||||
@if (iconField && item[iconField]) {
|
||||
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
||||
}
|
||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
|
||||
@@ -35,7 +35,7 @@ import { AbstractInputComponent } from '../abstract-input'
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class SelectComponent extends AbstractInputComponent<number | string> {
|
||||
export class SelectComponent extends AbstractInputComponent<number> {
|
||||
constructor() {
|
||||
super()
|
||||
this.addItemRef = this.addItem.bind(this)
|
||||
@@ -100,9 +100,6 @@ export class SelectComponent extends AbstractInputComponent<number | string> {
|
||||
@Input()
|
||||
bindLabel: string = 'name'
|
||||
|
||||
@Input()
|
||||
iconField: string
|
||||
|
||||
public searchFn = (term: string, item: any): boolean =>
|
||||
matchesSearchText(item?.[this.bindLabel], term)
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
</div>
|
||||
}
|
||||
<div class="form-group ms-md-auto">
|
||||
<button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">{{ cancelBtnCaption }}</button>
|
||||
<button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,13 +151,6 @@
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
|
||||
& section {
|
||||
position: absolute;
|
||||
text-align: initial;
|
||||
box-sizing: border-box;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
& .annotationTextContent {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,6 @@ describe('PngxPdfViewerComponent', () => {
|
||||
}
|
||||
expect(viewer).toBeInstanceOf(PDFSinglePageViewer)
|
||||
expect(viewer.options.textLayerMode).toBe(0)
|
||||
expect(viewer.options.enableSelectionRendering).toBe(false)
|
||||
})
|
||||
|
||||
it('applies zoom, rotation, and page changes', async () => {
|
||||
@@ -130,25 +129,13 @@ describe('PngxPdfViewerComponent', () => {
|
||||
;(component as any).applyScale()
|
||||
expect(viewer.currentScaleValue).toBe(PdfZoomScale.PageFit)
|
||||
expect(viewer.currentScale).toBe(2)
|
||||
})
|
||||
|
||||
it('does not reapply scale for page-only changes', async () => {
|
||||
await initComponent()
|
||||
|
||||
const pdf = (component as any).pdf as { numPages: number }
|
||||
pdf.numPages = 3
|
||||
const viewer = (component as any).pdfViewer as PDFViewer
|
||||
viewer.setDocument(pdf)
|
||||
const applyScaleSpy = jest.spyOn(component as any, 'applyScale')
|
||||
component.page = 2
|
||||
|
||||
component.ngOnChanges({
|
||||
page: new SimpleChange(1, 2, false),
|
||||
})
|
||||
|
||||
expect(viewer.currentPageNumber).toBe(2)
|
||||
;(component as any).lastViewerPage = 2
|
||||
;(component as any).applyViewerState()
|
||||
expect((component as any).lastViewerPage).toBeUndefined()
|
||||
expect(applyScaleSpy).not.toHaveBeenCalled()
|
||||
expect(applyScaleSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reset the viewer when it is already on the requested page', async () => {
|
||||
@@ -296,22 +283,6 @@ describe('PngxPdfViewerComponent', () => {
|
||||
expect(mockViewer.currentPageNumber).toBe(1)
|
||||
})
|
||||
|
||||
it('reloads when the source revision changes', () => {
|
||||
const resetSpy = jest.spyOn(component as any, 'resetViewerState')
|
||||
const loadSpy = jest
|
||||
.spyOn(component as any, 'loadDocument')
|
||||
.mockImplementation(() => {})
|
||||
component.src = 'test.pdf'
|
||||
component.sourceRevision = 1
|
||||
|
||||
component.ngOnChanges({
|
||||
sourceRevision: new SimpleChange(0, 1, false),
|
||||
})
|
||||
|
||||
expect(resetSpy).toHaveBeenCalled()
|
||||
expect(loadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies viewer state after view init when already loaded', () => {
|
||||
const applySpy = jest.spyOn(component as any, 'applyViewerState')
|
||||
;(component as any).hasLoaded = true
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
AnnotationMode,
|
||||
getDocument,
|
||||
GlobalWorkerOptions,
|
||||
PDFDocumentLoadingTask,
|
||||
@@ -44,7 +43,6 @@ export class PngxPdfViewerComponent
|
||||
private readonly document = inject<Document>(DOCUMENT)
|
||||
|
||||
@Input() src!: string
|
||||
@Input() sourceRevision = 0
|
||||
@Input() password?: string
|
||||
@Input() page?: number
|
||||
@Output() pageChange = new EventEmitter<number>()
|
||||
@@ -95,7 +93,7 @@ export class PngxPdfViewerComponent
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['src'] || changes['sourceRevision'] || changes['password']) {
|
||||
if (changes['src'] || changes['password']) {
|
||||
this.resetViewerState()
|
||||
if (this.src) {
|
||||
this.loadDocument()
|
||||
@@ -117,10 +115,7 @@ export class PngxPdfViewerComponent
|
||||
changes['zoomScale'] ||
|
||||
changes['rotation']
|
||||
) {
|
||||
// Prevent loop with page / scale application see https://github.com/paperless-ngx/paperless-ngx/issues/13404
|
||||
this.applyViewerState(
|
||||
!!(changes['zoom'] || changes['zoomScale'] || changes['rotation'])
|
||||
)
|
||||
this.applyViewerState()
|
||||
}
|
||||
|
||||
if (changes['searchQuery']) {
|
||||
@@ -222,8 +217,6 @@ export class PngxPdfViewerComponent
|
||||
linkService: this.linkService,
|
||||
findController: this.findController,
|
||||
textLayerMode,
|
||||
annotationMode: AnnotationMode.ENABLE,
|
||||
enableSelectionRendering: false,
|
||||
removePageBorders: true,
|
||||
}
|
||||
|
||||
@@ -246,7 +239,7 @@ export class PngxPdfViewerComponent
|
||||
}
|
||||
}
|
||||
|
||||
private applyViewerState(applyScale = true): void {
|
||||
private applyViewerState(): void {
|
||||
if (!this.pdfViewer) {
|
||||
return
|
||||
}
|
||||
@@ -270,7 +263,7 @@ export class PngxPdfViewerComponent
|
||||
if (this.page === this.lastViewerPage) {
|
||||
this.lastViewerPage = undefined
|
||||
}
|
||||
if (hasPages && applyScale) {
|
||||
if (hasPages) {
|
||||
this.applyScale()
|
||||
}
|
||||
this.dispatchFindIfReady()
|
||||
|
||||
+3
-3
@@ -22,7 +22,7 @@
|
||||
}
|
||||
</div>
|
||||
<div class="me-1">
|
||||
<small>{{ownerFilterLabel}}</small>
|
||||
<small i18n>My documents</small>
|
||||
</div>
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
||||
@@ -32,7 +32,7 @@
|
||||
}
|
||||
</div>
|
||||
<div class="me-1">
|
||||
<small>{{ownerExclusionFilterLabel}}</small>
|
||||
<small i18n>Shared with me</small>
|
||||
</div>
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
||||
@@ -42,7 +42,7 @@
|
||||
}
|
||||
</div>
|
||||
<div class="me-1">
|
||||
<small>{{sharedByFilterLabel}}</small>
|
||||
<small i18n>Shared by me</small>
|
||||
</div>
|
||||
</button>
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
||||
|
||||
-52
@@ -94,58 +94,6 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
expect(component.isActive).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should describe concrete user filters honestly', () => {
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
||||
component.selectionModel.userID = 1
|
||||
expect(component.ownerFilterLabel).toEqual('Owned by user1')
|
||||
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||
component.selectionModel.excludeUsers = [1]
|
||||
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
|
||||
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
||||
component.selectionModel.userID = 1
|
||||
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
|
||||
})
|
||||
|
||||
it('should describe concrete filters when usernames are unavailable', () => {
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
||||
component.selectionModel.userID = 99
|
||||
expect(component.ownerFilterLabel).toEqual('Owned by another user')
|
||||
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||
component.selectionModel.excludeUsers = [99]
|
||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||
'Not owned by another user'
|
||||
)
|
||||
|
||||
component.selectionModel.excludeUsers = [98, 99]
|
||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||
'Not owned by selected users'
|
||||
)
|
||||
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
||||
component.selectionModel.userID = 99
|
||||
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
|
||||
})
|
||||
|
||||
it('should retain relative labels for filters bound to the current user', () => {
|
||||
component.selectionModel.userID = currentUserID
|
||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
||||
|
||||
component.selectionModel.excludeUsers = [currentUserID]
|
||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||
})
|
||||
|
||||
it('should retain relative labels for inactive filter choices', () => {
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||
|
||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
||||
})
|
||||
|
||||
it('should support reset', () => {
|
||||
component.setFilter(OwnerFilterType.OTHERS)
|
||||
expect(component.selectionModel.ownerFilter).not.toEqual(
|
||||
|
||||
-53
@@ -93,55 +93,6 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
)
|
||||
}
|
||||
|
||||
get ownerFilterLabel(): string {
|
||||
if (
|
||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
|
||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
||||
) {
|
||||
return $localize`My documents`
|
||||
}
|
||||
|
||||
const username = this.getUsername(this.selectionModel?.userID)
|
||||
return username
|
||||
? $localize`Owned by ${username}`
|
||||
: $localize`Owned by another user`
|
||||
}
|
||||
|
||||
get ownerExclusionFilterLabel(): string {
|
||||
const excludedUsers = this.selectionModel?.excludeUsers ?? []
|
||||
if (
|
||||
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
|
||||
(excludedUsers.length === 1 &&
|
||||
excludedUsers[0] === this.settingsService.currentUser()?.id)
|
||||
) {
|
||||
return $localize`Shared with me`
|
||||
}
|
||||
|
||||
const usernames = excludedUsers
|
||||
.map((id) => this.getUsername(id))
|
||||
.filter(Boolean)
|
||||
if (usernames.length === excludedUsers.length && usernames.length > 0) {
|
||||
return $localize`Not owned by ${usernames.join(', ')}`
|
||||
}
|
||||
return excludedUsers.length === 1
|
||||
? $localize`Not owned by another user`
|
||||
: $localize`Not owned by selected users`
|
||||
}
|
||||
|
||||
get sharedByFilterLabel(): string {
|
||||
if (
|
||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME ||
|
||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
||||
) {
|
||||
return $localize`Shared by me`
|
||||
}
|
||||
|
||||
const username = this.getUsername(this.selectionModel?.userID)
|
||||
return username
|
||||
? $localize`Shared by ${username}`
|
||||
: $localize`Shared by another user`
|
||||
}
|
||||
|
||||
constructor() {
|
||||
const userService = inject(UserService)
|
||||
|
||||
@@ -213,8 +164,4 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
}
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
private getUsername(userID: number): string {
|
||||
return this.users().find((user) => user.id === userID)?.username
|
||||
}
|
||||
}
|
||||
|
||||
-9
@@ -17,10 +17,6 @@ const permissions = [
|
||||
'view_document',
|
||||
'change_document',
|
||||
'delete_document',
|
||||
'add_sharelinkbundle',
|
||||
'view_sharelinkbundle',
|
||||
'change_sharelinkbundle',
|
||||
'delete_sharelinkbundle',
|
||||
'change_tag',
|
||||
'view_documenttype',
|
||||
]
|
||||
@@ -79,7 +75,6 @@ describe('PermissionsSelectComponent', () => {
|
||||
component.ngOnInit()
|
||||
component.writeValue(permissions)
|
||||
expect(component.typesWithAllActions).toContain('Document')
|
||||
expect(component.typesWithAllActions).toContain('ShareLinkBundle')
|
||||
})
|
||||
|
||||
it('should update checkboxes on permissions set', () => {
|
||||
@@ -90,10 +85,6 @@ describe('PermissionsSelectComponent', () => {
|
||||
expect(input1.nativeElement.checked).toBeTruthy()
|
||||
const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
|
||||
expect(input2.nativeElement.checked).toBeTruthy()
|
||||
const bundleInput = fixture.debugElement.query(
|
||||
By.css('input#ShareLinkBundle_Add')
|
||||
)
|
||||
expect(bundleInput.nativeElement.checked).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disable checkboxes when permissions are inherited', () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<a [href]="link ?? previewUrl" class="{{linkClasses}}" [target]="linkTarget" [title]="linkTitle"
|
||||
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle" container="body"
|
||||
autoClose="true" [popoverClass]="popoverClass()" [popperOptions]="popperOptions"
|
||||
(mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
|
||||
autoClose="true" [popoverClass]="popoverClass()" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
|
||||
<ng-content></ng-content>
|
||||
</a>
|
||||
<ng-template #previewContent>
|
||||
|
||||
@@ -67,10 +67,6 @@ describe('PreviewPopupComponent', () => {
|
||||
expect(component.useNativePdfViewer).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should use fixed positioning for the preview popover', () => {
|
||||
expect(component.popperOptions({}).strategy).toEqual('fixed')
|
||||
})
|
||||
|
||||
it('should render object if native PDF viewer enabled', () => {
|
||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
|
||||
component.popover.open()
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Options } from '@popperjs/core'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { first, Subject, takeUntil } from 'rxjs'
|
||||
import { Document } from 'src/app/data/document'
|
||||
@@ -75,11 +74,6 @@ export class PreviewPopupComponent implements OnDestroy {
|
||||
|
||||
readonly popoverClass = signal('shadow popover-preview')
|
||||
|
||||
readonly popperOptions = (options: Partial<Options>): Partial<Options> => ({
|
||||
...options,
|
||||
strategy: 'fixed',
|
||||
})
|
||||
|
||||
get renderAsObject(): boolean {
|
||||
return (this.isPdf && this.useNativePdfViewer) || !this.isPdf
|
||||
}
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap"
|
||||
(click)="submit()"
|
||||
[disabled]="loading() || !buttonsEnabled()">
|
||||
[disabled]="loading() || !buttonsEnabled">
|
||||
@if (loading()) {
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
}
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ describe('ShareLinkBundleDialogComponent', () => {
|
||||
file_version: FileVersion.Original,
|
||||
expiration_days: 3,
|
||||
})
|
||||
expect(component.buttonsEnabled()).toBe(false)
|
||||
expect(component.buttonsEnabled).toBe(false)
|
||||
expect(confirmSpy).toHaveBeenCalled()
|
||||
|
||||
component.form.setValue({
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
|
||||
: FileVersion.Original,
|
||||
expiration_days: this.form.value.expirationDays,
|
||||
}
|
||||
this.buttonsEnabled.set(false)
|
||||
this.buttonsEnabled = false
|
||||
super.confirm()
|
||||
}
|
||||
|
||||
|
||||
+2
-8
@@ -2,16 +2,10 @@
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())">
|
||||
@if (loading()) {
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
} @else if (noSuggestions) {
|
||||
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
|
||||
} @else {
|
||||
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
|
||||
}
|
||||
@if (noSuggestions) {
|
||||
<span class="d-none d-lg-inline ps-1" i18n>No suggestions</span>
|
||||
} @else {
|
||||
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
|
||||
}
|
||||
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
|
||||
@if (totalSuggestions > 0) {
|
||||
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
|
||||
}
|
||||
@@ -25,7 +19,7 @@
|
||||
|
||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||
<div class="list-group list-group-flush small pb-0">
|
||||
@if (totalSuggestions === 0) {
|
||||
@if (!suggestions()?.suggested_tags && !suggestions()?.suggested_document_types && !suggestions()?.suggested_correspondents) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||
</div>
|
||||
|
||||
-29
@@ -30,34 +30,6 @@ describe('SuggestionsDropdownComponent', () => {
|
||||
expect(component.totalSuggestions).toBe(4)
|
||||
})
|
||||
|
||||
it('should show when a completed request returned no suggestions', () => {
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
correspondents: [],
|
||||
tags: [],
|
||||
document_types: [],
|
||||
storage_paths: [],
|
||||
dates: [],
|
||||
})
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.noSuggestions).toBeTruthy()
|
||||
expect(fixture.nativeElement.textContent).toContain('No suggestions')
|
||||
})
|
||||
|
||||
it('should not show the empty state before a request or with suggestions', () => {
|
||||
expect(component.noSuggestions).toBeFalsy()
|
||||
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
correspondents: [],
|
||||
tags: [42],
|
||||
document_types: [],
|
||||
storage_paths: [],
|
||||
dates: [],
|
||||
})
|
||||
|
||||
expect(component.noSuggestions).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should emit getSuggestions when clickSuggest is called and suggestions are null', () => {
|
||||
jest.spyOn(component.getSuggestions, 'emit')
|
||||
fixture.componentRef.setInput('suggestions', null)
|
||||
@@ -87,6 +59,5 @@ describe('SuggestionsDropdownComponent', () => {
|
||||
})
|
||||
component.clickSuggest()
|
||||
expect(component.dropdown.open).toBeTruthy()
|
||||
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
|
||||
})
|
||||
})
|
||||
|
||||
-17
@@ -61,21 +61,4 @@ export class SuggestionsDropdownComponent {
|
||||
this.suggestions()?.suggested_document_types?.length || 0
|
||||
)
|
||||
}
|
||||
|
||||
get noSuggestions(): boolean {
|
||||
const suggestions = this.suggestions()
|
||||
return (
|
||||
suggestions != null &&
|
||||
!suggestions.title &&
|
||||
!suggestions.tags?.length &&
|
||||
!suggestions.suggested_tags?.length &&
|
||||
!suggestions.correspondents?.length &&
|
||||
!suggestions.suggested_correspondents?.length &&
|
||||
!suggestions.document_types?.length &&
|
||||
!suggestions.suggested_document_types?.length &&
|
||||
!suggestions.storage_paths?.length &&
|
||||
!suggestions.suggested_storage_paths?.length &&
|
||||
!suggestions.dates?.length
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -1,7 +1,6 @@
|
||||
<pngx-widget-frame
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
||||
[title]="savedView.name"
|
||||
[titleIcon]="savedView.icon || 'funnel'"
|
||||
[loading]="false"
|
||||
[draggable]="savedView"
|
||||
>
|
||||
|
||||
+1
-6
@@ -8,12 +8,7 @@
|
||||
<i-bs name="grip-vertical"></i-bs>
|
||||
</div>
|
||||
}
|
||||
<h6 class="card-title mb-0">
|
||||
@if (titleIcon()) {
|
||||
<i-bs class="me-2" [name]="titleIcon()"></i-bs>
|
||||
}
|
||||
{{title()}}
|
||||
</h6>
|
||||
<h6 class="card-title mb-0">{{title()}}</h6>
|
||||
<ng-content select="[title-badge]"></ng-content>
|
||||
@if (badge() !== null && badge() !== undefined) {
|
||||
<span class="badge bg-info text-dark ms-2">{{badge()}}</span>
|
||||
|
||||
@@ -16,8 +16,6 @@ export class WidgetFrameComponent implements AfterViewInit {
|
||||
|
||||
title = input<string>()
|
||||
|
||||
titleIcon = input<string>()
|
||||
|
||||
draggable = input<any>()
|
||||
|
||||
cardless = input(false)
|
||||
|
||||
@@ -113,8 +113,8 @@
|
||||
|
||||
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
||||
|
||||
<div class="btn-toolbar justify-content-end mb-1 pb-3 gap-2 row-gap-2 border-bottom">
|
||||
<div class="btn-group me-auto">
|
||||
<div class="btn-toolbar mb-1 border-bottom">
|
||||
<div class="btn-group pb-3">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
|
||||
<i-bs width="1.2em" height="1.2em" name="x"></i-bs>
|
||||
</button>
|
||||
@@ -127,36 +127,32 @@
|
||||
</div>
|
||||
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<div class="d-flex gap-2">
|
||||
<div class="btn-group">
|
||||
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
|
||||
[disabled]="!userCanEdit || suggestionsLoading()"
|
||||
[loading]="suggestionsLoading()"
|
||||
[suggestions]="suggestions()"
|
||||
[aiEnabled]="aiEnabled"
|
||||
(getSuggestions)="getSuggestions()"
|
||||
(addTag)="createTag($event)"
|
||||
(addDocumentType)="createDocumentType($event)"
|
||||
(addCorrespondent)="createCorrespondent($event)">
|
||||
</pngx-suggestions-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<pngx-custom-fields-dropdown
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
||||
[documentId]="documentId()"
|
||||
[disabled]="!userCanEdit"
|
||||
[existingFields]="document()?.custom_fields"
|
||||
(created)="refreshCustomFields()"
|
||||
(added)="addField($event)">
|
||||
</pngx-custom-fields-dropdown>
|
||||
</div>
|
||||
<div class="btn-group pb-3 ms-auto">
|
||||
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
|
||||
[disabled]="!userCanEdit || suggestionsLoading()"
|
||||
[loading]="suggestionsLoading()"
|
||||
[suggestions]="suggestions()"
|
||||
[aiEnabled]="aiEnabled"
|
||||
(getSuggestions)="getSuggestions()"
|
||||
(addTag)="createTag($event)"
|
||||
(addDocumentType)="createDocumentType($event)"
|
||||
(addCorrespondent)="createCorrespondent($event)">
|
||||
</pngx-suggestions-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="ps-3">
|
||||
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
||||
<div class="btn-group pb-3 ms-2">
|
||||
<pngx-custom-fields-dropdown
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
||||
[documentId]="documentId()"
|
||||
[disabled]="!userCanEdit"
|
||||
[existingFields]="document()?.custom_fields"
|
||||
(created)="refreshCustomFields()"
|
||||
(added)="addField($event)">
|
||||
</pngx-custom-fields-dropdown>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
||||
</div>
|
||||
|
||||
<ul ngbNav #nav="ngbNav" class="nav-underline flex-nowrap flex-md-wrap overflow-auto" (navChange)="onNavChange($event)" [activeId]="activeNavID()" (activeIdChange)="activeNavID.set($event)">
|
||||
@@ -280,7 +276,7 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end border-top pt-3">
|
||||
<div class="d-flex border-top pt-3">
|
||||
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -444,7 +440,7 @@
|
||||
</div>
|
||||
|
||||
<ng-template #saveButtons>
|
||||
<div class="btn-group">
|
||||
<div class="btn-group pb-3 ms-4">
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<button type="submit" class="order-3 btn btn-sm btn-primary" i18n [disabled]="!userCanEdit || networkActive() || (isDirty$ | async) !== true">Save</button>
|
||||
@if (hasNext()) {
|
||||
@@ -476,7 +472,6 @@
|
||||
<div class="preview-sticky pdf-viewer-container">
|
||||
<pngx-pdf-viewer
|
||||
[src]="pdfSource()"
|
||||
[sourceRevision]="previewRevision()"
|
||||
[password]="pdfPassword()"
|
||||
[renderMode]="PdfRenderMode.All"
|
||||
[page]="previewCurrentPage()" (pageChange)="previewCurrentPage.set($event)"
|
||||
|
||||
@@ -467,13 +467,6 @@ describe('DocumentDetailComponent', () => {
|
||||
const docWithVersions = {
|
||||
...doc,
|
||||
versions: [
|
||||
{
|
||||
id: 10,
|
||||
added: new Date('2024-01-02T00:00:00Z'),
|
||||
version_label: 'Edited',
|
||||
checksum: 'bbbb',
|
||||
is_root: false,
|
||||
},
|
||||
{
|
||||
id: doc.id,
|
||||
added: new Date('2024-01-01T00:00:00Z'),
|
||||
@@ -481,6 +474,13 @@ describe('DocumentDetailComponent', () => {
|
||||
checksum: 'aaaa',
|
||||
is_root: true,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
added: new Date('2024-01-02T00:00:00Z'),
|
||||
version_label: 'Edited',
|
||||
checksum: 'bbbb',
|
||||
is_root: false,
|
||||
},
|
||||
],
|
||||
} as Document
|
||||
|
||||
@@ -1232,8 +1232,8 @@ describe('DocumentDetailComponent', () => {
|
||||
|
||||
metadataSpy.mockClear()
|
||||
component.document().versions = [
|
||||
{ id: 10, is_root: false },
|
||||
{ id: doc.id, is_root: true },
|
||||
{ id: 10, is_root: false },
|
||||
] as any
|
||||
jest.spyOn(documentService, 'getPreviewUrl').mockReturnValue('preview-root')
|
||||
jest.spyOn(documentService, 'getThumbUrl').mockReturnValue('thumb-root')
|
||||
@@ -1564,7 +1564,7 @@ describe('DocumentDetailComponent', () => {
|
||||
dialog.confirmClicked.next()
|
||||
await openModal.result
|
||||
|
||||
expect(dialog.buttonsEnabled()).toBe(false)
|
||||
expect(dialog.buttonsEnabled).toBe(false)
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
expect((component as any).incomingUpdateModal).toBeNull()
|
||||
})
|
||||
@@ -1602,7 +1602,6 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(openDoc.__changedFields).toEqual([])
|
||||
expect(setDirtySpy).toHaveBeenCalledWith(openDoc, false)
|
||||
expect(saveSpy).toHaveBeenCalled()
|
||||
expect(component.previewRevision()).toBe(1)
|
||||
})
|
||||
|
||||
it('should ignore incoming update for a different document id', () => {
|
||||
@@ -1789,7 +1788,7 @@ describe('DocumentDetailComponent', () => {
|
||||
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
expect(component.networkActive()).toBe(false)
|
||||
expect(dialog.buttonsEnabled()).toBe(true)
|
||||
expect(dialog.buttonsEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should refresh the document when removing password in update mode', () => {
|
||||
@@ -1929,8 +1928,8 @@ describe('DocumentDetailComponent', () => {
|
||||
component.documentId.set(doc.id)
|
||||
component.document.set({ ...doc, versions: [] } as Document)
|
||||
const updatedVersions = [
|
||||
{ id: 10, is_root: false },
|
||||
{ id: doc.id, is_root: true },
|
||||
{ id: 10, is_root: false },
|
||||
] as any
|
||||
const openDoc = { ...doc, versions: [] } as Document
|
||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||
@@ -2046,8 +2045,8 @@ describe('DocumentDetailComponent', () => {
|
||||
it('should include version in download and print only for non-latest selected version', () => {
|
||||
initNormally()
|
||||
component.document().versions = [
|
||||
{ id: 10, is_root: false },
|
||||
{ id: doc.id, is_root: true },
|
||||
{ id: 10, is_root: false },
|
||||
] as any
|
||||
|
||||
const getDownloadUrlSpy = jest
|
||||
@@ -2161,14 +2160,8 @@ describe('DocumentDetailComponent', () => {
|
||||
it('should support open share links and email modals', () => {
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
initNormally()
|
||||
component.selectedVersionId.set(10)
|
||||
component.openShareLinks()
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
expect(
|
||||
(
|
||||
modalSpy.mock.results[0].value as NgbModalRef
|
||||
).componentInstance.documentId()
|
||||
).toBe(10)
|
||||
component.openEmailDocument()
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
})
|
||||
@@ -2197,6 +2190,9 @@ describe('DocumentDetailComponent', () => {
|
||||
const appendChildSpy = jest
|
||||
.spyOn(document.body, 'appendChild')
|
||||
.mockImplementation((node: Node) => node)
|
||||
const removeChildSpy = jest
|
||||
.spyOn(document.body, 'removeChild')
|
||||
.mockImplementation((node: Node) => node)
|
||||
const createObjectURLSpy = jest
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue('blob:mock-url')
|
||||
@@ -2215,7 +2211,6 @@ describe('DocumentDetailComponent', () => {
|
||||
src: '',
|
||||
onload: null,
|
||||
contentWindow: mockContentWindow,
|
||||
remove: jest.fn(),
|
||||
}
|
||||
|
||||
const createElementSpy = jest
|
||||
@@ -2259,11 +2254,12 @@ describe('DocumentDetailComponent', () => {
|
||||
mockContentWindow.onafterprint(new Event('afterprint'))
|
||||
}
|
||||
|
||||
expect(mockIframe.remove).toHaveBeenCalled()
|
||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
|
||||
createElementSpy.mockRestore()
|
||||
appendChildSpy.mockRestore()
|
||||
removeChildSpy.mockRestore()
|
||||
createObjectURLSpy.mockRestore()
|
||||
revokeObjectURLSpy.mockRestore()
|
||||
})
|
||||
@@ -2310,6 +2306,9 @@ describe('DocumentDetailComponent', () => {
|
||||
const appendChildSpy = jest
|
||||
.spyOn(document.body, 'appendChild')
|
||||
.mockImplementation((node: Node) => node)
|
||||
const removeChildSpy = jest
|
||||
.spyOn(document.body, 'removeChild')
|
||||
.mockImplementation((node: Node) => node)
|
||||
const createObjectURLSpy = jest
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue('blob:mock-url')
|
||||
@@ -2332,7 +2331,6 @@ describe('DocumentDetailComponent', () => {
|
||||
src: '',
|
||||
onload: null,
|
||||
contentWindow: mockContentWindow,
|
||||
remove: jest.fn(),
|
||||
}
|
||||
|
||||
const createElementSpy = jest
|
||||
@@ -2355,21 +2353,15 @@ describe('DocumentDetailComponent', () => {
|
||||
|
||||
if (expectToast) {
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(mockIframe.remove).toHaveBeenCalled()
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
} else {
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
expect(mockIframe.remove).not.toHaveBeenCalled()
|
||||
expect(revokeObjectURLSpy).not.toHaveBeenCalled()
|
||||
|
||||
component.ngOnDestroy()
|
||||
|
||||
expect(mockIframe.remove).toHaveBeenCalled()
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
}
|
||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
|
||||
createElementSpy.mockRestore()
|
||||
appendChildSpy.mockRestore()
|
||||
removeChildSpy.mockRestore()
|
||||
createObjectURLSpy.mockRestore()
|
||||
revokeObjectURLSpy.mockRestore()
|
||||
})
|
||||
|
||||
@@ -249,7 +249,6 @@ export class DocumentDetailComponent
|
||||
titleSubject: Subject<string> = new Subject()
|
||||
readonly previewUrl = signal<string>(undefined)
|
||||
readonly pdfSource = signal<string>(undefined)
|
||||
readonly previewRevision = signal(0)
|
||||
readonly pdfPassword = signal<string>(undefined)
|
||||
readonly thumbUrl = signal<string>(undefined)
|
||||
readonly previewText = signal<string>(undefined)
|
||||
@@ -289,8 +288,6 @@ export class DocumentDetailComponent
|
||||
private incomingUpdateModal: NgbModalRef
|
||||
private pendingIncomingUpdate: IncomingDocumentUpdate
|
||||
private lastLocalSaveModified: string | null = null
|
||||
private printIframe: HTMLIFrameElement | null = null
|
||||
private printBlobUrl: string | null = null
|
||||
|
||||
requiresPassword: boolean = false
|
||||
password: string
|
||||
@@ -612,9 +609,6 @@ export class DocumentDetailComponent
|
||||
.subscribe()
|
||||
}
|
||||
this.updateComponent(useDoc)
|
||||
if (forceRemote) {
|
||||
this.previewRevision.update((revision) => revision + 1)
|
||||
}
|
||||
this.titleSubject
|
||||
.pipe(
|
||||
debounceTime(1000),
|
||||
@@ -659,7 +653,7 @@ export class DocumentDetailComponent
|
||||
modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
|
||||
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.close()
|
||||
this.reloadRemoteVersion()
|
||||
})
|
||||
@@ -870,7 +864,6 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.cleanupPrintDocument()
|
||||
this.unsubscribeNotifier.next(this)
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
@@ -889,9 +882,13 @@ export class DocumentDetailComponent
|
||||
|
||||
updateComponent(doc: Document) {
|
||||
this.document.set(doc)
|
||||
// Default selected version is the newest version, which the API returns first
|
||||
// Default selected version is the newest version
|
||||
const versions = doc.versions ?? []
|
||||
this.selectedVersionId.set(versions.length ? versions[0].id : doc.id)
|
||||
this.selectedVersionId.set(
|
||||
versions.length
|
||||
? Math.max(...versions.map((version) => version.id))
|
||||
: doc.id
|
||||
)
|
||||
this.previewLoaded.set(false)
|
||||
this.requiresPassword = false
|
||||
this.updateFormForCustomFields()
|
||||
@@ -1370,7 +1367,7 @@ export class DocumentDetailComponent
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(
|
||||
switchMap(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
return this.documentsService.delete(this.document())
|
||||
})
|
||||
)
|
||||
@@ -1382,7 +1379,7 @@ export class DocumentDetailComponent
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError($localize`Error deleting document`, error)
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
this.subscribeModalDelete(modal)
|
||||
},
|
||||
})
|
||||
@@ -1407,7 +1404,7 @@ export class DocumentDetailComponent
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.documentsService
|
||||
.reprocessDocuments({ documents: [this.document().id] })
|
||||
.subscribe({
|
||||
@@ -1421,7 +1418,7 @@ export class DocumentDetailComponent
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
}
|
||||
this.toastService.showError(
|
||||
$localize`Error executing operation`,
|
||||
@@ -1437,8 +1434,7 @@ export class DocumentDetailComponent
|
||||
if (!versions.length || !this.selectedVersionId()) {
|
||||
return null
|
||||
}
|
||||
// The API returns versions newest first
|
||||
const latestVersionId = versions[0].id
|
||||
const latestVersionId = Math.max(...versions.map((version) => version.id))
|
||||
return this.selectedVersionId() === latestVersionId
|
||||
? null
|
||||
: this.selectedVersionId()
|
||||
@@ -1795,7 +1791,7 @@ export class DocumentDetailComponent
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.documentsService
|
||||
.editPdfDocuments([sourceDocumentId], {
|
||||
operations: modal.componentInstance.getOperations(),
|
||||
@@ -1818,7 +1814,7 @@ export class DocumentDetailComponent
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
}
|
||||
this.toastService.showError(
|
||||
$localize`Error executing PDF edit operation`,
|
||||
@@ -1852,7 +1848,7 @@ export class DocumentDetailComponent
|
||||
const sourceDocumentId = this.selectedVersionId() ?? this.document().id
|
||||
const dialog =
|
||||
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
||||
dialog.buttonsEnabled.set(false)
|
||||
dialog.buttonsEnabled = false
|
||||
this.networkActive.set(true)
|
||||
this.documentsService
|
||||
.removePasswordDocuments([sourceDocumentId], {
|
||||
@@ -1877,7 +1873,7 @@ export class DocumentDetailComponent
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
dialog.buttonsEnabled.set(true)
|
||||
dialog.buttonsEnabled = true
|
||||
this.networkActive.set(false)
|
||||
this.toastService.showError(
|
||||
$localize`Error executing password removal operation`,
|
||||
@@ -1889,7 +1885,6 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
printDocument() {
|
||||
this.cleanupPrintDocument()
|
||||
const selectedVersionId = this.getSelectedNonLatestVersionId()
|
||||
const printUrl = this.documentsService.getDownloadUrl(
|
||||
this.document().id,
|
||||
@@ -1903,8 +1898,6 @@ export class DocumentDetailComponent
|
||||
next: (blob) => {
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const iframe = document.createElement('iframe')
|
||||
this.printIframe = iframe
|
||||
this.printBlobUrl = blobUrl
|
||||
iframe.style.position = 'fixed'
|
||||
iframe.style.right = '0'
|
||||
iframe.style.bottom = '0'
|
||||
@@ -1920,19 +1913,22 @@ export class DocumentDetailComponent
|
||||
iframe.contentWindow.focus()
|
||||
iframe.contentWindow.print()
|
||||
iframe.contentWindow.onafterprint = () => {
|
||||
this.cleanupPrintDocument()
|
||||
document.body.removeChild(iframe)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
// FF throws cross-origin error on onafterprint
|
||||
const isCrossOriginAfterPrintError =
|
||||
err instanceof DOMException &&
|
||||
err.message.includes('onafterprint')
|
||||
// FF throws here while print preview is still reading the iframe
|
||||
// so keep it alive until the next print or teardown
|
||||
if (!isCrossOriginAfterPrintError) {
|
||||
this.toastService.showError($localize`Print failed.`, err)
|
||||
timer(100).subscribe(() => this.cleanupPrintDocument())
|
||||
}
|
||||
timer(100).subscribe(() => {
|
||||
// delay to avoid FF print failure
|
||||
document.body.removeChild(iframe)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1945,20 +1941,9 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
|
||||
private cleanupPrintDocument() {
|
||||
if (this.printIframe) this.printIframe.remove()
|
||||
this.printIframe = null
|
||||
if (this.printBlobUrl) {
|
||||
URL.revokeObjectURL(this.printBlobUrl)
|
||||
this.printBlobUrl = null
|
||||
}
|
||||
}
|
||||
|
||||
public openShareLinks() {
|
||||
const modal = this.modalService.open(ShareLinksDialogComponent)
|
||||
modal.componentInstance.documentId.set(
|
||||
this.selectedVersionId() ?? this.document().id
|
||||
)
|
||||
modal.componentInstance.documentId.set(this.document().id)
|
||||
modal.componentInstance.hasArchiveVersion.set(
|
||||
this.metadata()?.has_archive_version ??
|
||||
!!this.document()?.archived_file_name
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" i18n>Add existing document as version</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pngx-input-document-link
|
||||
[(ngModel)]="selectedDocumentIDs"
|
||||
[parentDocumentID]="rootDocumentID"
|
||||
[minimal]="true"
|
||||
placeholder="Search for a document"
|
||||
i18n-placeholder
|
||||
></pngx-input-document-link>
|
||||
<div class="form-text mt-2" i18n>Select one document to add as a version.</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" [disabled]="!buttonsEnabled()" i18n>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="confirm()" [disabled]="!buttonsEnabled() || selectedDocumentIDs.length !== 1" i18n>Add version</button>
|
||||
</div>
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog.component'
|
||||
|
||||
describe('AddExistingDocumentVersionDialogComponent', () => {
|
||||
let component: AddExistingDocumentVersionDialogComponent
|
||||
let fixture: ComponentFixture<AddExistingDocumentVersionDialogComponent>
|
||||
let activeModal: jest.Mocked<Pick<NgbActiveModal, 'dismiss'>>
|
||||
|
||||
beforeEach(async () => {
|
||||
activeModal = { dismiss: jest.fn() }
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AddExistingDocumentVersionDialogComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: NgbActiveModal,
|
||||
useValue: activeModal,
|
||||
},
|
||||
{
|
||||
provide: DocumentService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(AddExistingDocumentVersionDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
component.rootDocumentID = 3
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should emit the single selected document', () => {
|
||||
const emitSpy = jest.spyOn(component.confirmClicked, 'emit')
|
||||
component.selectedDocumentIDs = [20]
|
||||
|
||||
component.confirm()
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(20)
|
||||
})
|
||||
|
||||
it('should require exactly one selected document', () => {
|
||||
const emitSpy = jest.spyOn(component.confirmClicked, 'emit')
|
||||
component.selectedDocumentIDs = [20, 21]
|
||||
|
||||
component.confirm()
|
||||
|
||||
expect(emitSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should dismiss on cancel', () => {
|
||||
component.cancel()
|
||||
|
||||
expect(activeModal.dismiss).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should re-render the buttons when they are toggled from outside', async () => {
|
||||
const cancelButton: HTMLButtonElement = fixture.nativeElement.querySelector(
|
||||
'.modal-footer button'
|
||||
)
|
||||
expect(cancelButton.disabled).toBeFalsy()
|
||||
|
||||
// No detectChanges: the dropdown toggling this from a request callback is
|
||||
// all that happens, and nothing else schedules a render for the modal
|
||||
component.buttonsEnabled.set(false)
|
||||
await fixture.whenStable()
|
||||
expect(cancelButton.disabled).toBeTruthy()
|
||||
|
||||
component.buttonsEnabled.set(true)
|
||||
await fixture.whenStable()
|
||||
expect(cancelButton.disabled).toBeFalsy()
|
||||
})
|
||||
})
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { DocumentLinkComponent } from 'src/app/components/common/input/document-link/document-link.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-add-existing-document-version-dialog',
|
||||
templateUrl: './add-existing-document-version-dialog.component.html',
|
||||
imports: [DocumentLinkComponent, FormsModule],
|
||||
})
|
||||
export class AddExistingDocumentVersionDialogComponent {
|
||||
private readonly activeModal = inject(NgbActiveModal)
|
||||
|
||||
@Input() rootDocumentID: number
|
||||
@Output() confirmClicked = new EventEmitter<number>()
|
||||
|
||||
selectedDocumentIDs: number[] = []
|
||||
readonly buttonsEnabled = signal(true)
|
||||
|
||||
confirm(): void {
|
||||
if (this.selectedDocumentIDs.length !== 1) return
|
||||
this.confirmClicked.emit(this.selectedDocumentIDs[0])
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.activeModal.dismiss()
|
||||
}
|
||||
}
|
||||
+7
-21
@@ -24,26 +24,13 @@
|
||||
class="visually-hidden"
|
||||
(change)="onVersionFileSelected($event)"
|
||||
/>
|
||||
<div class="btn-group btn-group-sm w-100">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary w-100"
|
||||
(click)="versionFileInput.click()"
|
||||
[disabled]="!userIsOwner || !userCanEdit"
|
||||
title="Upload a new version"
|
||||
i18n-title
|
||||
>
|
||||
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Upload</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary w-100"
|
||||
(click)="addExistingDocumentAsVersion()"
|
||||
[disabled]="!userIsOwner || !userCanEdit"
|
||||
title="Use an existing document"
|
||||
i18n-title
|
||||
>
|
||||
<i-bs name="file-earmark"></i-bs><span class="ps-1" i18n>Existing</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary w-100"
|
||||
(click)="versionFileInput.click()"
|
||||
[disabled]="!userIsOwner || !userCanEdit"
|
||||
>
|
||||
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
|
||||
</button>
|
||||
} @else {
|
||||
@switch (versionUploadState()) {
|
||||
@case (UploadState.Uploading) {
|
||||
@@ -141,7 +128,6 @@
|
||||
i18n-confirmMessage
|
||||
[disabled]="!userIsOwner || !userCanEdit"
|
||||
(confirm)="deleteVersion(version.id)"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }"
|
||||
>
|
||||
<span class="visually-hidden" i18n>Delete version</span>
|
||||
</pngx-confirm-button>
|
||||
|
||||
+3
-98
@@ -1,16 +1,9 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { SimpleChange, signal } from '@angular/core'
|
||||
import { SimpleChange } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { Subject, of, throwError } from 'rxjs'
|
||||
import { DocumentVersionInfo } from 'src/app/data/document'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
@@ -26,20 +19,12 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
let documentService: jest.Mocked<
|
||||
Pick<
|
||||
DocumentService,
|
||||
| 'deleteVersion'
|
||||
| 'getVersions'
|
||||
| 'mergeDocumentsAsVersions'
|
||||
| 'uploadVersion'
|
||||
| 'updateVersionLabel'
|
||||
'deleteVersion' | 'getVersions' | 'uploadVersion' | 'updateVersionLabel'
|
||||
>
|
||||
>
|
||||
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
|
||||
let finished$: Subject<{ taskId: string }>
|
||||
let failed$: Subject<{ taskId: string; message?: string }>
|
||||
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
|
||||
let permissionsService: jest.Mocked<
|
||||
Pick<PermissionsService, 'currentUserCan'>
|
||||
>
|
||||
|
||||
beforeEach(async () => {
|
||||
finished$ = new Subject<{ taskId: string }>()
|
||||
@@ -47,18 +32,13 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
documentService = {
|
||||
deleteVersion: jest.fn(),
|
||||
getVersions: jest.fn(),
|
||||
mergeDocumentsAsVersions: jest.fn(),
|
||||
uploadVersion: jest.fn(),
|
||||
updateVersionLabel: jest.fn(),
|
||||
}
|
||||
modalService = { open: jest.fn() }
|
||||
toastService = {
|
||||
showError: jest.fn(),
|
||||
showInfo: jest.fn(),
|
||||
}
|
||||
permissionsService = {
|
||||
currentUserCan: jest.fn().mockReturnValue(true),
|
||||
}
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
@@ -81,14 +61,6 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
provide: ToastService,
|
||||
useValue: toastService,
|
||||
},
|
||||
{
|
||||
provide: NgbModal,
|
||||
useValue: modalService,
|
||||
},
|
||||
{
|
||||
provide: PermissionsService,
|
||||
useValue: permissionsService,
|
||||
},
|
||||
{
|
||||
provide: WebsocketStatusService,
|
||||
useValue: {
|
||||
@@ -159,31 +131,6 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should not show version delete buttons without document delete permission', () => {
|
||||
fixture.destroy()
|
||||
permissionsService.currentUserCan.mockReturnValue(false)
|
||||
fixture = TestBed.createComponent(DocumentVersionDropdownComponent)
|
||||
component = fixture.componentInstance
|
||||
component.documentId = 3
|
||||
component.selectedVersionId = 3
|
||||
component.userIsOwner = true
|
||||
component.userCanEdit = true
|
||||
component.versions = [
|
||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||
{ id: 10, is_root: false, checksum: 'bbbb' },
|
||||
]
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(permissionsService.currentUserCan).toHaveBeenCalledWith(
|
||||
PermissionAction.Delete,
|
||||
PermissionType.Document
|
||||
)
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.css('pngx-confirm-button'))
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('beginEditingVersion should set active row and draft label', () => {
|
||||
component.userCanEdit = true
|
||||
component.userIsOwner = true
|
||||
@@ -275,10 +222,9 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
})
|
||||
|
||||
it('onVersionFileSelected should upload and update versions after websocket success', () => {
|
||||
// Newest first, as the API returns them
|
||||
const versions: DocumentVersionInfo[] = [
|
||||
{ id: 20, is_root: false, checksum: 'cccc' },
|
||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||
{ id: 20, is_root: false, checksum: 'cccc' },
|
||||
]
|
||||
const file = new File(['test'], 'new-version.pdf', {
|
||||
type: 'application/pdf',
|
||||
@@ -377,45 +323,4 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
expect(component.editingVersionId).toBeNull()
|
||||
expect(component.versionLabelDraft).toEqual('')
|
||||
})
|
||||
|
||||
it('addExistingDocumentAsVersion should merge with a label and refresh versions', () => {
|
||||
const confirmClicked = new Subject<number>()
|
||||
const modal = {
|
||||
componentInstance: {
|
||||
rootDocumentID: null,
|
||||
buttonsEnabled: signal(true),
|
||||
confirmClicked,
|
||||
},
|
||||
close: jest.fn(),
|
||||
}
|
||||
modalService.open.mockReturnValue(modal as any)
|
||||
documentService.mergeDocumentsAsVersions.mockReturnValue(of({} as any))
|
||||
// Newest first, as the API returns them. The merged document has a lower id
|
||||
// than the root, which is the whole point of merging an existing document.
|
||||
const versions: DocumentVersionInfo[] = [
|
||||
{ id: 2, is_root: false, checksum: 'cccc' },
|
||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||
]
|
||||
documentService.getVersions.mockReturnValue(of({ id: 3, versions } as any))
|
||||
component.newVersionLabel = ' Imported '
|
||||
const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit')
|
||||
const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit')
|
||||
|
||||
component.addExistingDocumentAsVersion()
|
||||
expect(modal.componentInstance.rootDocumentID).toEqual(3)
|
||||
confirmClicked.next(2)
|
||||
|
||||
expect(documentService.mergeDocumentsAsVersions).toHaveBeenCalledWith(
|
||||
[3, 2],
|
||||
3,
|
||||
'Imported'
|
||||
)
|
||||
expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
|
||||
expect(documentService.getVersions).toHaveBeenCalledWith(3)
|
||||
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
|
||||
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
|
||||
expect(component.newVersionLabel).toEqual('')
|
||||
expect(modal.close).toHaveBeenCalled()
|
||||
expect(toastService.showInfo).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user