mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-12-29 13:48:09 -06:00
Compare commits
16 Commits
feature-di
...
4feedf2add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4feedf2add | ||
|
|
2f76cf9831 | ||
|
|
1002d37f6b | ||
|
|
d260a94740 | ||
|
|
88c69b83ea | ||
|
|
2557ee2014 | ||
|
|
3c75deed80 | ||
|
|
d05343c927 | ||
|
|
e7972b7eaf | ||
|
|
75a091cc0d | ||
|
|
dca74803fd | ||
|
|
3cf3d868d0 | ||
|
|
bf4fc6604a | ||
|
|
e8c1eb86fa | ||
|
|
c3dad3cf69 | ||
|
|
811bd66088 |
@@ -10,8 +10,10 @@ component_management:
|
|||||||
paths:
|
paths:
|
||||||
- src-ui/**
|
- src-ui/**
|
||||||
# https://docs.codecov.com/docs/pull-request-comments
|
# https://docs.codecov.com/docs/pull-request-comments
|
||||||
|
# codecov will only comment if coverage changes
|
||||||
comment:
|
comment:
|
||||||
layout: "header, diff, components, flags, files"
|
layout: "header, diff, components, flags, files"
|
||||||
|
require_changes: true
|
||||||
# https://docs.codecov.com/docs/javascript-bundle-analysis
|
# https://docs.codecov.com/docs/javascript-bundle-analysis
|
||||||
require_bundle_changes: true
|
require_bundle_changes: true
|
||||||
bundle_change_threshold: "50Kb"
|
bundle_change_threshold: "50Kb"
|
||||||
|
|||||||
3
.codespellrc
Normal file
3
.codespellrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[codespell]
|
||||||
|
write-changes = True
|
||||||
|
ignore-words-list = criterias,afterall,valeu,ureue,equest,ure,assertIn
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml",
|
"dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml",
|
||||||
"service": "paperless-development",
|
"service": "paperless-development",
|
||||||
"workspaceFolder": "/usr/src/paperless/paperless-ngx",
|
"workspaceFolder": "/usr/src/paperless/paperless-ngx",
|
||||||
"postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run pre-commit install'",
|
"postCreateCommand": "/bin/bash -c 'uv sync --group dev && uv run pre-commit install'",
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ services:
|
|||||||
- ./data:/usr/src/paperless/paperless-ngx/data
|
- ./data:/usr/src/paperless/paperless-ngx/data
|
||||||
- ./media:/usr/src/paperless/paperless-ngx/media
|
- ./media:/usr/src/paperless/paperless-ngx/media
|
||||||
- ./consume:/usr/src/paperless/paperless-ngx/consume
|
- ./consume:/usr/src/paperless/paperless-ngx/consume
|
||||||
|
- ~/.gitconfig:/usr/src/paperless/.gitconfig:ro
|
||||||
environment:
|
environment:
|
||||||
PAPERLESS_REDIS: redis://broker:6379
|
PAPERLESS_REDIS: redis://broker:6379
|
||||||
PAPERLESS_TIKA_ENABLED: 1
|
PAPERLESS_TIKA_ENABLED: 1
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"python.testing.pytestArgs": [],
|
"python.testing.pytestArgs": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
"python.testing.unittestEnabled": false,
|
"python.testing.unittestEnabled": false,
|
||||||
"python.testing.pytestEnabled": true,
|
"python.testing.pytestEnabled": true,
|
||||||
"files.watcherExclude": {
|
"files.watcherExclude": {
|
||||||
"**/.venv/**": true,
|
"**/.venv/**": true,
|
||||||
"**/pytest_cache/**": true
|
"**/pytest_cache/**": true
|
||||||
},
|
}
|
||||||
"python.testing.cwd": "${workspaceFolder}/src"
|
|
||||||
}
|
}
|
||||||
|
|||||||
101
.github/workflows/ci.yml
vendored
101
.github/workflows/ci.yml
vendored
@@ -17,59 +17,18 @@ env:
|
|||||||
DEFAULT_PYTHON_VERSION: "3.11"
|
DEFAULT_PYTHON_VERSION: "3.11"
|
||||||
NLTK_DATA: "/usr/share/nltk_data"
|
NLTK_DATA: "/usr/share/nltk_data"
|
||||||
jobs:
|
jobs:
|
||||||
detect-duplicate:
|
|
||||||
name: Detect Duplicate Run
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
outputs:
|
|
||||||
should_run: ${{ steps.check.outputs.should_run }}
|
|
||||||
steps:
|
|
||||||
- name: Check if workflow should run
|
|
||||||
id: check
|
|
||||||
uses: actions/github-script@v8
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
script: |
|
|
||||||
if (context.eventName !== 'push') {
|
|
||||||
core.info('Not a push event; running workflow.');
|
|
||||||
core.setOutput('should_run', 'true');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ref = context.ref || '';
|
|
||||||
if (!ref.startsWith('refs/heads/')) {
|
|
||||||
core.info('Push is not to a branch; running workflow.');
|
|
||||||
core.setOutput('should_run', 'true');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const branch = ref.substring('refs/heads/'.length);
|
|
||||||
const { owner, repo } = context.repo;
|
|
||||||
const prs = await github.paginate(github.rest.pulls.list, {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
state: 'open',
|
|
||||||
head: `${owner}:${branch}`,
|
|
||||||
per_page: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (prs.length === 0) {
|
|
||||||
core.info(`No open PR found for ${branch}; running workflow.`);
|
|
||||||
core.setOutput('should_run', 'true');
|
|
||||||
} else {
|
|
||||||
core.info(`Found ${prs.length} open PR(s) for ${branch}; skipping duplicate push run.`);
|
|
||||||
core.setOutput('should_run', 'false');
|
|
||||||
}
|
|
||||||
pre-commit:
|
pre-commit:
|
||||||
needs:
|
# We want to run on external PRs, but not on our own internal PRs as they'll be run
|
||||||
- detect-duplicate
|
# by the push to the branch. Without this if check, checks are duplicated since
|
||||||
if: needs.detect-duplicate.outputs.should_run == 'true'
|
# internal PRs match both the push and pull_request events.
|
||||||
|
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
|
||||||
name: Linting Checks
|
name: Linting Checks
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
- name: Install python
|
- name: Install python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Check files
|
- name: Check files
|
||||||
@@ -81,10 +40,10 @@ jobs:
|
|||||||
- pre-commit
|
- pre-commit
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
@@ -131,14 +90,14 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
- name: Start containers
|
- name: Start containers
|
||||||
run: |
|
run: |
|
||||||
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml pull --quiet
|
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml pull --quiet
|
||||||
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml up --detach
|
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml up --detach
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
@@ -183,11 +142,13 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
uses: codecov/test-results-action@v1
|
uses: codecov/test-results-action@v1
|
||||||
with:
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: junit.xml
|
files: junit.xml
|
||||||
- name: Upload backend coverage to Codecov
|
- name: Upload backend coverage to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: coverage.xml
|
files: coverage.xml
|
||||||
- name: Stop containers
|
- name: Stop containers
|
||||||
@@ -201,13 +162,13 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- pre-commit
|
- pre-commit
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -234,13 +195,13 @@ jobs:
|
|||||||
shard-index: [1, 2, 3, 4]
|
shard-index: [1, 2, 3, 4]
|
||||||
shard-count: [4]
|
shard-count: [4]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -263,11 +224,13 @@ jobs:
|
|||||||
uses: codecov/test-results-action@v1
|
uses: codecov/test-results-action@v1
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/
|
directory: src-ui/
|
||||||
- name: Upload frontend coverage to Codecov
|
- name: Upload frontend coverage to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/coverage/
|
directory: src-ui/coverage/
|
||||||
tests-frontend-e2e:
|
tests-frontend-e2e:
|
||||||
@@ -282,13 +245,13 @@ jobs:
|
|||||||
shard-index: [1, 2]
|
shard-index: [1, 2]
|
||||||
shard-count: [2]
|
shard-count: [2]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -325,13 +288,13 @@ jobs:
|
|||||||
- tests-frontend
|
- tests-frontend
|
||||||
- tests-frontend-e2e
|
- tests-frontend-e2e
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -353,7 +316,7 @@ jobs:
|
|||||||
build-docker-image:
|
build-docker-image:
|
||||||
name: Build Docker image for ${{ github.ref_name }}
|
name: Build Docker image for ${{ github.ref_name }}
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || startsWith(github.ref, 'refs/heads/fix-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/l10n_'))
|
if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || startsWith(github.ref, 'refs/heads/fix-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v'))
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }}
|
group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
@@ -400,7 +363,7 @@ jobs:
|
|||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
# If https://github.com/docker/buildx/issues/1044 is resolved,
|
# If https://github.com/docker/buildx/issues/1044 is resolved,
|
||||||
# the append input with a native arm64 arch could be used to
|
# the append input with a native arm64 arch could be used to
|
||||||
# significantly speed up building
|
# significantly speed up building
|
||||||
@@ -470,10 +433,10 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
@@ -490,12 +453,12 @@ jobs:
|
|||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -qq --no-install-recommends gettext liblept5
|
sudo apt-get install -qq --no-install-recommends gettext liblept5
|
||||||
- name: Download frontend artifact
|
- name: Download frontend artifact
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: frontend-compiled
|
name: frontend-compiled
|
||||||
path: src/documents/static/frontend/
|
path: src/documents/static/frontend/
|
||||||
- name: Download documentation artifact
|
- name: Download documentation artifact
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: documentation
|
name: documentation
|
||||||
path: docs/_build/html/
|
path: docs/_build/html/
|
||||||
@@ -575,7 +538,7 @@ jobs:
|
|||||||
if: github.ref_type == 'tag' && (startsWith(github.ref_name, 'v') || contains(github.ref_name, '-beta.rc'))
|
if: github.ref_type == 'tag' && (startsWith(github.ref_name, 'v') || contains(github.ref_name, '-beta.rc'))
|
||||||
steps:
|
steps:
|
||||||
- name: Download release artifact
|
- name: Download release artifact
|
||||||
uses: actions/download-artifact@v5
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release
|
name: release
|
||||||
path: ./
|
path: ./
|
||||||
@@ -616,12 +579,12 @@ jobs:
|
|||||||
if: needs.publish-release.outputs.prerelease == 'false'
|
if: needs.publish-release.outputs.prerelease == 'false'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
@@ -653,7 +616,7 @@ jobs:
|
|||||||
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"
|
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"
|
||||||
git push origin ${{ needs.publish-release.outputs.version }}-changelog
|
git push origin ${{ needs.publish-release.outputs.version }}-changelog
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const { repo, owner } = context.repo;
|
const { repo, owner } = context.repo;
|
||||||
|
|||||||
11
.github/workflows/cleanup-tags.yml
vendored
11
.github/workflows/cleanup-tags.yml
vendored
@@ -6,9 +6,10 @@
|
|||||||
# This workflow will not trigger runs on forked repos.
|
# This workflow will not trigger runs on forked repos.
|
||||||
name: Cleanup Image Tags
|
name: Cleanup Image Tags
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
delete:
|
||||||
schedule:
|
push:
|
||||||
- cron: '0 0 * * 0'
|
paths:
|
||||||
|
- ".github/workflows/cleanup-tags.yml"
|
||||||
concurrency:
|
concurrency:
|
||||||
group: registry-tags-cleanup
|
group: registry-tags-cleanup
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
@@ -27,7 +28,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Clean temporary images
|
- name: Clean temporary images
|
||||||
if: "${{ env.TOKEN != '' }}"
|
if: "${{ env.TOKEN != '' }}"
|
||||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.11.0
|
uses: stumpylog/image-cleaner-action/ephemeral@v0.10.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "${{ github.repository_owner }}"
|
owner: "${{ github.repository_owner }}"
|
||||||
@@ -53,7 +54,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Clean untagged images
|
- name: Clean untagged images
|
||||||
if: "${{ env.TOKEN != '' }}"
|
if: "${{ env.TOKEN != '' }}"
|
||||||
uses: stumpylog/image-cleaner-action/untagged@v0.11.0
|
uses: stumpylog/image-cleaner-action/untagged@v0.10.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "${{ github.repository_owner }}"
|
owner: "${{ github.repository_owner }}"
|
||||||
|
|||||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
|||||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v3
|
||||||
|
|||||||
2
.github/workflows/crowdin.yml
vendored
2
.github/workflows/crowdin.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||||
- name: crowdin action
|
- name: crowdin action
|
||||||
|
|||||||
10
.github/workflows/pr-bot.yml
vendored
10
.github/workflows/pr-bot.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Label PR by file path or branch name
|
- name: Label PR by file path or branch name
|
||||||
# see .github/labeler.yml for the labeler config
|
# see .github/labeler.yml for the labeler config
|
||||||
uses: actions/labeler@v6
|
uses: actions/labeler@v5
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Label by size
|
- name: Label by size
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
fail_if_xl: 'false'
|
fail_if_xl: 'false'
|
||||||
excluded_files: /\.lock$/ /\.txt$/ ^src-ui/pnpm-lock\.yaml$ ^src-ui/messages\.xlf$ ^src/locale/en_US/LC_MESSAGES/django\.po$
|
excluded_files: /\.lock$/ /\.txt$/ ^src-ui/pnpm-lock\.yaml$ ^src-ui/messages\.xlf$ ^src/locale/en_US/LC_MESSAGES/django\.po$
|
||||||
- name: Label by PR title
|
- name: Label by PR title
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const pr = context.payload.pull_request;
|
const pr = context.payload.pull_request;
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
labels.push('bug');
|
labels.push('bug');
|
||||||
} else if (/^feature/i.test(title)) {
|
} else if (/^feature/i.test(title)) {
|
||||||
labels.push('enhancement');
|
labels.push('enhancement');
|
||||||
} else if (!/^(dependabot)/i.test(title) && !/^(chore)/i.test(title)) {
|
} else if (!/^(dependabot)/i.test(title)) {
|
||||||
labels.push('enhancement'); // Default fallback
|
labels.push('enhancement'); // Default fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
- name: Label bot-generated PRs
|
- name: Label bot-generated PRs
|
||||||
if: ${{ contains(github.actor, 'dependabot') || contains(github.actor, 'crowdin-bot') }}
|
if: ${{ contains(github.actor, 'dependabot') || contains(github.actor, 'crowdin-bot') }}
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const pr = context.payload.pull_request;
|
const pr = context.payload.pull_request;
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
- name: Welcome comment
|
- name: Welcome comment
|
||||||
if: ${{ !contains(github.actor, 'bot') }}
|
if: ${{ !contains(github.actor, 'bot') }}
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const pr = context.payload.pull_request;
|
const pr = context.payload.pull_request;
|
||||||
|
|||||||
9
.github/workflows/repo-maintenance.yml
vendored
9
.github/workflows/repo-maintenance.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v10
|
- uses: actions/stale@v9
|
||||||
with:
|
with:
|
||||||
days-before-stale: 7
|
days-before-stale: 7
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v8
|
- uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v8
|
- uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
@@ -206,7 +206,7 @@ jobs:
|
|||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v8
|
- uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
@@ -241,7 +241,6 @@ jobs:
|
|||||||
) {
|
) {
|
||||||
nodes {
|
nodes {
|
||||||
id,
|
id,
|
||||||
createdAt,
|
|
||||||
number,
|
number,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
upvoteCount,
|
upvoteCount,
|
||||||
|
|||||||
6
.github/workflows/translate-strings.yml
vendored
6
.github/workflows/translate-strings.yml
vendored
@@ -11,13 +11,13 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||||
ref: ${{ github.head_ref }}
|
ref: ${{ github.head_ref }}
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
@@ -38,7 +38,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -107,6 +107,3 @@ celerybeat-schedule*
|
|||||||
/.devcontainer/data/
|
/.devcontainer/data/
|
||||||
/.devcontainer/media/
|
/.devcontainer/media/
|
||||||
/.devcontainer/redisdata/
|
/.devcontainer/redisdata/
|
||||||
|
|
||||||
# ignore pnpm package store folder created when setting up the devcontainer
|
|
||||||
.pnpm-store/
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
repos:
|
repos:
|
||||||
# General hooks
|
# General hooks
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v6.0.0
|
rev: v5.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-docstring-first
|
- id: check-docstring-first
|
||||||
- id: check-json
|
- id: check-json
|
||||||
@@ -18,7 +18,7 @@ repos:
|
|||||||
exclude_types:
|
exclude_types:
|
||||||
- svg
|
- svg
|
||||||
- pofile
|
- pofile
|
||||||
exclude: "(^LICENSE$|^src/documents/static/bootstrap.min.css$)"
|
exclude: "(^LICENSE$)"
|
||||||
- id: mixed-line-ending
|
- id: mixed-line-ending
|
||||||
args:
|
args:
|
||||||
- "--fix=lf"
|
- "--fix=lf"
|
||||||
@@ -31,7 +31,7 @@ repos:
|
|||||||
rev: v2.4.1
|
rev: v2.4.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: codespell
|
- id: codespell
|
||||||
additional_dependencies: [tomli]
|
exclude: "(^src-ui/src/locale/)|(^src-ui/pnpm-lock.yaml)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)|(^src/documents/tests/samples/)"
|
||||||
exclude_types:
|
exclude_types:
|
||||||
- pofile
|
- pofile
|
||||||
- json
|
- json
|
||||||
@@ -49,9 +49,9 @@ repos:
|
|||||||
- 'prettier-plugin-organize-imports@4.1.0'
|
- 'prettier-plugin-organize-imports@4.1.0'
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.13.2
|
rev: v0.12.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||||
rev: "v2.6.0"
|
rev: "v2.6.0"
|
||||||
@@ -59,7 +59,7 @@ repos:
|
|||||||
- id: pyproject-fmt
|
- id: pyproject-fmt
|
||||||
# Dockerfile hooks
|
# Dockerfile hooks
|
||||||
- repo: https://github.com/AleksaC/hadolint-py
|
- repo: https://github.com/AleksaC/hadolint-py
|
||||||
rev: v2.14.0
|
rev: v2.12.1b3
|
||||||
hooks:
|
hooks:
|
||||||
- id: hadolint
|
- id: hadolint
|
||||||
# Shell script hooks
|
# Shell script hooks
|
||||||
@@ -72,7 +72,7 @@ repos:
|
|||||||
args:
|
args:
|
||||||
- "--tab"
|
- "--tab"
|
||||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||||
rev: "v0.11.0.1"
|
rev: "v0.10.0.1"
|
||||||
hooks:
|
hooks:
|
||||||
- id: shellcheck
|
- id: shellcheck
|
||||||
- repo: https://github.com/google/yamlfmt
|
- repo: https://github.com/google/yamlfmt
|
||||||
|
|||||||
@@ -2,11 +2,9 @@
|
|||||||
|
|
||||||
If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome.
|
If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome.
|
||||||
|
|
||||||
⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged.
|
|
||||||
|
|
||||||
If you want to implement something big:
|
If you want to implement something big:
|
||||||
|
|
||||||
- As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together.
|
- Please start a discussion about that in the issues! Maybe something similar is already in development and we can make it happen together.
|
||||||
- When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project.
|
- When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project.
|
||||||
- Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change.
|
- Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change.
|
||||||
- Please see the [paperless-ngx merge process](#merging-prs) below.
|
- Please see the [paperless-ngx merge process](#merging-prs) below.
|
||||||
@@ -39,8 +37,6 @@ Before you can run `pytest`, ensure to [properly set up your local environment](
|
|||||||
|
|
||||||
Once you have submitted a **P**ull **R**equest it will be reviewed, approved, and merged by one or more community members of any team. Automated code tests and formatting checks must be passed.
|
Once you have submitted a **P**ull **R**equest it will be reviewed, approved, and merged by one or more community members of any team. Automated code tests and formatting checks must be passed.
|
||||||
|
|
||||||
Important: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Instead of opening a PR which does not meet this requirement, please open a feature request instead, to gather feedback from both users and the project maintainers.
|
|
||||||
|
|
||||||
## Non-Trivial Requests
|
## Non-Trivial Requests
|
||||||
|
|
||||||
PRs deemed `non-trivial` will go through a stricter review process before being merged into `dev`. This is to ensure code quality and complete functionality (free of side effects).
|
PRs deemed `non-trivial` will go through a stricter review process before being merged into `dev`. This is to ensure code quality and complete functionality (free of side effects).
|
||||||
@@ -113,12 +109,28 @@ Paperless-ngx is a community project. We do our best to delegate permission and
|
|||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
There are currently 2 members in paperless-ngx with complete administrative privileges to the repo:
|
As of writing, there are 21 members in paperless-ngx. 4 of these people have complete administrative privileges to the repo:
|
||||||
|
|
||||||
- [@shamoon](https://github.com/shamoon)
|
- [@shamoon](https://github.com/shamoon)
|
||||||
- [@stumpylog](https://github.com/stumpylog)
|
- [@bauerj](https://github.com/bauerj)
|
||||||
|
- [@qcasey](https://github.com/qcasey)
|
||||||
|
- [@FrankStrieter](https://github.com/FrankStrieter)
|
||||||
|
|
||||||
There are other members who occasionally contribute but we are actively seeking more dedicated maintainers of the project. Please reach out if you are interested.
|
There are 5 teams collaborating on specific tasks within paperless-ngx:
|
||||||
|
|
||||||
|
- @paperless-ngx/backend (Python / django)
|
||||||
|
- @paperless-ngx/frontend (JavaScript / Typescript)
|
||||||
|
- @paperless-ngx/ci-cd (GitHub Actions / Deployment)
|
||||||
|
- @paperless-ngx/issues (Issue triage)
|
||||||
|
- @paperless-ngx/test (General testing for larger PRs)
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
All team members are notified when mentioned or assigned to a relevant issue or pull request. Additionally, each team has slightly different access to paperless-ngx:
|
||||||
|
|
||||||
|
- The **test** team has no special permissions.
|
||||||
|
- The **issues** team has `triage` access. This means they can organize issues and pull requests.
|
||||||
|
- The **backend**, **frontend**, and **ci-cd** teams have `write` access. This means they can approve PRs and push code, containers, releases, and more.
|
||||||
|
|
||||||
## Joining
|
## Joining
|
||||||
|
|
||||||
@@ -135,7 +147,7 @@ community members. That said, in an effort to keep the repository organized and
|
|||||||
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
|
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
|
||||||
- Discussions with a marked answer will be automatically closed.
|
- Discussions with a marked answer will be automatically closed.
|
||||||
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
|
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
|
||||||
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity with less than 80 "up-votes", < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 40 "up-votes" at 2 years.
|
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years.
|
||||||
|
|
||||||
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
|
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
|
||||||
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.
|
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ RUN set -eux \
|
|||||||
# Purpose: Installs s6-overlay and rootfs
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - Don't leave anything extra in here either
|
||||||
FROM ghcr.io/astral-sh/uv:0.8.22-python3.12-bookworm-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.8.4-python3.12-bookworm-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# correct networking for the tests
|
# correct networking for the tests
|
||||||
services:
|
services:
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.23
|
image: docker.io/gotenberg/gotenberg:8.20
|
||||||
hostname: gotenberg
|
hostname: gotenberg
|
||||||
container_name: gotenberg
|
container_name: gotenberg
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/mariadb:12
|
image: docker.io/library/mariadb:11
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- dbdata:/var/lib/mysql
|
- dbdata:/var/lib/mysql
|
||||||
@@ -72,7 +72,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.23
|
image: docker.io/gotenberg/gotenberg:8.20
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/mariadb:12
|
image: docker.io/library/mariadb:11
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- dbdata:/var/lib/mysql
|
- dbdata:/var/lib/mysql
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:18
|
image: docker.io/library/postgres:17
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:18
|
image: docker.io/library/postgres:17
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
@@ -66,7 +66,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.23
|
image: docker.io/gotenberg/gotenberg:8.20
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:18
|
image: docker.io/library/postgres:17
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.23
|
image: docker.io/gotenberg/gotenberg:8.20
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
|||||||
@@ -179,14 +179,10 @@ following:
|
|||||||
|
|
||||||
### Database Upgrades
|
### Database Upgrades
|
||||||
|
|
||||||
Paperless-ngx is compatible with Django-supported versions of PostgreSQL and MariaDB and it is generally
|
In general, paperless does not require a specific version of PostgreSQL or MariaDB and it is
|
||||||
safe to update them to newer versions. However, you should always take a backup and follow
|
safe to update them to newer versions. However, you should always take a backup and follow
|
||||||
the instructions from your database's documentation for how to upgrade between major versions.
|
the instructions from your database's documentation for how to upgrade between major versions.
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
As of Paperless-ngx v2.18, the minimum supported version of PostgreSQL is 14.
|
|
||||||
|
|
||||||
For PostgreSQL, refer to [Upgrading a PostgreSQL Cluster](https://www.postgresql.org/docs/current/upgrading.html).
|
For PostgreSQL, refer to [Upgrading a PostgreSQL Cluster](https://www.postgresql.org/docs/current/upgrading.html).
|
||||||
|
|
||||||
For MariaDB, refer to [Upgrading MariaDB](https://mariadb.com/kb/en/upgrading/)
|
For MariaDB, refer to [Upgrading MariaDB](https://mariadb.com/kb/en/upgrading/)
|
||||||
@@ -471,7 +467,7 @@ Failing to invalidate the cache after such modifications can lead to stale data
|
|||||||
Use the following management command to clear the cache:
|
Use the following management command to clear the cache:
|
||||||
|
|
||||||
```
|
```
|
||||||
python3 manage.py invalidate_cachalot
|
invalidate_cachalot
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
|
|||||||
@@ -434,134 +434,6 @@ provided. The template is provided as a string, potentially multiline, and rende
|
|||||||
In addition, the entire Document instance is available to be utilized in a more advanced way, as well as some variables which only make sense to be accessed
|
In addition, the entire Document instance is available to be utilized in a more advanced way, as well as some variables which only make sense to be accessed
|
||||||
with more complex logic.
|
with more complex logic.
|
||||||
|
|
||||||
#### Custom Jinja2 Filters
|
|
||||||
|
|
||||||
##### Custom Field Access
|
|
||||||
|
|
||||||
The `get_cf_value` filter retrieves a value from custom field data with optional default fallback.
|
|
||||||
|
|
||||||
###### Syntax
|
|
||||||
|
|
||||||
```jinja2
|
|
||||||
{{ custom_fields | get_cf_value('field_name') }}
|
|
||||||
{{ custom_fields | get_cf_value('field_name', 'default_value') }}
|
|
||||||
```
|
|
||||||
|
|
||||||
###### Parameters
|
|
||||||
|
|
||||||
- `custom_fields`: This _must_ be the provided custom field data
|
|
||||||
- `name` (str): Name of the custom field to retrieve
|
|
||||||
- `default` (str, optional): Default value to return if field is not found or has no value
|
|
||||||
|
|
||||||
###### Returns
|
|
||||||
|
|
||||||
- `str | None`: The field value, default value, or `None` if neither exists
|
|
||||||
|
|
||||||
###### Examples
|
|
||||||
|
|
||||||
```jinja2
|
|
||||||
<!-- Basic usage -->
|
|
||||||
{{ custom_fields | get_cf_value('department') }}
|
|
||||||
|
|
||||||
<!-- With default value -->
|
|
||||||
{{ custom_fields | get_cf_value('phone', 'Not provided') }}
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Datetime Formatting
|
|
||||||
|
|
||||||
The `datetime` filter formats a datetime string or datetime object using Python's strftime formatting.
|
|
||||||
|
|
||||||
###### Syntax
|
|
||||||
|
|
||||||
```jinja2
|
|
||||||
{{ datetime_value | datetime('%Y-%m-%d %H:%M:%S') }}
|
|
||||||
```
|
|
||||||
|
|
||||||
###### Parameters
|
|
||||||
|
|
||||||
- `value` (str | datetime): Date/time value to format (strings will be parsed automatically)
|
|
||||||
- `format` (str): Python strftime format string
|
|
||||||
|
|
||||||
###### Returns
|
|
||||||
|
|
||||||
- `str`: Formatted datetime string
|
|
||||||
|
|
||||||
###### Examples
|
|
||||||
|
|
||||||
```jinja2
|
|
||||||
<!-- Format datetime object -->
|
|
||||||
{{ created | datetime('%B %d, %Y at %I:%M %p') }}
|
|
||||||
<!-- Output: "January 15, 2024 at 02:30 PM" -->
|
|
||||||
|
|
||||||
<!-- Custom formatting -->
|
|
||||||
{{ custom_fields | get_cf_value('Date Field') | datetime('%A, %B %d, %Y') }}
|
|
||||||
<!-- Output: "Monday, January 15, 2024" -->
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [strftime format code documentation](https://docs.python.org/3.13/library/datetime.html#strftime-and-strptime-format-codes)
|
|
||||||
for the possible codes and their meanings.
|
|
||||||
|
|
||||||
##### Date Localization
|
|
||||||
|
|
||||||
The `localize_date` filter formats a date or datetime object into a localized string using Babel internationalization.
|
|
||||||
This takes into account the provided locale for translation. Since this must be used on a date or datetime object,
|
|
||||||
you must access the field directly, i.e. `document.created`.
|
|
||||||
An ISO string can also be provided to control the output format.
|
|
||||||
|
|
||||||
###### Syntax
|
|
||||||
|
|
||||||
```jinja2
|
|
||||||
{{ date_value | localize_date('medium', 'en_US') }}
|
|
||||||
{{ datetime_value | localize_date('short', 'fr_FR') }}
|
|
||||||
```
|
|
||||||
|
|
||||||
###### Parameters
|
|
||||||
|
|
||||||
- `value` (date | datetime | str): Date, datetime object or ISO string to format (datetime should be timezone-aware)
|
|
||||||
- `format` (str): Format type - either a Babel preset ('short', 'medium', 'long', 'full') or custom pattern
|
|
||||||
- `locale` (str): Locale code for localization (e.g., 'en_US', 'fr_FR', 'de_DE')
|
|
||||||
|
|
||||||
###### Returns
|
|
||||||
|
|
||||||
- `str`: Localized, formatted date string
|
|
||||||
|
|
||||||
###### Examples
|
|
||||||
|
|
||||||
```jinja2
|
|
||||||
<!-- Preset formats -->
|
|
||||||
{{ document.created | localize_date('short', 'en_US') }}
|
|
||||||
<!-- Output: "1/15/24" -->
|
|
||||||
|
|
||||||
{{ document.created | localize_date('medium', 'en_US') }}
|
|
||||||
<!-- Output: "Jan 15, 2024" -->
|
|
||||||
|
|
||||||
{{ document.created | localize_date('long', 'en_US') }}
|
|
||||||
<!-- Output: "January 15, 2024" -->
|
|
||||||
|
|
||||||
{{ document.created | localize_date('full', 'en_US') }}
|
|
||||||
<!-- Output: "Monday, January 15, 2024" -->
|
|
||||||
|
|
||||||
<!-- Different locales -->
|
|
||||||
{{ document.created | localize_date('medium', 'fr_FR') }}
|
|
||||||
<!-- Output: "15 janv. 2024" -->
|
|
||||||
|
|
||||||
{{ document.created | localize_date('medium', 'de_DE') }}
|
|
||||||
<!-- Output: "15.01.2024" -->
|
|
||||||
|
|
||||||
<!-- Custom patterns -->
|
|
||||||
{{ document.created | localize_date('dd/MM/yyyy', 'en_GB') }}
|
|
||||||
<!-- Output: "15/01/2024" -->
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [supported format codes](https://unicode.org/reports/tr35/tr35-dates.html#Date_Format_Patterns) for more options.
|
|
||||||
|
|
||||||
### Format Presets
|
|
||||||
|
|
||||||
- **short**: Abbreviated format (e.g., "1/15/24")
|
|
||||||
- **medium**: Medium-length format (e.g., "Jan 15, 2024")
|
|
||||||
- **long**: Long format with full month name (e.g., "January 15, 2024")
|
|
||||||
- **full**: Full format including day of week (e.g., "Monday, January 15, 2024")
|
|
||||||
|
|
||||||
#### Additional Variables
|
#### Additional Variables
|
||||||
|
|
||||||
- `{{ tag_name_list }}`: A list of tag names applied to the document, ordered by the tag name. Note this is a list, not a single string
|
- `{{ tag_name_list }}`: A list of tag names applied to the document, ordered by the tag name. Note this is a list, not a single string
|
||||||
|
|||||||
16
docs/api.md
16
docs/api.md
@@ -192,8 +192,8 @@ The endpoint supports the following optional form fields:
|
|||||||
- `tags`: Similar to correspondent. Specify this multiple times to
|
- `tags`: Similar to correspondent. Specify this multiple times to
|
||||||
have multiple tags added to the document.
|
have multiple tags added to the document.
|
||||||
- `archive_serial_number`: An optional archive serial number to set.
|
- `archive_serial_number`: An optional archive serial number to set.
|
||||||
- `custom_fields`: Either an array of custom field ids to assign (with an empty
|
- `custom_fields`: An array of custom field ids to assign (with an empty
|
||||||
value) to the document or an object mapping field id -> value.
|
value) to the document.
|
||||||
|
|
||||||
The endpoint will immediately return HTTP 200 if the document consumption
|
The endpoint will immediately return HTTP 200 if the document consumption
|
||||||
process was started successfully, with the UUID of the consumption task
|
process was started successfully, with the UUID of the consumption task
|
||||||
@@ -282,18 +282,6 @@ The following methods are supported:
|
|||||||
- `"merge": true or false` (defaults to false)
|
- `"merge": true or false` (defaults to false)
|
||||||
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
||||||
removing them) or be merged with existing permissions.
|
removing them) or be merged with existing permissions.
|
||||||
- `edit_pdf`
|
|
||||||
- Requires `parameters`:
|
|
||||||
- `"doc_ids": [DOCUMENT_ID]` A list of a single document ID to edit.
|
|
||||||
- `"operations": [OPERATION, ...]` A list of operations to perform on the documents. Each operation is a dictionary
|
|
||||||
with the following keys:
|
|
||||||
- `"page": PAGE_NUMBER` The page number to edit (1-based).
|
|
||||||
- `"rotate": DEGREES` Optional rotation in degrees (90, 180, 270).
|
|
||||||
- `"doc": OUTPUT_DOCUMENT_INDEX` Optional index of the output document for split operations.
|
|
||||||
- Optional `parameters`:
|
|
||||||
- `"delete_original": true` to delete the original documents after editing.
|
|
||||||
- `"update_document": true` to update the existing document with the edited PDF.
|
|
||||||
- `"include_metadata": true` to copy metadata from the original document to the edited document.
|
|
||||||
- `merge`
|
- `merge`
|
||||||
- No additional `parameters` required.
|
- No additional `parameters` required.
|
||||||
- The ordering of the merged document is determined by the list of IDs.
|
- The ordering of the merged document is determined by the list of IDs.
|
||||||
|
|||||||
@@ -1,281 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## paperless-ngx 2.18.4
|
|
||||||
|
|
||||||
### Features / Enhancements
|
|
||||||
|
|
||||||
- Enhancement: report websocket status [@shamoon](https://github.com/shamoon) ([#10777](https://github.com/paperless-ngx/paperless-ngx/pull/10777))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Revert "Performance: Enable virtual scrolling for large custom field … [@shamoon](https://github.com/shamoon) ([#10803](https://github.com/paperless-ngx/paperless-ngx/pull/10803))
|
|
||||||
- Fixhancement: update sidebar view counts on save \& next also [@shamoon](https://github.com/shamoon) ([#10793](https://github.com/paperless-ngx/paperless-ngx/pull/10793))
|
|
||||||
- Performance fix: add paging for custom field select options [@shamoon](https://github.com/shamoon) ([#10755](https://github.com/paperless-ngx/paperless-ngx/pull/10755))
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>8 changes</summary>
|
|
||||||
|
|
||||||
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 2 updates [@shamoon](https://github.com/shamoon) ([#10770](https://github.com/paperless-ngx/paperless-ngx/pull/10770))
|
|
||||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10745](https://github.com/paperless-ngx/paperless-ngx/pull/10745))
|
|
||||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 22 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10744](https://github.com/paperless-ngx/paperless-ngx/pull/10744))
|
|
||||||
- Chore(deps): Bump bootstrap from 5.3.7 to 5.3.8 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10740](https://github.com/paperless-ngx/paperless-ngx/pull/10740))
|
|
||||||
- Chore(deps-dev): Bump @<!---->playwright/test from 1.54.2 to 1.55.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10743](https://github.com/paperless-ngx/paperless-ngx/pull/10743))
|
|
||||||
- Chore(deps-dev): Bump webpack from 5.101.0 to 5.101.3 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10751](https://github.com/paperless-ngx/paperless-ngx/pull/10751))
|
|
||||||
- Chore(deps-dev): Bump @<!---->types/node from 24.1.0 to 24.3.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10750](https://github.com/paperless-ngx/paperless-ngx/pull/10750))
|
|
||||||
- Chore(deps): Bump the actions group with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10757](https://github.com/paperless-ngx/paperless-ngx/pull/10757))
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### All App Changes
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>13 changes</summary>
|
|
||||||
|
|
||||||
- Revert "Performance: Enable virtual scrolling for large custom field … @shamoon ([#10803](https://github.com/paperless-ngx/paperless-ngx/pull/10803))
|
|
||||||
- Fixhancement: update sidebar view counts on save \& next also @shamoon ([#10793](https://github.com/paperless-ngx/paperless-ngx/pull/10793))
|
|
||||||
- Enhancement: report websocket status @shamoon ([#10777](https://github.com/paperless-ngx/paperless-ngx/pull/10777))
|
|
||||||
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 2 updates @shamoon ([#10770](https://github.com/paperless-ngx/paperless-ngx/pull/10770))
|
|
||||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10745](https://github.com/paperless-ngx/paperless-ngx/pull/10745))
|
|
||||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 22 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10744](https://github.com/paperless-ngx/paperless-ngx/pull/10744))
|
|
||||||
- Chore(deps): Bump bootstrap from 5.3.7 to 5.3.8 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10740](https://github.com/paperless-ngx/paperless-ngx/pull/10740))
|
|
||||||
- Chore(deps-dev): Bump @<!---->playwright/test from 1.54.2 to 1.55.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10743](https://github.com/paperless-ngx/paperless-ngx/pull/10743))
|
|
||||||
- Chore(deps-dev): Bump webpack from 5.101.0 to 5.101.3 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10751](https://github.com/paperless-ngx/paperless-ngx/pull/10751))
|
|
||||||
- Chore(deps-dev): Bump @<!---->types/node from 24.1.0 to 24.3.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10750](https://github.com/paperless-ngx/paperless-ngx/pull/10750))
|
|
||||||
- Chore: switch from os.path to pathlib.Path @gothicVI ([#10539](https://github.com/paperless-ngx/paperless-ngx/pull/10539))
|
|
||||||
- Performance fix: add paging for custom field select options @shamoon ([#10755](https://github.com/paperless-ngx/paperless-ngx/pull/10755))
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## paperless-ngx 2.18.3
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fix: include application config language settings for dateparser auto-detection [@shamoon](https://github.com/shamoon) ([#10722](https://github.com/paperless-ngx/paperless-ngx/pull/10722))
|
|
||||||
- Fix: hide sidebar counts during saved views organization [@shamoon](https://github.com/shamoon) ([#10716](https://github.com/paperless-ngx/paperless-ngx/pull/10716))
|
|
||||||
- Fix: wrap long view titles in sidebar [@shamoon](https://github.com/shamoon) ([#10715](https://github.com/paperless-ngx/paperless-ngx/pull/10715))
|
|
||||||
- Fixhancement: more saved view count refreshes [@shamoon](https://github.com/shamoon) ([#10694](https://github.com/paperless-ngx/paperless-ngx/pull/10694))
|
|
||||||
- Fix: include pagination array items for valid openapi schema [@shamoon](https://github.com/shamoon) ([#10682](https://github.com/paperless-ngx/paperless-ngx/pull/10682))
|
|
||||||
- Fix: prevent scroll for view name in sidebar [@shamoon](https://github.com/shamoon) ([#10676](https://github.com/paperless-ngx/paperless-ngx/pull/10676))
|
|
||||||
- Tweak: center document close button in app frame [@shamoon](https://github.com/shamoon) ([#10661](https://github.com/paperless-ngx/paperless-ngx/pull/10661))
|
|
||||||
- Performance: Enable virtual scrolling for large custom field selects @david-loe ([#10708](https://github.com/paperless-ngx/paperless-ngx/pull/10708))
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>5 changes</summary>
|
|
||||||
|
|
||||||
- Chore(deps): Update granian[uvloop] requirement from ~=2.4.1 to ~=2.5.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10529](https://github.com/paperless-ngx/paperless-ngx/pull/10529))
|
|
||||||
- Chore(deps): Bump the small-changes group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10714](https://github.com/paperless-ngx/paperless-ngx/pull/10714))
|
|
||||||
- docker-compose(deps): Bump library/mariadb from 11 to 12 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#10621](https://github.com/paperless-ngx/paperless-ngx/pull/10621))
|
|
||||||
- docker-compose(deps): Bump gotenberg/gotenberg from 8.20 to 8.22 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#10687](https://github.com/paperless-ngx/paperless-ngx/pull/10687))
|
|
||||||
- docker(deps): Bump astral-sh/uv from 0.8.8-python3.12-bookworm-slim to 0.8.13-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10685](https://github.com/paperless-ngx/paperless-ngx/pull/10685))
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### All App Changes
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>11 changes</summary>
|
|
||||||
|
|
||||||
- Fix: include application config language settings for dateparser auto-detection [@shamoon](https://github.com/shamoon) ([#10722](https://github.com/paperless-ngx/paperless-ngx/pull/10722))
|
|
||||||
- Chore(deps): Update granian[uvloop] requirement from ~=2.4.1 to ~=2.5.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10529](https://github.com/paperless-ngx/paperless-ngx/pull/10529))
|
|
||||||
- Chore(deps): Bump the small-changes group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10714](https://github.com/paperless-ngx/paperless-ngx/pull/10714))
|
|
||||||
- Fix: hide sidebar counts during saved views organization [@shamoon](https://github.com/shamoon) ([#10716](https://github.com/paperless-ngx/paperless-ngx/pull/10716))
|
|
||||||
- Fix: wrap long view titles in sidebar [@shamoon](https://github.com/shamoon) ([#10715](https://github.com/paperless-ngx/paperless-ngx/pull/10715))
|
|
||||||
- Performance: Enable virtual scrolling for large custom field selects @david-loe ([#10708](https://github.com/paperless-ngx/paperless-ngx/pull/10708))
|
|
||||||
- Chore: refactor document details component [@shamoon](https://github.com/shamoon) ([#10662](https://github.com/paperless-ngx/paperless-ngx/pull/10662))
|
|
||||||
- Fixhancement: more saved view count refreshes [@shamoon](https://github.com/shamoon) ([#10694](https://github.com/paperless-ngx/paperless-ngx/pull/10694))
|
|
||||||
- Fix: include pagination array items for valid openapi schema [@shamoon](https://github.com/shamoon) ([#10682](https://github.com/paperless-ngx/paperless-ngx/pull/10682))
|
|
||||||
- Fix: prevent scroll for view name in sidebar [@shamoon](https://github.com/shamoon) ([#10676](https://github.com/paperless-ngx/paperless-ngx/pull/10676))
|
|
||||||
- Tweak: center document close button in app frame [@shamoon](https://github.com/shamoon) ([#10661](https://github.com/paperless-ngx/paperless-ngx/pull/10661))
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## paperless-ngx 2.18.2
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fix: prevent loss of changes when switching between open docs [@shamoon](https://github.com/shamoon) ([#10659](https://github.com/paperless-ngx/paperless-ngx/pull/10659))
|
|
||||||
- Fix: ignore incomplete tasks for system status 'last run' [@shamoon](https://github.com/shamoon) ([#10641](https://github.com/paperless-ngx/paperless-ngx/pull/10641))
|
|
||||||
- Fix: increase legibility of date filter clear button in light mode [@shamoon](https://github.com/shamoon) ([#10649](https://github.com/paperless-ngx/paperless-ngx/pull/10649))
|
|
||||||
- Fix: ensure saved view count is visible with long names [@shamoon](https://github.com/shamoon) ([#10616](https://github.com/paperless-ngx/paperless-ngx/pull/10616))
|
|
||||||
- Tweak: improve dateparser auto-detection messages [@shamoon](https://github.com/shamoon) ([#10640](https://github.com/paperless-ngx/paperless-ngx/pull/10640))
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
- Chore(deps): Bump the development group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10578](https://github.com/paperless-ngx/paperless-ngx/pull/10578))
|
|
||||||
|
|
||||||
### All App Changes
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>6 changes</summary>
|
|
||||||
|
|
||||||
- Fix: prevent loss of changes when switching between open docs [@shamoon](https://github.com/shamoon) ([#10659](https://github.com/paperless-ngx/paperless-ngx/pull/10659))
|
|
||||||
- Fix: ignore incomplete tasks for system status 'last run' [@shamoon](https://github.com/shamoon) ([#10641](https://github.com/paperless-ngx/paperless-ngx/pull/10641))
|
|
||||||
- Tweak: improve dateparser auto-detection messages [@shamoon](https://github.com/shamoon) ([#10640](https://github.com/paperless-ngx/paperless-ngx/pull/10640))
|
|
||||||
- Fix: increase legibility of date filter clear button in light mode [@shamoon](https://github.com/shamoon) ([#10649](https://github.com/paperless-ngx/paperless-ngx/pull/10649))
|
|
||||||
- Fix: ensure saved view count is visible with long names [@shamoon](https://github.com/shamoon) ([#10616](https://github.com/paperless-ngx/paperless-ngx/pull/10616))
|
|
||||||
- Chore(deps): Bump the development group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10578](https://github.com/paperless-ngx/paperless-ngx/pull/10578))
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## paperless-ngx 2.18.1
|
|
||||||
|
|
||||||
### Features / Enhancements
|
|
||||||
|
|
||||||
- Tweak: fix some button consistency [@shamoon](https://github.com/shamoon) ([#10593](https://github.com/paperless-ngx/paperless-ngx/pull/10593))
|
|
||||||
- Fixhancement: mobile layout improvements for pdf editor [@shamoon](https://github.com/shamoon) ([#10588](https://github.com/paperless-ngx/paperless-ngx/pull/10588))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fix: fix app logo validation with no file [@shamoon](https://github.com/shamoon) ([#10599](https://github.com/paperless-ngx/paperless-ngx/pull/10599))
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- Documentation: fix filters docs [@shamoon](https://github.com/shamoon) ([#10600](https://github.com/paperless-ngx/paperless-ngx/pull/10600))
|
|
||||||
|
|
||||||
### All App Changes
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>4 changes</summary>
|
|
||||||
|
|
||||||
- Fix: fix app logo validation with no file [@shamoon](https://github.com/shamoon) ([#10599](https://github.com/paperless-ngx/paperless-ngx/pull/10599))
|
|
||||||
- Tweak: fix some button consistency [@shamoon](https://github.com/shamoon) ([#10593](https://github.com/paperless-ngx/paperless-ngx/pull/10593))
|
|
||||||
- Development: restore version tag display [@shamoon](https://github.com/shamoon) ([#10592](https://github.com/paperless-ngx/paperless-ngx/pull/10592))
|
|
||||||
- Fixhancement: mobile layout improvements for pdf editor [@shamoon](https://github.com/shamoon) ([#10588](https://github.com/paperless-ngx/paperless-ngx/pull/10588))
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## paperless-ngx 2.18.0
|
|
||||||
|
|
||||||
### Notable Changes
|
|
||||||
|
|
||||||
- Feature: PDF editor [@shamoon](https://github.com/shamoon) ([#10318](https://github.com/paperless-ngx/paperless-ngx/pull/10318))
|
|
||||||
|
|
||||||
### Features / Enhancements
|
|
||||||
|
|
||||||
- Feature: Add filter to localize dates for filepath templating [@stumpylog](https://github.com/stumpylog) ([#10559](https://github.com/paperless-ngx/paperless-ngx/pull/10559))
|
|
||||||
- Feature: PDF editor [@shamoon](https://github.com/shamoon) ([#10318](https://github.com/paperless-ngx/paperless-ngx/pull/10318))
|
|
||||||
- Enhancement: support webhook restrictions [@shamoon](https://github.com/shamoon) ([#10555](https://github.com/paperless-ngx/paperless-ngx/pull/10555))
|
|
||||||
- Performance: Classifier performance optimizations [@Merinorus](https://github.com/Merinorus) ([#10363](https://github.com/paperless-ngx/paperless-ngx/pull/10363))
|
|
||||||
- Performance: add setting to enable DB connection pooling for PostgreSQL [@Merinorus](https://github.com/Merinorus) ([#10354](https://github.com/paperless-ngx/paperless-ngx/pull/10354))
|
|
||||||
- Fixhancement: improve text thumbnail generation for large files [@shamoon](https://github.com/shamoon) ([#10483](https://github.com/paperless-ngx/paperless-ngx/pull/10483))
|
|
||||||
- Enhancement: disable auto spellcheck on filtering dropdowns [@TheDodger](https://github.com/TheDodger) ([#10487](https://github.com/paperless-ngx/paperless-ngx/pull/10487))
|
|
||||||
- Enhancement: display saved view counts [@shamoon](https://github.com/shamoon) ([#10246](https://github.com/paperless-ngx/paperless-ngx/pull/10246))
|
|
||||||
- Fixhancement: add missing exact operator for boolean CF queries [@shamoon](https://github.com/shamoon) ([#10402](https://github.com/paperless-ngx/paperless-ngx/pull/10402))
|
|
||||||
- Feature: add Vietnamese translation [@shamoon](https://github.com/shamoon) ([#10352](https://github.com/paperless-ngx/paperless-ngx/pull/10352))
|
|
||||||
- Performance: Add support for configuring date parser languages [@Merinorus](https://github.com/Merinorus) ([#10181](https://github.com/paperless-ngx/paperless-ngx/pull/10181))
|
|
||||||
- Enhancement: Add a database caching for improved performance [@Merinorus](https://github.com/Merinorus) ([#9784](https://github.com/paperless-ngx/paperless-ngx/pull/9784))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fix: include ignore for config logos in sanity checker [@shamoon](https://github.com/shamoon) ([#10473](https://github.com/paperless-ngx/paperless-ngx/pull/10473))
|
|
||||||
- Fix: track and restore changed document fields from session storage [@shamoon](https://github.com/shamoon) ([#10468](https://github.com/paperless-ngx/paperless-ngx/pull/10468))
|
|
||||||
- Fix: Make some natural keyword date searches timezone-aware [@shamoon](https://github.com/shamoon) ([#10416](https://github.com/paperless-ngx/paperless-ngx/pull/10416))
|
|
||||||
- Fixhancement: follow redirects in curl health check [@V0idC0de](https://github.com/V0idC0de) ([#10415](https://github.com/paperless-ngx/paperless-ngx/pull/10415))
|
|
||||||
- Fix: dont use translated verbose_name for getting object perms [@shamoon](https://github.com/shamoon) ([#10399](https://github.com/paperless-ngx/paperless-ngx/pull/10399))
|
|
||||||
- Fix: fix date format for 'today' in DateComponent [@shamoon](https://github.com/shamoon) ([#10369](https://github.com/paperless-ngx/paperless-ngx/pull/10369))
|
|
||||||
- Fix: default to empty permissions for group creation [@shamoon](https://github.com/shamoon) ([#10337](https://github.com/paperless-ngx/paperless-ngx/pull/10337))
|
|
||||||
- Fix: correct api created coercion with timezone [@shamoon](https://github.com/shamoon) ([#10287](https://github.com/paperless-ngx/paperless-ngx/pull/10287))
|
|
||||||
- Fix: reset search query for preview on reset filter [@shamoon](https://github.com/shamoon) ([#10279](https://github.com/paperless-ngx/paperless-ngx/pull/10279))
|
|
||||||
- Chore: reject absurd max age values [@shamoon](https://github.com/shamoon) ([#10243](https://github.com/paperless-ngx/paperless-ngx/pull/10243))
|
|
||||||
- Chore: add tasks task_id param to openapi spec [@shamoon](https://github.com/shamoon) ([#10469](https://github.com/paperless-ngx/paperless-ngx/pull/10469))
|
|
||||||
- Chore: include advanced search query param in API spec [@shamoon](https://github.com/shamoon) ([#10449](https://github.com/paperless-ngx/paperless-ngx/pull/10449))
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- Address XSS vulnerability GHSA-6p53-hqqw-8j62
|
|
||||||
|
|
||||||
### Maintenance
|
|
||||||
|
|
||||||
- docker(deps): Bump astral-sh/uv from 0.8.4-python3.12-bookworm-slim to 0.8.8-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10564](https://github.com/paperless-ngx/paperless-ngx/pull/10564))
|
|
||||||
- docker(deps): Bump astral-sh/uv from 0.7.9-python3.12-bookworm-slim to 0.7.19-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10343](https://github.com/paperless-ngx/paperless-ngx/pull/10343))
|
|
||||||
- Chore(deps): Bump the small-changes group across 1 directory with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10347](https://github.com/paperless-ngx/paperless-ngx/pull/10347))
|
|
||||||
- Chore(deps-dev): Bump @types/node from 22.15.29 to 24.0.10 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10306](https://github.com/paperless-ngx/paperless-ngx/pull/10306))
|
|
||||||
- Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10481](https://github.com/paperless-ngx/paperless-ngx/pull/10481))
|
|
||||||
- docker(deps): bump astral-sh/uv from 0.7.19-python3.12-bookworm-slim to 0.8.3-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10465](https://github.com/paperless-ngx/paperless-ngx/pull/10465))
|
|
||||||
- Chore: switch from os.path to pathlib.Path [@gothicVI](https://github.com/gothicVI) ([#10397](https://github.com/paperless-ngx/paperless-ngx/pull/10397))
|
|
||||||
- Chore(deps): Bump the small-changes group with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10528](https://github.com/paperless-ngx/paperless-ngx/pull/10528))
|
|
||||||
- Chore(deps): Bump the django group across 1 directory with 9 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10538](https://github.com/paperless-ngx/paperless-ngx/pull/10538))
|
|
||||||
- Chore(deps): Bump stefanzweifel/git-auto-commit-action from 5 to 6 in the actions group @[dependabot[bot]](https://github.com/apps/dependabot) ([#10302](https://github.com/paperless-ngx/paperless-ngx/pull/10302))
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>23 changes</summary>
|
|
||||||
|
|
||||||
- chore: Small targeted upgrades to dependencies [@stumpylog](https://github.com/stumpylog) ([#10561](https://github.com/paperless-ngx/paperless-ngx/pull/10561))
|
|
||||||
- docker(deps): Bump astral-sh/uv from 0.8.4-python3.12-bookworm-slim to 0.8.8-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10564](https://github.com/paperless-ngx/paperless-ngx/pull/10564))
|
|
||||||
- Chore(deps): Bump the django group across 1 directory with 9 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10538](https://github.com/paperless-ngx/paperless-ngx/pull/10538))
|
|
||||||
- Chore(deps): Bump the small-changes group with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10528](https://github.com/paperless-ngx/paperless-ngx/pull/10528))
|
|
||||||
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10497](https://github.com/paperless-ngx/paperless-ngx/pull/10497))
|
|
||||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10498](https://github.com/paperless-ngx/paperless-ngx/pull/10498))
|
|
||||||
- Chore(deps-dev): Bump @playwright/test from 1.53.2 to 1.54.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10499](https://github.com/paperless-ngx/paperless-ngx/pull/10499))
|
|
||||||
- Chore(deps-dev): Bump webpack from 5.99.9 to 5.101.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10501](https://github.com/paperless-ngx/paperless-ngx/pull/10501))
|
|
||||||
- Chore(deps-dev): Bump prettier-plugin-organize-imports from 4.1.0 to 4.2.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10500](https://github.com/paperless-ngx/paperless-ngx/pull/10500))
|
|
||||||
- Chore(deps-dev): Bump @types/node from 24.0.10 to 24.1.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10502](https://github.com/paperless-ngx/paperless-ngx/pull/10502))
|
|
||||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 16 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10496](https://github.com/paperless-ngx/paperless-ngx/pull/10496))
|
|
||||||
- Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10481](https://github.com/paperless-ngx/paperless-ngx/pull/10481))
|
|
||||||
- docker(deps): bump astral-sh/uv from 0.7.19-python3.12-bookworm-slim to 0.8.3-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10465](https://github.com/paperless-ngx/paperless-ngx/pull/10465))
|
|
||||||
- docker(deps): Bump astral-sh/uv from 0.7.9-python3.12-bookworm-slim to 0.7.19-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10343](https://github.com/paperless-ngx/paperless-ngx/pull/10343))
|
|
||||||
- Chore(deps): Bump the small-changes group across 1 directory with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10347](https://github.com/paperless-ngx/paperless-ngx/pull/10347))
|
|
||||||
- Chore(deps): Bump stefanzweifel/git-auto-commit-action from 5 to 6 in the actions group @[dependabot[bot]](https://github.com/apps/dependabot) ([#10302](https://github.com/paperless-ngx/paperless-ngx/pull/10302))
|
|
||||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10311](https://github.com/paperless-ngx/paperless-ngx/pull/10311))
|
|
||||||
- Chore(deps-dev): Bump @types/node from 22.15.29 to 24.0.10 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10306](https://github.com/paperless-ngx/paperless-ngx/pull/10306))
|
|
||||||
- Chore(deps): Bump bootstrap from 5.3.6 to 5.3.7 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10308](https://github.com/paperless-ngx/paperless-ngx/pull/10308))
|
|
||||||
- Chore(deps-dev): Bump webpack from 5.98.0 to 5.99.9 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10309](https://github.com/paperless-ngx/paperless-ngx/pull/10309))
|
|
||||||
- Chore(deps-dev): Bump @playwright/test from 1.51.1 to 1.53.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10307](https://github.com/paperless-ngx/paperless-ngx/pull/10307))
|
|
||||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 13 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10303](https://github.com/paperless-ngx/paperless-ngx/pull/10303))
|
|
||||||
- Chore: update to Angular 20 [@shamoon](https://github.com/shamoon) ([#10273](https://github.com/paperless-ngx/paperless-ngx/pull/10273))
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### All App Changes
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>44 changes</summary>
|
|
||||||
|
|
||||||
- chore: Small targeted upgrades to dependencies [@stumpylog](https://github.com/stumpylog) ([#10561](https://github.com/paperless-ngx/paperless-ngx/pull/10561))
|
|
||||||
- Feature: Add filter to localize dates for filepath templating [@stumpylog](https://github.com/stumpylog) ([#10559](https://github.com/paperless-ngx/paperless-ngx/pull/10559))
|
|
||||||
- Chore: Removes duplication and spread out config for codespell [@stumpylog](https://github.com/stumpylog) ([#10560](https://github.com/paperless-ngx/paperless-ngx/pull/10560))
|
|
||||||
- Chore(deps): Bump the django group across 1 directory with 9 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10538](https://github.com/paperless-ngx/paperless-ngx/pull/10538))
|
|
||||||
- Feature: PDF editor [@shamoon](https://github.com/shamoon) ([#10318](https://github.com/paperless-ngx/paperless-ngx/pull/10318))
|
|
||||||
- Enhancement: support webhook restrictions [@shamoon](https://github.com/shamoon) ([#10555](https://github.com/paperless-ngx/paperless-ngx/pull/10555))
|
|
||||||
- Performance: Classifier performance optimizations [@Merinorus](https://github.com/Merinorus) ([#10363](https://github.com/paperless-ngx/paperless-ngx/pull/10363))
|
|
||||||
- Chore: switch from os.path to pathlib.Path [@gothicVI](https://github.com/gothicVI) ([#10397](https://github.com/paperless-ngx/paperless-ngx/pull/10397))
|
|
||||||
- Chore(deps): Bump the small-changes group with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10528](https://github.com/paperless-ngx/paperless-ngx/pull/10528))
|
|
||||||
- Performance: add setting to enable DB connection pooling for PostgreSQL [@Merinorus](https://github.com/Merinorus) ([#10354](https://github.com/paperless-ngx/paperless-ngx/pull/10354))
|
|
||||||
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10497](https://github.com/paperless-ngx/paperless-ngx/pull/10497))
|
|
||||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10498](https://github.com/paperless-ngx/paperless-ngx/pull/10498))
|
|
||||||
- Chore(deps-dev): Bump @playwright/test from 1.53.2 to 1.54.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10499](https://github.com/paperless-ngx/paperless-ngx/pull/10499))
|
|
||||||
- Chore(deps-dev): Bump webpack from 5.99.9 to 5.101.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10501](https://github.com/paperless-ngx/paperless-ngx/pull/10501))
|
|
||||||
- Chore(deps-dev): Bump prettier-plugin-organize-imports from 4.1.0 to 4.2.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10500](https://github.com/paperless-ngx/paperless-ngx/pull/10500))
|
|
||||||
- Chore(deps-dev): Bump @types/node from 24.0.10 to 24.1.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10502](https://github.com/paperless-ngx/paperless-ngx/pull/10502))
|
|
||||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 16 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10496](https://github.com/paperless-ngx/paperless-ngx/pull/10496))
|
|
||||||
- Fixhancement: improve text thumbnail generation for large files [@shamoon](https://github.com/shamoon) ([#10483](https://github.com/paperless-ngx/paperless-ngx/pull/10483))
|
|
||||||
- Enhancement: disable auto spellcheck on filtering dropdowns [@TheDodger](https://github.com/TheDodger) ([#10487](https://github.com/paperless-ngx/paperless-ngx/pull/10487))
|
|
||||||
- Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10481](https://github.com/paperless-ngx/paperless-ngx/pull/10481))
|
|
||||||
- Fix: include ignore for config logos in sanity checker [@shamoon](https://github.com/shamoon) ([#10473](https://github.com/paperless-ngx/paperless-ngx/pull/10473))
|
|
||||||
- Chore: add tasks task_id param to openapi spec [@shamoon](https://github.com/shamoon) ([#10469](https://github.com/paperless-ngx/paperless-ngx/pull/10469))
|
|
||||||
- Fix: track and restore changed document fields from session storage [@shamoon](https://github.com/shamoon) ([#10468](https://github.com/paperless-ngx/paperless-ngx/pull/10468))
|
|
||||||
- Chore: include advanced search query param in API spec [@shamoon](https://github.com/shamoon) ([#10449](https://github.com/paperless-ngx/paperless-ngx/pull/10449))
|
|
||||||
- Enhancement: display saved view counts [@shamoon](https://github.com/shamoon) ([#10246](https://github.com/paperless-ngx/paperless-ngx/pull/10246))
|
|
||||||
- Fix: Make some natural keyword date searches timezone-aware [@shamoon](https://github.com/shamoon) ([#10416](https://github.com/paperless-ngx/paperless-ngx/pull/10416))
|
|
||||||
- Fixhancement: add missing exact operator for boolean CF queries [@shamoon](https://github.com/shamoon) ([#10402](https://github.com/paperless-ngx/paperless-ngx/pull/10402))
|
|
||||||
- Fix: dont use translated verbose_name for getting object perms [@shamoon](https://github.com/shamoon) ([#10399](https://github.com/paperless-ngx/paperless-ngx/pull/10399))
|
|
||||||
- Fix: fix date format for 'today' in DateComponent [@shamoon](https://github.com/shamoon) ([#10369](https://github.com/paperless-ngx/paperless-ngx/pull/10369))
|
|
||||||
- Feature: add Vietnamese translation [@shamoon](https://github.com/shamoon) ([#10352](https://github.com/paperless-ngx/paperless-ngx/pull/10352))
|
|
||||||
- Chore(deps): Bump the small-changes group across 1 directory with 7 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10347](https://github.com/paperless-ngx/paperless-ngx/pull/10347))
|
|
||||||
- Fix: default to empty permissions for group creation [@shamoon](https://github.com/shamoon) ([#10337](https://github.com/paperless-ngx/paperless-ngx/pull/10337))
|
|
||||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10311](https://github.com/paperless-ngx/paperless-ngx/pull/10311))
|
|
||||||
- Chore(deps-dev): Bump @types/node from 22.15.29 to 24.0.10 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10306](https://github.com/paperless-ngx/paperless-ngx/pull/10306))
|
|
||||||
- Chore(deps): Bump bootstrap from 5.3.6 to 5.3.7 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10308](https://github.com/paperless-ngx/paperless-ngx/pull/10308))
|
|
||||||
- Chore(deps-dev): Bump webpack from 5.98.0 to 5.99.9 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10309](https://github.com/paperless-ngx/paperless-ngx/pull/10309))
|
|
||||||
- Chore(deps-dev): Bump @playwright/test from 1.51.1 to 1.53.2 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10307](https://github.com/paperless-ngx/paperless-ngx/pull/10307))
|
|
||||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 13 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10303](https://github.com/paperless-ngx/paperless-ngx/pull/10303))
|
|
||||||
- Performance: Add support for configuring date parser languages [@Merinorus](https://github.com/Merinorus) ([#10181](https://github.com/paperless-ngx/paperless-ngx/pull/10181))
|
|
||||||
- Enhancement: Add a database caching for improved performance [@Merinorus](https://github.com/Merinorus) ([#9784](https://github.com/paperless-ngx/paperless-ngx/pull/9784))
|
|
||||||
- Fix: correct api created coercion with timezone [@shamoon](https://github.com/shamoon) ([#10287](https://github.com/paperless-ngx/paperless-ngx/pull/10287))
|
|
||||||
- Fix: reset search query for preview on reset filter [@shamoon](https://github.com/shamoon) ([#10279](https://github.com/paperless-ngx/paperless-ngx/pull/10279))
|
|
||||||
- Chore: update to Angular 20 [@shamoon](https://github.com/shamoon) ([#10273](https://github.com/paperless-ngx/paperless-ngx/pull/10273))
|
|
||||||
- Chore: reject absurd max age values [@shamoon](https://github.com/shamoon) ([#10243](https://github.com/paperless-ngx/paperless-ngx/pull/10243))
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## paperless-ngx 2.17.1
|
## paperless-ngx 2.17.1
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
@@ -5699,6 +5423,9 @@ This release contains new database migrations.
|
|||||||
Paperless will continue to work with WSGI, but you will not get any
|
Paperless will continue to work with WSGI, but you will not get any
|
||||||
status notifications.
|
status notifications.
|
||||||
|
|
||||||
|
Apache `mod_wsgi` users, see
|
||||||
|
[this note](faq.md#how-do-i-get-websocket-support-with-apache-mod_wsgi).
|
||||||
|
|
||||||
- Paperless now offers suggestions for tags, correspondents and types
|
- Paperless now offers suggestions for tags, correspondents and types
|
||||||
on the document detail page.
|
on the document detail page.
|
||||||
|
|
||||||
@@ -6500,12 +6227,11 @@ primarily.
|
|||||||
who are doing active development on Paperless using the Docker
|
who are doing active development on Paperless using the Docker
|
||||||
environment:
|
environment:
|
||||||
[#376](https://github.com/the-paperless-project/paperless/pull/376).
|
[#376](https://github.com/the-paperless-project/paperless/pull/376).
|
||||||
- ~~You now also have the ability to customise the interface to your
|
- You now also have the ability to customise the interface to your
|
||||||
heart's content by creating a file called `overrides.css` and/or
|
heart's content by creating a file called `overrides.css` and/or
|
||||||
`overrides.js` in the root of your media directory. Thanks to [Mark
|
`overrides.js` in the root of your media directory. Thanks to [Mark
|
||||||
McFate](https://github.com/SummittDweller) for this idea:
|
McFate](https://github.com/SummittDweller) for this idea:
|
||||||
[#371](https://github.com/the-paperless-project/paperless/issues/371)~~
|
[#371](https://github.com/the-paperless-project/paperless/issues/371)
|
||||||
(Not supported by Paperless-ngx)
|
|
||||||
|
|
||||||
### 2.0.0
|
### 2.0.0
|
||||||
|
|
||||||
|
|||||||
@@ -170,11 +170,11 @@ Available options are `postgresql` and `mariadb`.
|
|||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
A small pool is typically sufficient — for example, a size of 4.
|
A small pool is typically sufficient — for example, a size of 4.
|
||||||
Make sure your PostgreSQL server's max_connections setting is large enough to handle:
|
Make sure your PostgreSQL server's max_connections setting is large enough to handle:
|
||||||
```(Paperless workers + Celery workers) × pool size + safety margin```
|
```(Paperless workers + Celery workers) × pool size + safety margin```
|
||||||
For example, with 4 Paperless workers and 2 Celery workers, and a pool size of 4:
|
For example, with 4 Paperless workers and 2 Celery workers, and a pool size of 4:
|
||||||
(4 + 2) × 4 + 10 = 34 connections required.
|
(4 + 2) × 4 + 10 = 34 connections required.
|
||||||
|
|
||||||
#### [`PAPERLESS_DB_READ_CACHE_ENABLED=<bool>`](#PAPERLESS_DB_READ_CACHE_ENABLED) {#PAPERLESS_DB_READ_CACHE_ENABLED}
|
#### [`PAPERLESS_DB_READ_CACHE_ENABLED=<bool>`](#PAPERLESS_DB_READ_CACHE_ENABLED) {#PAPERLESS_DB_READ_CACHE_ENABLED}
|
||||||
|
|
||||||
@@ -184,9 +184,9 @@ Available options are `postgresql` and `mariadb`.
|
|||||||
|
|
||||||
!!! danger
|
!!! danger
|
||||||
|
|
||||||
**Do not modify the database outside the application while it is running.**
|
**Do not modify the database outside the application while it is running.**
|
||||||
This includes actions such as restoring a backup, upgrading the database, or performing manual inserts. All external modifications must be done **only when the application is stopped**.
|
This includes actions such as restoring a backup, upgrading the database, or performing manual inserts. All external modifications must be done **only when the application is stopped**.
|
||||||
After making any such changes, you **must invalidate the DB read cache** using the `invalidate_cachalot` management command.
|
After making any such changes, you **must invalidate the DB read cache** using the `invalidate_cachalot` management command.
|
||||||
|
|
||||||
#### [`PAPERLESS_READ_CACHE_TTL=<int>`](#PAPERLESS_READ_CACHE_TTL) {#PAPERLESS_READ_CACHE_TTL}
|
#### [`PAPERLESS_READ_CACHE_TTL=<int>`](#PAPERLESS_READ_CACHE_TTL) {#PAPERLESS_READ_CACHE_TTL}
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ Available options are `postgresql` and `mariadb`.
|
|||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
A high TTL increases memory usage over time. Memory may be used until end of TTL, even if the cache is invalidated with the `invalidate_cachalot` command.
|
A high TTL increases memory usage over time. Memory may be used until end of TTL, even if the cache is invalidated with the `invalidate_cachalot` command.
|
||||||
|
|
||||||
In case of an out-of-memory (OOM) situation, Redis may stop accepting new data — including cache entries, scheduled tasks, and documents to consume.
|
In case of an out-of-memory (OOM) situation, Redis may stop accepting new data — including cache entries, scheduled tasks, and documents to consume.
|
||||||
If your system has limited RAM, consider configuring a dedicated Redis instance for the read cache, with a memory limit and the eviction policy set to `allkeys-lru`.
|
If your system has limited RAM, consider configuring a dedicated Redis instance for the read cache, with a memory limit and the eviction policy set to `allkeys-lru`.
|
||||||
@@ -1282,30 +1282,6 @@ within your documents.
|
|||||||
|
|
||||||
Defaults to false.
|
Defaults to false.
|
||||||
|
|
||||||
## Workflow webhooks
|
|
||||||
|
|
||||||
#### [`PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES=<str>`](#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES) {#PAPERLESS_WEBHOOKS_ALLOWED_SCHEMES}
|
|
||||||
|
|
||||||
: A comma-separated list of allowed schemes for webhooks. This setting
|
|
||||||
controls which URL schemes are permitted for webhook URLs.
|
|
||||||
|
|
||||||
Defaults to `http,https`.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_WEBHOOKS_ALLOWED_PORTS=<str>`](#PAPERLESS_WEBHOOKS_ALLOWED_PORTS) {#PAPERLESS_WEBHOOKS_ALLOWED_PORTS}
|
|
||||||
|
|
||||||
: A comma-separated list of allowed ports for webhooks. This setting
|
|
||||||
controls which ports are permitted for webhook URLs. For example, if you
|
|
||||||
set this to `80,443`, webhooks will only be sent to URLs that use these
|
|
||||||
ports.
|
|
||||||
|
|
||||||
Defaults to empty list, which allows all ports.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS=<bool>`](#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS) {#PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS}
|
|
||||||
|
|
||||||
: If set to false, webhooks cannot be sent to internal URLs (e.g., localhost).
|
|
||||||
|
|
||||||
Defaults to true, which allows internal requests.
|
|
||||||
|
|
||||||
### Polling {#polling}
|
### Polling {#polling}
|
||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_POLLING=<num>`](#PAPERLESS_CONSUMER_POLLING) {#PAPERLESS_CONSUMER_POLLING}
|
#### [`PAPERLESS_CONSUMER_POLLING=<num>`](#PAPERLESS_CONSUMER_POLLING) {#PAPERLESS_CONSUMER_POLLING}
|
||||||
@@ -1759,11 +1735,6 @@ started by the container.
|
|||||||
|
|
||||||
: Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg`
|
: Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg`
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
The logo file will be viewable by anyone with access to the Paperless instance login page,
|
|
||||||
so consider your choice of logo carefully and removing exif data from images before uploading.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}
|
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@@ -1805,3 +1776,23 @@ password. All of these options come from their similarly-named [Django settings]
|
|||||||
#### [`PAPERLESS_EMAIL_USE_SSL=<bool>`](#PAPERLESS_EMAIL_USE_SSL) {#PAPERLESS_EMAIL_USE_SSL}
|
#### [`PAPERLESS_EMAIL_USE_SSL=<bool>`](#PAPERLESS_EMAIL_USE_SSL) {#PAPERLESS_EMAIL_USE_SSL}
|
||||||
|
|
||||||
: Defaults to false.
|
: Defaults to false.
|
||||||
|
|
||||||
|
## Remote OCR
|
||||||
|
|
||||||
|
#### [`PAPERLESS_REMOTE_OCR_ENGINE=<str>`](#PAPERLESS_REMOTE_OCR_ENGINE) {#PAPERLESS_REMOTE_OCR_ENGINE}
|
||||||
|
|
||||||
|
: The remote OCR engine to use. Currently only Azure AI is supported as "azureai".
|
||||||
|
|
||||||
|
Defaults to None, which disables remote OCR.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_REMOTE_OCR_API_KEY=<str>`](#PAPERLESS_REMOTE_OCR_API_KEY) {#PAPERLESS_REMOTE_OCR_API_KEY}
|
||||||
|
|
||||||
|
: The API key to use for the remote OCR engine.
|
||||||
|
|
||||||
|
Defaults to None.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_REMOTE_OCR_ENDPOINT=<str>`](#PAPERLESS_REMOTE_OCR_ENDPOINT) {#PAPERLESS_REMOTE_OCR_ENDPOINT}
|
||||||
|
|
||||||
|
: The endpoint to use for the remote OCR engine. This is required for Azure AI.
|
||||||
|
|
||||||
|
Defaults to None.
|
||||||
|
|||||||
@@ -470,14 +470,9 @@ To get started:
|
|||||||
|
|
||||||
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
|
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
|
||||||
|
|
||||||
3. In case your host operating system is Windows:
|
3. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
|
||||||
|
|
||||||
- The Source Control view in Visual Studio Code might show: "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user." Use "Manage Unsafe Repositories" to fix this.
|
|
||||||
- Git might have detecteded modifications for all files, because Windows is using CRLF line endings. Run `git checkout .` in the containers terminal to fix this issue.
|
|
||||||
|
|
||||||
4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
|
|
||||||
will initialize the database tables and create a superuser. Then you can compile the front end
|
will initialize the database tables and create a superuser. Then you can compile the front end
|
||||||
for production or run the frontend in debug mode.
|
for production or run the frontend in debug mode.
|
||||||
|
|
||||||
5. The project is ready for debugging, start either run the fullstack debug or individual debug
|
4. The project is ready for debugging, start either run the fullstack debug or individual debug
|
||||||
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
|
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ physical documents into a searchable online archive so you can keep, well, _less
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Organize and index** your scanned documents with tags, correspondents, types, and more.
|
- **Organize and index** your scanned documents with tags, correspondents, types, and more.
|
||||||
- _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way.
|
- _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way, unless you explicitly choose to do so.
|
||||||
- Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images.
|
- Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images.
|
||||||
- Utilizes the open-source Tesseract engine to recognize more than 100 languages.
|
- Utilizes the open-source Tesseract engine to recognize more than 100 languages.
|
||||||
|
- _New!_ Supports remote OCR with Azure AI (opt-in).
|
||||||
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
|
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
|
||||||
- Uses machine-learning to automatically add tags, correspondents and document types to your documents.
|
- Uses machine-learning to automatically add tags, correspondents and document types to your documents.
|
||||||
- Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more.
|
- Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ warns that
|
|||||||
`OCR for XX failed, but we're going to stick with what we've got since FORGIVING_OCR is enabled`,
|
`OCR for XX failed, but we're going to stick with what we've got since FORGIVING_OCR is enabled`,
|
||||||
then you might need to install the [Tesseract language
|
then you might need to install the [Tesseract language
|
||||||
files](https://packages.ubuntu.com/search?keywords=tesseract-ocr)
|
files](https://packages.ubuntu.com/search?keywords=tesseract-ocr)
|
||||||
matching your document's languages.
|
marching your document's languages.
|
||||||
|
|
||||||
As an example, if you are running Paperless-ngx from any Ubuntu or
|
As an example, if you are running Paperless-ngx from any Ubuntu or
|
||||||
Debian box, and your documents are written in Spanish you may need to
|
Debian box, and your documents are written in Spanish you may need to
|
||||||
|
|||||||
124
docs/usage.md
124
docs/usage.md
@@ -92,16 +92,6 @@ and more. These areas allow you to view, add, edit, delete and manage permission
|
|||||||
for these objects. You can also manage saved views, mail accounts, mail rules,
|
for these objects. You can also manage saved views, mail accounts, mail rules,
|
||||||
workflows and more from the management sections.
|
workflows and more from the management sections.
|
||||||
|
|
||||||
### Nested Tags
|
|
||||||
|
|
||||||
Paperless-ngx v2.19 introduces support for nested tags, allowing you to create a
|
|
||||||
hierarchy of tags, which may be useful for organizing your documents. Tags can
|
|
||||||
have a 'parent' tag, creating a tree-like structure, to a maximum depth of 5. When
|
|
||||||
a tag is added to a document, all of its parent tags are also added automatically
|
|
||||||
and similarly, when a tag is removed from a document, all of its child tags are
|
|
||||||
also removed. Additionally, assigning a parent to an existing tag will automatically
|
|
||||||
update all documents that have this tag assigned, adding the parent tag as well.
|
|
||||||
|
|
||||||
## Adding documents to Paperless-ngx
|
## Adding documents to Paperless-ngx
|
||||||
|
|
||||||
Once you've got Paperless setup, you need to start feeding documents
|
Once you've got Paperless setup, you need to start feeding documents
|
||||||
@@ -261,10 +251,6 @@ different means. These are as follows:
|
|||||||
Paperless is set up to check your mails every 10 minutes. This can be
|
Paperless is set up to check your mails every 10 minutes. This can be
|
||||||
configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON)
|
configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON)
|
||||||
|
|
||||||
#### Processed Mail
|
|
||||||
|
|
||||||
Paperless keeps track of emails it has processed in order to avoid processing the same mail multiple times. This uses the message `UID` provided by the mail server, which should be unique for each message. You can view and manage processed mails from the web UI under Mail > Processed Mails. If you need to re-process a message, you can delete the corresponding processed mail entry, which will allow Paperless-ngx to process the email again the next time the mail fetch task runs.
|
|
||||||
|
|
||||||
#### OAuth Email Setup
|
#### OAuth Email Setup
|
||||||
|
|
||||||
Paperless-ngx supports OAuth2 authentication for Gmail and Outlook email accounts. To set up an email account with OAuth2, you will need to create a 'developer' app with the respective provider and obtain the client ID and client secret and set the appropriate [configuration variables](configuration.md#email_oauth). You will also need to set either [`PAPERLESS_OAUTH_CALLBACK_BASE_URL`](configuration.md#PAPERLESS_OAUTH_CALLBACK_BASE_URL) or [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) to the correct value for the OAuth2 flow to work correctly.
|
Paperless-ngx supports OAuth2 authentication for Gmail and Outlook email accounts. To set up an email account with OAuth2, you will need to create a 'developer' app with the respective provider and obtain the client ID and client secret and set the appropriate [configuration variables](configuration.md#email_oauth). You will also need to set either [`PAPERLESS_OAUTH_CALLBACK_BASE_URL`](configuration.md#PAPERLESS_OAUTH_CALLBACK_BASE_URL) or [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) to the correct value for the OAuth2 flow to work correctly.
|
||||||
@@ -414,7 +400,7 @@ fields and permissions, which will be merged.
|
|||||||
|
|
||||||
#### Types {#workflow-trigger-types}
|
#### Types {#workflow-trigger-types}
|
||||||
|
|
||||||
Currently, there are four events that correspond to workflow trigger 'types':
|
Currently, there are three events that correspond to workflow trigger 'types':
|
||||||
|
|
||||||
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
|
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
|
||||||
folder or API), file path, file name, mail rule
|
folder or API), file path, file name, mail rule
|
||||||
@@ -422,12 +408,12 @@ Currently, there are four events that correspond to workflow trigger 'types':
|
|||||||
but the document content has been extracted and metadata such as document type, tags, etc. have been set, so these can now
|
but the document content has been extracted and metadata such as document type, tags, etc. have been set, so these can now
|
||||||
be used for filtering.
|
be used for filtering.
|
||||||
3. **Document Updated**: when a document is updated. Similar to 'added' events, triggers can include filtering by content matching,
|
3. **Document Updated**: when a document is updated. Similar to 'added' events, triggers can include filtering by content matching,
|
||||||
tags, doc type, correspondent or storage path.
|
tags, doc type, or correspondent.
|
||||||
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
|
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
|
||||||
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive
|
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive
|
||||||
offsets will trigger after the date, negative offsets will trigger before).
|
offsets will trigger after the date, negative offsets will trigger before).
|
||||||
|
|
||||||
The following flow diagram illustrates the four document trigger types:
|
The following flow diagram illustrates the three document trigger types:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
@@ -466,11 +452,10 @@ Workflows allow you to filter by:
|
|||||||
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
||||||
example, automatically assigning documents to different owners based on the upload directory.
|
example, automatically assigning documents to different owners based on the upload directory.
|
||||||
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
||||||
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
|
- Content matching (`Added` and `Updated` triggers only). Filter document content using the matching settings.
|
||||||
- Tags (`Added`, `Updated` and `Scheduled` triggers only). Filter for documents with any of the specified tags
|
- Tags (`Added` and `Updated` triggers only). Filter for documents with any of the specified tags
|
||||||
- Document type (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this doc type
|
- Document type (`Added` and `Updated` triggers only). Filter documents with this doc type
|
||||||
- Correspondent (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this correspondent
|
- Correspondent (`Added` and `Updated` triggers only). Filter documents with this correspondent
|
||||||
- Storage path (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this storage path
|
|
||||||
|
|
||||||
### Workflow Actions
|
### Workflow Actions
|
||||||
|
|
||||||
@@ -514,58 +499,37 @@ The following workflow action types are available:
|
|||||||
- Encoding for the request body, either JSON or form data
|
- Encoding for the request body, either JSON or form data
|
||||||
- The request headers as key-value pairs
|
- The request headers as key-value pairs
|
||||||
|
|
||||||
For security reasons, webhooks can be limited to specific ports and disallowed from connecting to local URLs. See the relevant
|
|
||||||
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows,
|
|
||||||
you may want to adjust these settings to prevent abuse.
|
|
||||||
|
|
||||||
#### Workflow placeholders
|
#### Workflow placeholders
|
||||||
|
|
||||||
Titles can be assigned by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
Some workflow text can include placeholders but the available options differ depending on the type of
|
||||||
This allows for complex logic to be used to generate the title, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
|
workflow trigger. This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
|
||||||
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
|
applied. You can use the following placeholders with any trigger type:
|
||||||
The template is provided as a string.
|
|
||||||
|
|
||||||
Using Jinja2 Templates is also useful for [Date localization](advanced_usage.md#Date-Localization) in the title.
|
- `{correspondent}`: assigned correspondent name
|
||||||
|
- `{document_type}`: assigned document type name
|
||||||
The available inputs differ depending on the type of workflow trigger.
|
- `{owner_username}`: assigned owner username
|
||||||
This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
|
- `{added}`: added datetime
|
||||||
applied. You can use the following placeholders in the template with any trigger type:
|
- `{added_year}`: added year
|
||||||
|
- `{added_year_short}`: added year
|
||||||
- `{{correspondent}}`: assigned correspondent name
|
- `{added_month}`: added month
|
||||||
- `{{document_type}}`: assigned document type name
|
- `{added_month_name}`: added month name
|
||||||
- `{{owner_username}}`: assigned owner username
|
- `{added_month_name_short}`: added month short name
|
||||||
- `{{added}}`: added datetime
|
- `{added_day}`: added day
|
||||||
- `{{added_year}}`: added year
|
- `{added_time}`: added time in HH:MM format
|
||||||
- `{{added_year_short}}`: added year
|
- `{original_filename}`: original file name without extension
|
||||||
- `{{added_month}}`: added month
|
- `{filename}`: current file name without extension
|
||||||
- `{{added_month_name}}`: added month name
|
|
||||||
- `{{added_month_name_short}}`: added month short name
|
|
||||||
- `{{added_day}}`: added day
|
|
||||||
- `{{added_time}}`: added time in HH:MM format
|
|
||||||
- `{{original_filename}}`: original file name without extension
|
|
||||||
- `{{filename}}`: current file name without extension
|
|
||||||
|
|
||||||
The following placeholders are only available for "added" or "updated" triggers
|
The following placeholders are only available for "added" or "updated" triggers
|
||||||
|
|
||||||
- `{{created}}`: created datetime
|
- `{created}`: created datetime
|
||||||
- `{{created_year}}`: created year
|
- `{created_year}`: created year
|
||||||
- `{{created_year_short}}`: created year
|
- `{created_year_short}`: created year
|
||||||
- `{{created_month}}`: created month
|
- `{created_month}`: created month
|
||||||
- `{{created_month_name}}`: created month name
|
- `{created_month_name}`: created month name
|
||||||
- `{created_month_name_short}}`: created month short name
|
- `{created_month_name_short}`: created month short name
|
||||||
- `{{created_day}}`: created day
|
- `{created_day}`: created day
|
||||||
- `{{created_time}}`: created time in HH:MM format
|
- `{created_time}`: created time in HH:MM format
|
||||||
- `{{doc_url}}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set.
|
- `{doc_url}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set.
|
||||||
|
|
||||||
##### Examples
|
|
||||||
|
|
||||||
```jinja2
|
|
||||||
{{ created | localize_date('MMMM', 'en_US') }}
|
|
||||||
<!-- Output: "January" -->
|
|
||||||
|
|
||||||
{{ added | localize_date('MMMM', 'de_DE') }}
|
|
||||||
<!-- Output: "Juni" --> # codespell:ignore
|
|
||||||
```
|
|
||||||
|
|
||||||
### Workflow permissions
|
### Workflow permissions
|
||||||
|
|
||||||
@@ -612,14 +576,12 @@ The following custom field types are supported:
|
|||||||
|
|
||||||
## PDF Actions
|
## PDF Actions
|
||||||
|
|
||||||
Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
|
Paperless-ngx supports four basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files):
|
||||||
open the 'PDF Editor' to use a simple UI for re-arranging, rotating, deleting pages and splitting documents.
|
|
||||||
|
|
||||||
- Merging documents: available when selecting multiple documents for 'bulk editing'.
|
- Merging documents: available when selecting multiple documents for 'bulk editing'.
|
||||||
- Rotating documents: available when selecting multiple documents for 'bulk editing' and via the pdf editor on an individual document's details page.
|
- Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page.
|
||||||
- Splitting documents: via the pdf editor on an individual document's details page.
|
- Splitting documents: available from an individual document's details page.
|
||||||
- Deleting pages: via the pdf editor on an individual document's details page.
|
- Deleting pages: available from an individual document's details page.
|
||||||
- Re-arranging pages: via the pdf editor on an individual document's details page.
|
|
||||||
|
|
||||||
!!! important
|
!!! important
|
||||||
|
|
||||||
@@ -637,7 +599,7 @@ When you first delete a document it is moved to the 'trash' until either it is e
|
|||||||
You can set how long documents remain in the trash before being automatically deleted with [`PAPERLESS_EMPTY_TRASH_DELAY`](configuration.md#PAPERLESS_EMPTY_TRASH_DELAY), which defaults
|
You can set how long documents remain in the trash before being automatically deleted with [`PAPERLESS_EMPTY_TRASH_DELAY`](configuration.md#PAPERLESS_EMPTY_TRASH_DELAY), which defaults
|
||||||
to 30 days. Until the file is actually deleted (e.g. the trash is emptied), all files and database content remains intact and can be restored at any point up until that time.
|
to 30 days. Until the file is actually deleted (e.g. the trash is emptied), all files and database content remains intact and can be restored at any point up until that time.
|
||||||
|
|
||||||
Additionally you may configure a directory where deleted files are moved to when the trash is emptied with [`PAPERLESS_EMPTY_TRASH_DIR`](configuration.md#PAPERLESS_EMPTY_TRASH_DIR).
|
Additionally you may configure a directory where deleted files are moved to when they the trash is emptied with [`PAPERLESS_EMPTY_TRASH_DIR`](configuration.md#PAPERLESS_EMPTY_TRASH_DIR).
|
||||||
Note that the empty trash directory only stores the original file, the archive file and all database information is permanently removed once a document is fully deleted.
|
Note that the empty trash directory only stores the original file, the archive file and all database information is permanently removed once a document is fully deleted.
|
||||||
|
|
||||||
## Best practices {#basic-searching}
|
## Best practices {#basic-searching}
|
||||||
@@ -882,6 +844,18 @@ how regularly you intend to scan documents and use paperless.
|
|||||||
performed the task associated with the document, move it to the
|
performed the task associated with the document, move it to the
|
||||||
inbox.
|
inbox.
|
||||||
|
|
||||||
|
## Remote OCR
|
||||||
|
|
||||||
|
!!! important
|
||||||
|
|
||||||
|
This feature is disabled by default and will always remain strictly "opt-in".
|
||||||
|
|
||||||
|
Paperless-ngx supports performing OCR on documents using remote services. At the moment, this is limited to
|
||||||
|
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
|
||||||
|
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
|
||||||
|
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
|
||||||
|
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Paperless-ngx consists of the following components:
|
Paperless-ngx consists of the following components:
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ markdown_extensions:
|
|||||||
- pymdownx.superfences
|
- pymdownx.superfences
|
||||||
- pymdownx.inlinehilite
|
- pymdownx.inlinehilite
|
||||||
- pymdownx.snippets
|
- pymdownx.snippets
|
||||||
- pymdownx.tilde
|
|
||||||
- footnotes
|
- footnotes
|
||||||
- pymdownx.superfences:
|
- pymdownx.superfences:
|
||||||
custom_fences:
|
custom_fences:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "2.18.4"
|
version = "2.17.1"
|
||||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
description = "A community-supported supercharged version of paperless: scan, index and archive all your physical documents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@@ -15,7 +15,7 @@ classifiers = [
|
|||||||
# This will allow testing to not install a webserver, mysql, etc
|
# This will allow testing to not install a webserver, mysql, etc
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"babel>=2.17",
|
"azure-ai-documentintelligence>=1.0.2",
|
||||||
"bleach~=6.2.0",
|
"bleach~=6.2.0",
|
||||||
"celery[redis]~=5.5.1",
|
"celery[redis]~=5.5.1",
|
||||||
"channels~=4.2",
|
"channels~=4.2",
|
||||||
@@ -24,43 +24,43 @@ dependencies = [
|
|||||||
"dateparser~=1.2",
|
"dateparser~=1.2",
|
||||||
# WARNING: django does not use semver.
|
# WARNING: django does not use semver.
|
||||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||||
"django~=5.2.5",
|
"django~=5.1.7",
|
||||||
"django-allauth[socialaccount,mfa]~=65.4.0",
|
"django-allauth[socialaccount,mfa]~=65.4.0",
|
||||||
"django-auditlog~=3.2.1",
|
"django-auditlog~=3.1.2",
|
||||||
"django-cachalot~=2.8.0",
|
"django-cachalot~=2.8.0",
|
||||||
"django-celery-results~=2.6.0",
|
"django-celery-results~=2.6.0",
|
||||||
"django-compression-middleware~=0.5.0",
|
"django-compression-middleware~=0.5.0",
|
||||||
"django-cors-headers~=4.9.0",
|
"django-cors-headers~=4.7.0",
|
||||||
"django-extensions~=4.1",
|
"django-extensions~=4.1",
|
||||||
"django-filter~=25.1",
|
"django-filter~=25.1",
|
||||||
"django-guardian~=3.2.0",
|
"django-guardian~=2.4.0",
|
||||||
"django-multiselectfield~=1.0.1",
|
"django-multiselectfield~=0.1.13",
|
||||||
"django-soft-delete~=1.0.18",
|
"django-soft-delete~=1.0.18",
|
||||||
"django-treenode>=0.23.2",
|
"djangorestframework~=3.15",
|
||||||
"djangorestframework~=3.16",
|
"djangorestframework-guardian~=0.3.0",
|
||||||
"djangorestframework-guardian~=0.4.0",
|
|
||||||
"drf-spectacular~=0.28",
|
"drf-spectacular~=0.28",
|
||||||
"drf-spectacular-sidecar~=2025.9.1",
|
"drf-spectacular-sidecar~=2025.4.1",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock~=3.19.1",
|
"filelock~=3.18.0",
|
||||||
"flower~=2.0.1",
|
"flower~=2.0.1",
|
||||||
"gotenberg-client~=0.11.0",
|
"gotenberg-client~=0.10.0",
|
||||||
"httpx-oauth~=0.16",
|
"httpx-oauth~=0.16",
|
||||||
"imap-tools~=1.11.0",
|
"imap-tools~=1.11.0",
|
||||||
"inotifyrecursive~=0.3",
|
"inotifyrecursive~=0.3",
|
||||||
"jinja2~=3.1.5",
|
"jinja2~=3.1.5",
|
||||||
"langdetect~=1.0.9",
|
"langdetect~=1.0.9",
|
||||||
"nltk~=3.9.1",
|
"nltk~=3.9.1",
|
||||||
"ocrmypdf~=16.11.0",
|
"ocrmypdf~=16.10.0",
|
||||||
"pathvalidate~=3.3.1",
|
"pathvalidate~=3.3.1",
|
||||||
"pdf2image~=1.17.0",
|
"pdf2image~=1.17.0",
|
||||||
|
"psycopg-pool",
|
||||||
"python-dateutil~=2.9.0",
|
"python-dateutil~=2.9.0",
|
||||||
"python-dotenv~=1.1.0",
|
"python-dotenv~=1.1.0",
|
||||||
"python-gnupg~=0.5.4",
|
"python-gnupg~=0.5.4",
|
||||||
"python-ipware~=3.0.0",
|
"python-ipware~=3.0.0",
|
||||||
"python-magic~=0.4.27",
|
"python-magic~=0.4.27",
|
||||||
"pyzbar~=0.1.9",
|
"pyzbar~=0.1.9",
|
||||||
"rapidfuzz~=3.14.0",
|
"rapidfuzz~=3.13.0",
|
||||||
"redis[hiredis]~=5.2.1",
|
"redis[hiredis]~=5.2.1",
|
||||||
"scikit-learn~=1.7.0",
|
"scikit-learn~=1.7.0",
|
||||||
"setproctitle~=1.3.4",
|
"setproctitle~=1.3.4",
|
||||||
@@ -82,7 +82,7 @@ optional-dependencies.postgres = [
|
|||||||
"psycopg-pool==3.2.6",
|
"psycopg-pool==3.2.6",
|
||||||
]
|
]
|
||||||
optional-dependencies.webserver = [
|
optional-dependencies.webserver = [
|
||||||
"granian[uvloop]~=2.5.1",
|
"granian[uvloop]~=2.4.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -94,7 +94,7 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
docs = [
|
docs = [
|
||||||
"mkdocs-glightbox~=0.5.1",
|
"mkdocs-glightbox~=0.4.0",
|
||||||
"mkdocs-material~=9.6.4",
|
"mkdocs-material~=9.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -103,8 +103,8 @@ testing = [
|
|||||||
"factory-boy~=3.3.1",
|
"factory-boy~=3.3.1",
|
||||||
"imagehash",
|
"imagehash",
|
||||||
"pytest~=8.4.1",
|
"pytest~=8.4.1",
|
||||||
"pytest-cov~=7.0.0",
|
"pytest-cov~=6.2.1",
|
||||||
"pytest-django~=4.11.1",
|
"pytest-django~=4.10.0",
|
||||||
"pytest-env",
|
"pytest-env",
|
||||||
"pytest-httpx",
|
"pytest-httpx",
|
||||||
"pytest-mock",
|
"pytest-mock",
|
||||||
@@ -114,9 +114,9 @@ testing = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
lint = [
|
lint = [
|
||||||
"pre-commit~=4.3.0",
|
"pre-commit~=4.2.0",
|
||||||
"pre-commit-uv~=4.1.3",
|
"pre-commit-uv~=4.1.3",
|
||||||
"ruff~=0.13.0",
|
"ruff~=0.12.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
typing = [
|
typing = [
|
||||||
@@ -124,7 +124,6 @@ typing = [
|
|||||||
"django-filter-stubs",
|
"django-filter-stubs",
|
||||||
"django-stubs[compatible-mypy]",
|
"django-stubs[compatible-mypy]",
|
||||||
"djangorestframework-stubs[compatible-mypy]",
|
"djangorestframework-stubs[compatible-mypy]",
|
||||||
"lxml-stubs",
|
|
||||||
"mypy",
|
"mypy",
|
||||||
"types-bleach",
|
"types-bleach",
|
||||||
"types-colorama",
|
"types-colorama",
|
||||||
@@ -132,7 +131,6 @@ typing = [
|
|||||||
"types-markdown",
|
"types-markdown",
|
||||||
"types-pygments",
|
"types-pygments",
|
||||||
"types-python-dateutil",
|
"types-python-dateutil",
|
||||||
"types-pytz",
|
|
||||||
"types-redis",
|
"types-redis",
|
||||||
"types-setuptools",
|
"types-setuptools",
|
||||||
"types-tqdm",
|
"types-tqdm",
|
||||||
@@ -207,19 +205,23 @@ lint.per-file-ignores."docker/wait-for-redis.py" = [
|
|||||||
"INP001",
|
"INP001",
|
||||||
"T201",
|
"T201",
|
||||||
]
|
]
|
||||||
|
lint.per-file-ignores."src/documents/management/commands/document_consumer.py" = [
|
||||||
|
"PTH",
|
||||||
|
] # TODO Enable & remove
|
||||||
|
lint.per-file-ignores."src/documents/migrations/1012_fix_archive_files.py" = [
|
||||||
|
"PTH",
|
||||||
|
] # TODO Enable & remove
|
||||||
lint.per-file-ignores."src/documents/models.py" = [
|
lint.per-file-ignores."src/documents/models.py" = [
|
||||||
"SIM115",
|
"SIM115",
|
||||||
]
|
]
|
||||||
|
lint.per-file-ignores."src/documents/parsers.py" = [
|
||||||
|
"PTH",
|
||||||
|
] # TODO Enable & remove
|
||||||
lint.per-file-ignores."src/paperless_tesseract/tests/test_parser.py" = [
|
lint.per-file-ignores."src/paperless_tesseract/tests/test_parser.py" = [
|
||||||
"RUF001",
|
"RUF001",
|
||||||
]
|
]
|
||||||
lint.isort.force-single-line = true
|
lint.isort.force-single-line = true
|
||||||
|
|
||||||
[tool.codespell]
|
|
||||||
write-changes = true
|
|
||||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober"
|
|
||||||
skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json"
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
minversion = "8.0"
|
minversion = "8.0"
|
||||||
pythonpath = [
|
pythonpath = [
|
||||||
@@ -232,6 +234,7 @@ testpaths = [
|
|||||||
"src/paperless_tesseract/tests/",
|
"src/paperless_tesseract/tests/",
|
||||||
"src/paperless_tika/tests",
|
"src/paperless_tika/tests",
|
||||||
"src/paperless_text/tests/",
|
"src/paperless_text/tests/",
|
||||||
|
"src/paperless_remote/tests/",
|
||||||
]
|
]
|
||||||
addopts = [
|
addopts = [
|
||||||
"--pythonwarnings=all",
|
"--pythonwarnings=all",
|
||||||
@@ -272,10 +275,10 @@ exclude_also = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
mypy_path = "src"
|
|
||||||
plugins = [
|
plugins = [
|
||||||
"mypy_django_plugin.main",
|
"mypy_django_plugin.main",
|
||||||
"mypy_drf_plugin.main",
|
"mypy_drf_plugin.main",
|
||||||
|
"numpy.typing.mypy_plugin",
|
||||||
]
|
]
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
disallow_any_generics = true
|
disallow_any_generics = true
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ test('bulk edit', async ({ page }) => {
|
|||||||
await expect(page.locator('pngx-document-list')).toHaveText(
|
await expect(page.locator('pngx-document-list')).toHaveText(
|
||||||
/Selected 61 of 61 documents/i
|
/Selected 61 of 61 documents/i
|
||||||
)
|
)
|
||||||
await page.getByRole('button', { name: 'None' }).click()
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
|
|
||||||
await page.locator('pngx-document-card-small').nth(1).click()
|
await page.locator('pngx-document-card-small').nth(1).click()
|
||||||
await page.locator('pngx-document-card-small').nth(2).click()
|
await page.locator('pngx-document-card-small').nth(2).click()
|
||||||
|
|||||||
1490
src-ui/messages.xlf
1490
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "2.18.4",
|
"version": "2.17.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
@@ -11,66 +11,65 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^20.2.6",
|
"@angular/cdk": "^20.1.4",
|
||||||
"@angular/common": "~20.3.2",
|
"@angular/common": "~20.1.4",
|
||||||
"@angular/compiler": "~20.3.2",
|
"@angular/compiler": "~20.1.4",
|
||||||
"@angular/core": "~20.3.2",
|
"@angular/core": "~20.1.4",
|
||||||
"@angular/forms": "~20.3.2",
|
"@angular/forms": "~20.1.4",
|
||||||
"@angular/localize": "~20.3.2",
|
"@angular/localize": "~20.1.4",
|
||||||
"@angular/platform-browser": "~20.3.2",
|
"@angular/platform-browser": "~20.1.4",
|
||||||
"@angular/platform-browser-dynamic": "~20.3.2",
|
"@angular/platform-browser-dynamic": "~20.1.4",
|
||||||
"@angular/router": "~20.3.2",
|
"@angular/router": "~20.1.4",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
||||||
"@ng-select/ng-select": "^20.2.2",
|
"@ng-select/ng-select": "^20.0.1",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.7",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ng2-pdf-viewer": "^10.4.0",
|
"ng2-pdf-viewer": "^10.4.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^10.1.0",
|
"ngx-color": "^10.0.0",
|
||||||
"ngx-cookie-service": "^20.1.0",
|
"ngx-cookie-service": "^20.0.1",
|
||||||
"ngx-device-detector": "^10.1.0",
|
"ngx-device-detector": "^10.0.2",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
|
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^11.1.0",
|
||||||
"zone.js": "^0.15.1"
|
"zone.js": "^0.15.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "^20.0.0",
|
"@angular-builders/custom-webpack": "^20.0.0",
|
||||||
"@angular-builders/jest": "^20.0.0",
|
"@angular-builders/jest": "^20.0.0",
|
||||||
"@angular-devkit/core": "^20.3.3",
|
"@angular-devkit/core": "^20.1.4",
|
||||||
"@angular-devkit/schematics": "^20.3.3",
|
"@angular-devkit/schematics": "^20.1.4",
|
||||||
"@angular-eslint/builder": "20.3.0",
|
"@angular-eslint/builder": "20.1.1",
|
||||||
"@angular-eslint/eslint-plugin": "20.3.0",
|
"@angular-eslint/eslint-plugin": "20.1.1",
|
||||||
"@angular-eslint/eslint-plugin-template": "20.3.0",
|
"@angular-eslint/eslint-plugin-template": "20.1.1",
|
||||||
"@angular-eslint/schematics": "20.3.0",
|
"@angular-eslint/schematics": "20.1.1",
|
||||||
"@angular-eslint/template-parser": "20.3.0",
|
"@angular-eslint/template-parser": "20.1.1",
|
||||||
"@angular/build": "^20.3.3",
|
"@angular/build": "^20.1.4",
|
||||||
"@angular/cli": "~20.3.3",
|
"@angular/cli": "~20.1.4",
|
||||||
"@angular/compiler-cli": "~20.3.2",
|
"@angular/compiler-cli": "~20.1.4",
|
||||||
"@codecov/webpack-plugin": "^1.9.1",
|
"@codecov/webpack-plugin": "^1.9.1",
|
||||||
"@playwright/test": "^1.55.1",
|
"@playwright/test": "^1.54.2",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.6.1",
|
"@types/node": "^24.1.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.45.0",
|
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||||
"@typescript-eslint/parser": "^8.45.0",
|
"@typescript-eslint/parser": "^8.38.0",
|
||||||
"@typescript-eslint/utils": "^8.45.0",
|
"@typescript-eslint/utils": "^8.38.0",
|
||||||
"eslint": "^9.36.0",
|
"eslint": "^9.32.0",
|
||||||
"jest": "30.2.0",
|
"jest": "30.0.5",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.0.5",
|
||||||
"jest-junit": "^16.0.0",
|
"jest-junit": "^16.0.0",
|
||||||
"jest-preset-angular": "^15.0.2",
|
"jest-preset-angular": "^15.0.0",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.2.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"webpack": "^5.102.0"
|
"webpack": "^5.101.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.17.1",
|
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
|
|||||||
4577
src-ui/pnpm-lock.yaml
generated
4577
src-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -121,38 +121,8 @@ if (!URL.revokeObjectURL) {
|
|||||||
}
|
}
|
||||||
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
|
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
|
||||||
|
|
||||||
if (typeof IntersectionObserver === 'undefined') {
|
|
||||||
class MockIntersectionObserver {
|
|
||||||
constructor(
|
|
||||||
public callback: IntersectionObserverCallback,
|
|
||||||
public options?: IntersectionObserverInit
|
|
||||||
) {}
|
|
||||||
|
|
||||||
observe = jest.fn()
|
|
||||||
unobserve = jest.fn()
|
|
||||||
disconnect = jest.fn()
|
|
||||||
takeRecords = jest.fn()
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.defineProperty(window, 'IntersectionObserver', {
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
value: MockIntersectionObserver,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
HTMLCanvasElement.prototype.getContext = <
|
HTMLCanvasElement.prototype.getContext = <
|
||||||
typeof HTMLCanvasElement.prototype.getContext
|
typeof HTMLCanvasElement.prototype.getContext
|
||||||
>jest.fn()
|
>jest.fn()
|
||||||
|
|
||||||
jest.mock('uuid', () => ({
|
|
||||||
v4: jest.fn(() =>
|
|
||||||
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char: string) => {
|
|
||||||
const random = Math.floor(Math.random() * 16)
|
|
||||||
const value = char === 'x' ? random : (random & 0x3) | 0x8
|
|
||||||
return value.toString(16)
|
|
||||||
})
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
jest.mock('pdfjs-dist')
|
jest.mock('pdfjs-dist')
|
||||||
|
|||||||
@@ -61,40 +61,6 @@ const groups = [
|
|||||||
{ id: 2, name: 'group2' },
|
{ id: 2, name: 'group2' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const status: SystemStatus = {
|
|
||||||
pngx_version: '2.4.3',
|
|
||||||
server_os: 'macOS-14.1.1-arm64-arm-64bit',
|
|
||||||
install_type: InstallType.BareMetal,
|
|
||||||
storage: { total: 494384795648, available: 13573525504 },
|
|
||||||
database: {
|
|
||||||
type: 'sqlite',
|
|
||||||
url: '/paperless-ngx/data/db.sqlite3',
|
|
||||||
status: SystemStatusItemStatus.ERROR,
|
|
||||||
error: null,
|
|
||||||
migration_status: {
|
|
||||||
latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data',
|
|
||||||
unapplied_migrations: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tasks: {
|
|
||||||
redis_url: 'redis://localhost:6379',
|
|
||||||
redis_status: SystemStatusItemStatus.ERROR,
|
|
||||||
redis_error: 'Error 61 connecting to localhost:6379. Connection refused.',
|
|
||||||
celery_status: SystemStatusItemStatus.ERROR,
|
|
||||||
celery_url: 'celery@localhost',
|
|
||||||
celery_error: 'Error connecting to celery@localhost',
|
|
||||||
index_status: SystemStatusItemStatus.OK,
|
|
||||||
index_last_modified: new Date().toISOString(),
|
|
||||||
index_error: null,
|
|
||||||
classifier_status: SystemStatusItemStatus.OK,
|
|
||||||
classifier_last_trained: new Date().toISOString(),
|
|
||||||
classifier_error: null,
|
|
||||||
sanity_check_status: SystemStatusItemStatus.ERROR,
|
|
||||||
sanity_check_last_run: new Date().toISOString(),
|
|
||||||
sanity_check_error: 'Error running sanity check.',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('SettingsComponent', () => {
|
describe('SettingsComponent', () => {
|
||||||
let component: SettingsComponent
|
let component: SettingsComponent
|
||||||
let fixture: ComponentFixture<SettingsComponent>
|
let fixture: ComponentFixture<SettingsComponent>
|
||||||
@@ -324,6 +290,40 @@ describe('SettingsComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should load system status on initialize, show errors if needed', () => {
|
it('should load system status on initialize, show errors if needed', () => {
|
||||||
|
const status: SystemStatus = {
|
||||||
|
pngx_version: '2.4.3',
|
||||||
|
server_os: 'macOS-14.1.1-arm64-arm-64bit',
|
||||||
|
install_type: InstallType.BareMetal,
|
||||||
|
storage: { total: 494384795648, available: 13573525504 },
|
||||||
|
database: {
|
||||||
|
type: 'sqlite',
|
||||||
|
url: '/paperless-ngx/data/db.sqlite3',
|
||||||
|
status: SystemStatusItemStatus.ERROR,
|
||||||
|
error: null,
|
||||||
|
migration_status: {
|
||||||
|
latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data',
|
||||||
|
unapplied_migrations: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
redis_url: 'redis://localhost:6379',
|
||||||
|
redis_status: SystemStatusItemStatus.ERROR,
|
||||||
|
redis_error:
|
||||||
|
'Error 61 connecting to localhost:6379. Connection refused.',
|
||||||
|
celery_status: SystemStatusItemStatus.ERROR,
|
||||||
|
celery_url: 'celery@localhost',
|
||||||
|
celery_error: 'Error connecting to celery@localhost',
|
||||||
|
index_status: SystemStatusItemStatus.OK,
|
||||||
|
index_last_modified: new Date().toISOString(),
|
||||||
|
index_error: null,
|
||||||
|
classifier_status: SystemStatusItemStatus.OK,
|
||||||
|
classifier_last_trained: new Date().toISOString(),
|
||||||
|
classifier_error: null,
|
||||||
|
sanity_check_status: SystemStatusItemStatus.ERROR,
|
||||||
|
sanity_check_last_run: new Date().toISOString(),
|
||||||
|
sanity_check_error: 'Error running sanity check.',
|
||||||
|
},
|
||||||
|
}
|
||||||
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
||||||
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
|
||||||
completeSetup()
|
completeSetup()
|
||||||
@@ -340,8 +340,6 @@ describe('SettingsComponent', () => {
|
|||||||
|
|
||||||
it('should open system status dialog', () => {
|
it('should open system status dialog', () => {
|
||||||
const modalOpenSpy = jest.spyOn(modalService, 'open')
|
const modalOpenSpy = jest.spyOn(modalService, 'open')
|
||||||
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
|
||||||
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
|
|
||||||
completeSetup()
|
completeSetup()
|
||||||
component.showSystemStatus()
|
component.showSystemStatus()
|
||||||
expect(modalOpenSpy).toHaveBeenCalledWith(SystemStatusDialogComponent, {
|
expect(modalOpenSpy).toHaveBeenCalledWith(SystemStatusDialogComponent, {
|
||||||
|
|||||||
@@ -185,8 +185,7 @@ export class SettingsComponent
|
|||||||
this.systemStatus.tasks.classifier_status ===
|
this.systemStatus.tasks.classifier_status ===
|
||||||
SystemStatusItemStatus.ERROR ||
|
SystemStatusItemStatus.ERROR ||
|
||||||
this.systemStatus.tasks.sanity_check_status ===
|
this.systemStatus.tasks.sanity_check_status ===
|
||||||
SystemStatusItemStatus.ERROR ||
|
SystemStatusItemStatus.ERROR
|
||||||
this.systemStatus.websocket_connected === SystemStatusItemStatus.ERROR
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
NgbNavItem,
|
NgbNavItem,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { throwError } from 'rxjs'
|
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import {
|
import {
|
||||||
PaperlessTask,
|
PaperlessTask,
|
||||||
@@ -29,7 +28,6 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
|||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
@@ -125,7 +123,6 @@ describe('TasksComponent', () => {
|
|||||||
let router: Router
|
let router: Router
|
||||||
let httpTestingController: HttpTestingController
|
let httpTestingController: HttpTestingController
|
||||||
let reloadSpy
|
let reloadSpy
|
||||||
let toastService: ToastService
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -160,7 +157,6 @@ describe('TasksComponent', () => {
|
|||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
toastService = TestBed.inject(ToastService)
|
|
||||||
fixture = TestBed.createComponent(TasksComponent)
|
fixture = TestBed.createComponent(TasksComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
@@ -253,42 +249,6 @@ describe('TasksComponent', () => {
|
|||||||
expect(dismissSpy).toHaveBeenCalledWith(selected)
|
expect(dismissSpy).toHaveBeenCalledWith(selected)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show an error and re-enable modal buttons when dismissing multiple tasks fails', () => {
|
|
||||||
component.selectedTasks = new Set([tasks[0].id, tasks[1].id])
|
|
||||||
const error = new Error('dismiss failed')
|
|
||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
|
||||||
const dismissSpy = jest
|
|
||||||
.spyOn(tasksService, 'dismissTasks')
|
|
||||||
.mockReturnValue(throwError(() => error))
|
|
||||||
|
|
||||||
let modal: NgbModalRef
|
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
|
||||||
|
|
||||||
component.dismissTasks()
|
|
||||||
expect(modal).not.toBeUndefined()
|
|
||||||
|
|
||||||
modal.componentInstance.confirmClicked.emit()
|
|
||||||
|
|
||||||
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(component.selectedTasks.size).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show an error when dismissing a single task fails', () => {
|
|
||||||
const error = new Error('dismiss failed')
|
|
||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
|
||||||
const dismissSpy = jest
|
|
||||||
.spyOn(tasksService, 'dismissTasks')
|
|
||||||
.mockReturnValue(throwError(() => error))
|
|
||||||
|
|
||||||
component.dismissTask(tasks[0])
|
|
||||||
|
|
||||||
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id]))
|
|
||||||
expect(toastSpy).toHaveBeenCalledWith('Error dismissing task', error)
|
|
||||||
expect(component.selectedTasks.size).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support dismiss all tasks', () => {
|
it('should support dismiss all tasks', () => {
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { PaperlessTask } from 'src/app/data/paperless-task'
|
|||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
@@ -73,7 +72,6 @@ export class TasksComponent
|
|||||||
tasksService = inject(TasksService)
|
tasksService = inject(TasksService)
|
||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
private readonly router = inject(Router)
|
private readonly router = inject(Router)
|
||||||
private readonly toastService = inject(ToastService)
|
|
||||||
|
|
||||||
public activeTab: TaskTab
|
public activeTab: TaskTab
|
||||||
public selectedTasks: Set<number> = new Set()
|
public selectedTasks: Set<number> = new Set()
|
||||||
@@ -156,19 +154,11 @@ export class TasksComponent
|
|||||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
modal.close()
|
modal.close()
|
||||||
this.tasksService.dismissTasks(tasks).subscribe({
|
this.tasksService.dismissTasks(tasks)
|
||||||
error: (e) => {
|
|
||||||
this.toastService.showError($localize`Error dismissing tasks`, e)
|
|
||||||
modal.componentInstance.buttonsEnabled = true
|
|
||||||
},
|
|
||||||
})
|
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.tasksService.dismissTasks(tasks).subscribe({
|
this.tasksService.dismissTasks(tasks)
|
||||||
error: (e) =>
|
|
||||||
this.toastService.showError($localize`Error dismissing task`, e),
|
|
||||||
})
|
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,16 +108,15 @@
|
|||||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
|
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
|
||||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||||
(cdkDragEnded)="onDragEnd($event)">
|
(cdkDragEnded)="onDragEnd($event)">
|
||||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}"
|
||||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
||||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
popoverClass="popover-slim">
|
popoverClass="popover-slim">
|
||||||
<i-bs class="me-1" name="funnel"></i-bs>
|
<i-bs class="me-1" name="funnel"></i-bs><span> {{view.name}}
|
||||||
<span> <div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
|
@if (showSidebarCounts && !slimSidebarEnabled) {
|
||||||
@if (showSidebarCounts && !slimSidebarEnabled) {
|
<span><span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span></span>
|
||||||
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
|
}
|
||||||
}
|
</span>
|
||||||
</span>
|
|
||||||
@if (showSidebarCounts && slimSidebarEnabled) {
|
@if (showSidebarCounts && slimSidebarEnabled) {
|
||||||
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
|
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
|
||||||
}
|
}
|
||||||
@@ -147,7 +146,7 @@
|
|||||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
popoverClass="popover-slim">
|
popoverClass="popover-slim">
|
||||||
<i-bs class="me-1" name="file-text"></i-bs><span> {{d.title | documentTitle}}</span>
|
<i-bs class="me-1" name="file-text"></i-bs><span> {{d.title | documentTitle}}</span>
|
||||||
<span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()">
|
<span class="close" (click)="closeDocument(d); $event.preventDefault()">
|
||||||
<i-bs name="x"></i-bs>
|
<i-bs name="x"></i-bs>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -19,10 +19,6 @@
|
|||||||
height: 0.8em;
|
height: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-name {
|
|
||||||
max-width: calc(100% - 50px)
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-group:not(:has(.app-link)) .sidebar-heading {
|
.nav-group:not(:has(.app-link)) .sidebar-heading {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -191,7 +187,7 @@ main {
|
|||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
|
||||||
&:hover .close {
|
&:hover .close {
|
||||||
display: flex;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
.close {
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get versionString(): string {
|
get versionString(): string {
|
||||||
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.production ? '' : ` #${environment.tag}`}`
|
||||||
}
|
}
|
||||||
|
|
||||||
get customAppTitle(): string {
|
get customAppTitle(): string {
|
||||||
@@ -287,9 +287,6 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get showSidebarCounts(): boolean {
|
get showSidebarCounts(): boolean {
|
||||||
return (
|
return this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT)
|
||||||
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
|
|
||||||
!this.settingsService.organizingSidebarSavedViews
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<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">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="btn-toolbar flex-nowrap">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<div class="input-group-text" i18n>Page</div>
|
||||||
|
<input class="form-control mw-60" type="number" min="1" [(ngModel)]="currentPage" />
|
||||||
|
<div class="input-group-text" i18n>of {{totalPages}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-group input-group-sm ms-auto">
|
||||||
|
<span class="input-group-text" i18n>Pages to remove</span>
|
||||||
|
<input [ngModel]="pagesString" class="form-control" disabled />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-viewer-container w-100 mt-3">
|
||||||
|
<pdf-viewer #pdfViewer [src]="pdfSrc" [(page)]="currentPage"
|
||||||
|
[original-size]="false"
|
||||||
|
[zoom]="1"
|
||||||
|
zoom-scale="page-fit"
|
||||||
|
[render-text]="false"
|
||||||
|
(pagerendered)="pageRendered($event)"
|
||||||
|
(after-load-complete)="pdfPreviewLoaded($event)">
|
||||||
|
</pdf-viewer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer flex-nowrap">
|
||||||
|
<div>
|
||||||
|
@if (message) {
|
||||||
|
<p [innerHTML]="message | safeHtml"></p>
|
||||||
|
}
|
||||||
|
@if (messageBold) {
|
||||||
|
<p class="mb-0 small"><b [innerHTML]="messageBold | safeHtml"></b></p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<ng-template #pageCheckOverlay let-page="page" let-pages="pages">
|
||||||
|
<div class="position-absolute top-0 start-0 w-100 h-100 p-2" (click)="pageCheckChanged(page)">
|
||||||
|
<input type="checkbox" class="form-check-input" />
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
.pdf-viewer-container {
|
||||||
|
background-color: gray;
|
||||||
|
height: 550px;
|
||||||
|
|
||||||
|
pdf-viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mw-60 {
|
||||||
|
max-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.position-absolute:has(.form-check-input:checked) {
|
||||||
|
background-color: rgba(var(--bs-dark-rgb), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input {
|
||||||
|
&:checked {
|
||||||
|
background-color: var(--bs-danger);
|
||||||
|
border-color: var(--bs-danger);
|
||||||
|
}
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), var(--pngx-focus-alpha));
|
||||||
|
border-color: var(--bs-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||||
|
import { DeletePagesConfirmDialogComponent } from './delete-pages-confirm-dialog.component'
|
||||||
|
|
||||||
|
describe('DeletePagesConfirmDialogComponent', () => {
|
||||||
|
let component: DeletePagesConfirmDialogComponent
|
||||||
|
let fixture: ComponentFixture<DeletePagesConfirmDialogComponent>
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [],
|
||||||
|
imports: [
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
DeletePagesConfirmDialogComponent,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
NgbActiveModal,
|
||||||
|
SafeHtmlPipe,
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
fixture = TestBed.createComponent(DeletePagesConfirmDialogComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return a string with comma-separated pages', () => {
|
||||||
|
component.pages = [1, 2, 3, 4]
|
||||||
|
expect(component.pagesString).toEqual('1, 2, 3, 4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update totalPages when pdf is loaded', () => {
|
||||||
|
component.pdfPreviewLoaded({ numPages: 5 } as any)
|
||||||
|
expect(component.totalPages).toEqual(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update checks when page is rendered', () => {
|
||||||
|
const event = {
|
||||||
|
target: document.createElement('div'),
|
||||||
|
detail: { pageNumber: 1 },
|
||||||
|
} as any
|
||||||
|
component.pageRendered(event)
|
||||||
|
expect(component['checks'].length).toEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update pages when page check is changed', () => {
|
||||||
|
component.pageCheckChanged(1)
|
||||||
|
expect(component.pages).toEqual([1])
|
||||||
|
component.pageCheckChanged(1)
|
||||||
|
expect(component.pages).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Component, TemplateRef, ViewChild, inject } from '@angular/core'
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import {
|
||||||
|
PDFDocumentProxy,
|
||||||
|
PdfViewerComponent,
|
||||||
|
PdfViewerModule,
|
||||||
|
} from 'ng2-pdf-viewer'
|
||||||
|
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-delete-pages-confirm-dialog',
|
||||||
|
templateUrl: './delete-pages-confirm-dialog.component.html',
|
||||||
|
styleUrl: './delete-pages-confirm-dialog.component.scss',
|
||||||
|
imports: [PdfViewerModule, FormsModule, ReactiveFormsModule, SafeHtmlPipe],
|
||||||
|
})
|
||||||
|
export class DeletePagesConfirmDialogComponent extends ConfirmDialogComponent {
|
||||||
|
private documentService = inject(DocumentService)
|
||||||
|
|
||||||
|
public documentID: number
|
||||||
|
public pages: number[] = []
|
||||||
|
public currentPage: number = 1
|
||||||
|
public totalPages: number
|
||||||
|
|
||||||
|
@ViewChild('pdfViewer') pdfViewer: PdfViewerComponent
|
||||||
|
@ViewChild('pageCheckOverlay') pageCheckOverlay!: TemplateRef<any>
|
||||||
|
private checks: HTMLElement[] = []
|
||||||
|
|
||||||
|
public get pagesString(): string {
|
||||||
|
return this.pages.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
public get pdfSrc(): string {
|
||||||
|
return this.documentService.getPreviewUrl(this.documentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
public pdfPreviewLoaded(pdf: PDFDocumentProxy) {
|
||||||
|
this.totalPages = pdf.numPages
|
||||||
|
}
|
||||||
|
|
||||||
|
pageRendered(event: CustomEvent) {
|
||||||
|
const pageDiv = event.target as HTMLDivElement
|
||||||
|
const check = this.pageCheckOverlay.createEmbeddedView({
|
||||||
|
page: event.detail.pageNumber,
|
||||||
|
})
|
||||||
|
this.checks[event.detail.pageNumber - 1] = check.rootNodes[0]
|
||||||
|
pageDiv?.insertBefore(check.rootNodes[0], pageDiv.firstChild)
|
||||||
|
this.updateChecks()
|
||||||
|
}
|
||||||
|
|
||||||
|
pageCheckChanged(pageNumber: number) {
|
||||||
|
if (!this.pages.includes(pageNumber)) this.pages.push(pageNumber)
|
||||||
|
else if (this.pages.includes(pageNumber))
|
||||||
|
this.pages.splice(this.pages.indexOf(pageNumber), 1)
|
||||||
|
this.updateChecks()
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateChecks() {
|
||||||
|
this.checks.forEach((check, i) => {
|
||||||
|
const input = check.getElementsByTagName('input')[0]
|
||||||
|
input.checked = this.pages.includes(i + 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<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="row mb-2">
|
||||||
|
<div class="col-7">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<div class="input-group-text" i18n>Page</div>
|
||||||
|
<input class="form-control" type="number" min="1" [(ngModel)]="page" />
|
||||||
|
<div class="input-group-text" i18n>of {{totalPages}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-viewer-container w-100 mt-3">
|
||||||
|
<pdf-viewer [src]="pdfSrc" [(page)]="page"
|
||||||
|
[original-size]="false"
|
||||||
|
[zoom]="1"
|
||||||
|
zoom-scale="page-fit"
|
||||||
|
(after-load-complete)="pdfPreviewLoaded($event)">
|
||||||
|
</pdf-viewer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-5">
|
||||||
|
<div class="d-grid">
|
||||||
|
<button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="!canSplit">
|
||||||
|
<i-bs name="plus-circle"></i-bs>
|
||||||
|
<span i18n>Add Split</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="list-group mt-3">
|
||||||
|
@for (pageStr of pagesString.split(','); track pageStr; let i = $index) {
|
||||||
|
<li class="list-group-item d-flex align-items-center">
|
||||||
|
{{pageStr}}
|
||||||
|
@if (pagesString.split(',').length > 1) {
|
||||||
|
|
||||||
|
<button class="btn btn-sm btn-danger ms-auto" (click)="removeSplit(i)">
|
||||||
|
<i-bs name="trash"></i-bs>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div class="form-check form-switch me-auto">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalSwitch" [(ngModel)]="deleteOriginal" [disabled]="!userOwnsDocument">
|
||||||
|
<label class="form-check-label" for="deleteOriginalSwitch" i18n>Delete original document after successful split</label>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.pdf-viewer-container {
|
||||||
|
background-color: gray;
|
||||||
|
height: 500px;
|
||||||
|
|
||||||
|
pdf-viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { PdfViewerModule } from 'ng2-pdf-viewer'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { of } from 'rxjs'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { SplitConfirmDialogComponent } from './split-confirm-dialog.component'
|
||||||
|
|
||||||
|
describe('SplitConfirmDialogComponent', () => {
|
||||||
|
let component: SplitConfirmDialogComponent
|
||||||
|
let fixture: ComponentFixture<SplitConfirmDialogComponent>
|
||||||
|
let documentService: DocumentService
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
ReactiveFormsModule,
|
||||||
|
FormsModule,
|
||||||
|
PdfViewerModule,
|
||||||
|
SplitConfirmDialogComponent,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
NgbActiveModal,
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(SplitConfirmDialogComponent)
|
||||||
|
documentService = TestBed.inject(DocumentService)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should load document on init', () => {
|
||||||
|
const getSpy = jest.spyOn(documentService, 'get')
|
||||||
|
component.documentID = 1
|
||||||
|
getSpy.mockReturnValue(of({ id: 1 } as any))
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(documentService.get).toHaveBeenCalledWith(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update pagesString when pages are added', () => {
|
||||||
|
component.totalPages = 5
|
||||||
|
component.page = 2
|
||||||
|
component.addSplit()
|
||||||
|
expect(component.pagesString).toEqual('1-2,3-5')
|
||||||
|
component.page = 4
|
||||||
|
component.addSplit()
|
||||||
|
expect(component.pagesString).toEqual('1-2,3-4,5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update pagesString when pages are removed', () => {
|
||||||
|
component.totalPages = 5
|
||||||
|
component.page = 2
|
||||||
|
component.addSplit()
|
||||||
|
component.page = 4
|
||||||
|
component.addSplit()
|
||||||
|
expect(component.pagesString).toEqual('1-2,3-4,5')
|
||||||
|
component.removeSplit(0)
|
||||||
|
expect(component.pagesString).toEqual('1-4,5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should enable confirm button when pages are added', () => {
|
||||||
|
component.totalPages = 5
|
||||||
|
component.page = 2
|
||||||
|
component.addSplit()
|
||||||
|
expect(component.confirmButtonEnabled).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable confirm button when all pages are removed', () => {
|
||||||
|
component.totalPages = 5
|
||||||
|
component.page = 2
|
||||||
|
component.addSplit()
|
||||||
|
component.removeSplit(0)
|
||||||
|
expect(component.confirmButtonEnabled).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not add split if page is the last page', () => {
|
||||||
|
component.totalPages = 5
|
||||||
|
component.page = 5
|
||||||
|
component.addSplit()
|
||||||
|
expect(component.pagesString).toEqual('1-5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update totalPages when pdf is loaded', () => {
|
||||||
|
component.pdfPreviewLoaded({ numPages: 5 } as any)
|
||||||
|
expect(component.totalPages).toEqual(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should correctly disable split button', () => {
|
||||||
|
component.totalPages = 5
|
||||||
|
component.page = 1
|
||||||
|
expect(component.canSplit).toBeTruthy()
|
||||||
|
component.page = 5
|
||||||
|
expect(component.canSplit).toBeFalsy()
|
||||||
|
component.page = 4
|
||||||
|
expect(component.canSplit).toBeTruthy()
|
||||||
|
component['pages'] = new Set([1, 2, 3, 4])
|
||||||
|
expect(component.canSplit).toBeFalsy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Component, OnInit, inject } from '@angular/core'
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { Document } from 'src/app/data/document'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-split-confirm-dialog',
|
||||||
|
templateUrl: './split-confirm-dialog.component.html',
|
||||||
|
styleUrl: './split-confirm-dialog.component.scss',
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
PdfViewerModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class SplitConfirmDialogComponent
|
||||||
|
extends ConfirmDialogComponent
|
||||||
|
implements OnInit
|
||||||
|
{
|
||||||
|
private documentService = inject(DocumentService)
|
||||||
|
private permissionService = inject(PermissionsService)
|
||||||
|
|
||||||
|
public get pagesString(): string {
|
||||||
|
let pagesStr = ''
|
||||||
|
|
||||||
|
let lastPage = 1
|
||||||
|
for (let i = 1; i <= this.totalPages; i++) {
|
||||||
|
if (this.pages.has(i) || i === this.totalPages) {
|
||||||
|
if (lastPage === i) {
|
||||||
|
pagesStr += `${i},`
|
||||||
|
lastPage = Math.min(i + 1, this.totalPages)
|
||||||
|
} else {
|
||||||
|
pagesStr += `${lastPage}-${i},`
|
||||||
|
lastPage = Math.min(i + 1, this.totalPages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pagesStr.replace(/,$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
private pages: Set<number> = new Set()
|
||||||
|
|
||||||
|
public documentID: number
|
||||||
|
private document: Document
|
||||||
|
public page: number = 1
|
||||||
|
public totalPages: number
|
||||||
|
public deleteOriginal: boolean = false
|
||||||
|
|
||||||
|
public get canSplit(): boolean {
|
||||||
|
return (
|
||||||
|
this.page < this.totalPages &&
|
||||||
|
this.pages.size < this.totalPages - 1 &&
|
||||||
|
!this.pages.has(this.page)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public get pdfSrc(): string {
|
||||||
|
return this.documentService.getPreviewUrl(this.documentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.confirmButtonEnabled = this.pages.size > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.documentService.get(this.documentID).subscribe((r) => {
|
||||||
|
this.document = r
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfPreviewLoaded(pdf: PDFDocumentProxy) {
|
||||||
|
this.totalPages = pdf.numPages
|
||||||
|
}
|
||||||
|
|
||||||
|
addSplit() {
|
||||||
|
if (this.page === this.totalPages) return
|
||||||
|
this.pages.add(this.page)
|
||||||
|
this.pages = new Set(Array.from(this.pages).sort((a, b) => a - b))
|
||||||
|
this.confirmButtonEnabled = this.pages.size > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
removeSplit(i: number) {
|
||||||
|
let page = Array.from(this.pages)[Math.min(i, this.pages.size - 1)]
|
||||||
|
this.pages.delete(page)
|
||||||
|
this.confirmButtonEnabled = this.pages.size > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
get userOwnsDocument(): boolean {
|
||||||
|
return this.permissionService.currentUserOwnsObject(this.document)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,9 +35,6 @@
|
|||||||
@case (CustomFieldDataType.Select) {
|
@case (CustomFieldDataType.Select) {
|
||||||
<span [ngbTooltip]="nameTooltip">{{getSelectValue(field, value)}}</span>
|
<span [ngbTooltip]="nameTooltip">{{getSelectValue(field, value)}}</span>
|
||||||
}
|
}
|
||||||
@case (CustomFieldDataType.LongText) {
|
|
||||||
<p class="mb-0" [ngbTooltip]="nameTooltip">{{value | slice:0:20}}{{value.length > 20 ? '...' : ''}}</p>
|
|
||||||
}
|
|
||||||
@default {
|
@default {
|
||||||
<span [ngbTooltip]="nameTooltip">{{value}}</span>
|
<span [ngbTooltip]="nameTooltip">{{value}}</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
|
import { CurrencyPipe, getLocaleCurrencyCode } from '@angular/common'
|
||||||
import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core'
|
import { Component, Input, LOCALE_ID, OnInit, inject } from '@angular/core'
|
||||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { takeUntil } from 'rxjs'
|
import { takeUntil } from 'rxjs'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
@@ -14,7 +14,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||||||
selector: 'pngx-custom-field-display',
|
selector: 'pngx-custom-field-display',
|
||||||
templateUrl: './custom-field-display.component.html',
|
templateUrl: './custom-field-display.component.html',
|
||||||
styleUrl: './custom-field-display.component.scss',
|
styleUrl: './custom-field-display.component.scss',
|
||||||
imports: [CustomDatePipe, CurrencyPipe, NgbTooltipModule, SlicePipe],
|
imports: [CustomDatePipe, CurrencyPipe, NgbTooltipModule],
|
||||||
})
|
})
|
||||||
export class CustomFieldDisplayComponent
|
export class CustomFieldDisplayComponent
|
||||||
extends LoadingComponentWithPermissions
|
extends LoadingComponentWithPermissions
|
||||||
|
|||||||
@@ -41,3 +41,9 @@
|
|||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-group-xs {
|
||||||
|
> .btn {
|
||||||
|
border-radius: 0.15rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<div class="selected-icon">
|
<div class="selected-icon">
|
||||||
@if (createdRelativeDate) {
|
@if (createdRelativeDate) {
|
||||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()">
|
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()">
|
||||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused text-dark"></i-bs>
|
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,16 +28,6 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (allSelectOptions.length > SELECT_OPTION_PAGE_SIZE) {
|
|
||||||
<ngb-pagination
|
|
||||||
class="d-flex justify-content-end"
|
|
||||||
[pageSize]="SELECT_OPTION_PAGE_SIZE"
|
|
||||||
[collectionSize]="allSelectOptions.length"
|
|
||||||
[(page)]="selectOptionsPage"
|
|
||||||
[maxSize]="5"
|
|
||||||
size="sm"
|
|
||||||
></ngb-pagination>
|
|
||||||
}
|
|
||||||
@if (object?.id) {
|
@if (object?.id) {
|
||||||
<small class="d-block mt-2" i18n>Warning: existing instances of this field will retain their current value index (e.g. option #1, #2, #3) after editing the options here</small>
|
<small class="d-block mt-2" i18n>Warning: existing instances of this field will retain their current value index (e.g. option #1, #2, #3) after editing the options here</small>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,42 +125,4 @@ describe('CustomFieldEditDialogComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(document.activeElement).toBe(selectOptionInputs.last.nativeElement)
|
expect(document.activeElement).toBe(selectOptionInputs.last.nativeElement)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should send all select options including those changed in form on save', () => {
|
|
||||||
component.dialogMode = EditDialogMode.EDIT
|
|
||||||
component.object = {
|
|
||||||
id: 1,
|
|
||||||
name: 'Field 1',
|
|
||||||
data_type: CustomFieldDataType.Select,
|
|
||||||
extra_data: {
|
|
||||||
select_options: Array.from({ length: 50 }, (_, i) => ({
|
|
||||||
label: `Option ${i + 1}`,
|
|
||||||
id: `${i + 1}-xyz`,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.ngOnInit()
|
|
||||||
component.selectOptionsPage = 2
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.objectForm
|
|
||||||
.get('extra_data')
|
|
||||||
.get('select_options')
|
|
||||||
.get('0')
|
|
||||||
.get('label')
|
|
||||||
.setValue('Updated Option 9')
|
|
||||||
const formValues = (component as any).getFormValues()
|
|
||||||
// first item unchanged
|
|
||||||
expect(formValues.extra_data.select_options[0]).toEqual({
|
|
||||||
label: 'Option 1',
|
|
||||||
id: '1-xyz',
|
|
||||||
})
|
|
||||||
// page 2 first item updated
|
|
||||||
expect(
|
|
||||||
formValues.extra_data.select_options[component.SELECT_OPTION_PAGE_SIZE]
|
|
||||||
).toEqual({
|
|
||||||
label: 'Updated Option 9',
|
|
||||||
id: '9-xyz',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { takeUntil } from 'rxjs'
|
import { takeUntil } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
@@ -29,8 +28,6 @@ import { SelectComponent } from '../../input/select/select.component'
|
|||||||
import { TextComponent } from '../../input/text/text.component'
|
import { TextComponent } from '../../input/text/text.component'
|
||||||
import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component'
|
import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component'
|
||||||
|
|
||||||
const SELECT_OPTION_PAGE_SIZE = 8
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-custom-field-edit-dialog',
|
selector: 'pngx-custom-field-edit-dialog',
|
||||||
templateUrl: './custom-field-edit-dialog.component.html',
|
templateUrl: './custom-field-edit-dialog.component.html',
|
||||||
@@ -40,7 +37,6 @@ const SELECT_OPTION_PAGE_SIZE = 8
|
|||||||
TextComponent,
|
TextComponent,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgbPaginationModule,
|
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -49,21 +45,6 @@ export class CustomFieldEditDialogComponent
|
|||||||
implements OnInit, AfterViewInit
|
implements OnInit, AfterViewInit
|
||||||
{
|
{
|
||||||
CustomFieldDataType = CustomFieldDataType
|
CustomFieldDataType = CustomFieldDataType
|
||||||
SELECT_OPTION_PAGE_SIZE = SELECT_OPTION_PAGE_SIZE
|
|
||||||
|
|
||||||
private _allSelectOptions: any[] = []
|
|
||||||
public get allSelectOptions(): any[] {
|
|
||||||
return this._allSelectOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
private _selectOptionsPage: number
|
|
||||||
public get selectOptionsPage(): number {
|
|
||||||
return this._selectOptionsPage
|
|
||||||
}
|
|
||||||
public set selectOptionsPage(v: number) {
|
|
||||||
this._selectOptionsPage = v
|
|
||||||
this.updateSelectOptions()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewChildren('selectOption')
|
@ViewChildren('selectOption')
|
||||||
private selectOptionInputs: QueryList<ElementRef>
|
private selectOptionInputs: QueryList<ElementRef>
|
||||||
@@ -86,10 +67,17 @@ export class CustomFieldEditDialogComponent
|
|||||||
this.objectForm.get('data_type').disable()
|
this.objectForm.get('data_type').disable()
|
||||||
}
|
}
|
||||||
if (this.object?.data_type === CustomFieldDataType.Select) {
|
if (this.object?.data_type === CustomFieldDataType.Select) {
|
||||||
this._allSelectOptions = [
|
this.selectOptions.clear()
|
||||||
...(this.object.extra_data.select_options ?? []),
|
this.object.extra_data.select_options
|
||||||
]
|
.filter((option) => option)
|
||||||
this.selectOptionsPage = 1
|
.forEach((option) =>
|
||||||
|
this.selectOptions.push(
|
||||||
|
new FormGroup({
|
||||||
|
label: new FormControl(option.label),
|
||||||
|
id: new FormControl(option.id),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,19 +87,6 @@ export class CustomFieldEditDialogComponent
|
|||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.selectOptionInputs.last?.nativeElement.focus()
|
this.selectOptionInputs.last?.nativeElement.focus()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.objectForm.valueChanges
|
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
|
||||||
.subscribe((change) => {
|
|
||||||
// Update the relevant select options values if changed in the form, which is only a page of the entire list
|
|
||||||
this.objectForm
|
|
||||||
.get('extra_data.select_options')
|
|
||||||
?.value.forEach((option, index) => {
|
|
||||||
this._allSelectOptions[
|
|
||||||
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE
|
|
||||||
] = option
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getCreateTitle() {
|
getCreateTitle() {
|
||||||
@@ -133,17 +108,6 @@ export class CustomFieldEditDialogComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getFormValues() {
|
|
||||||
const formValues = super.getFormValues()
|
|
||||||
if (
|
|
||||||
this.objectForm.get('data_type')?.value === CustomFieldDataType.Select
|
|
||||||
) {
|
|
||||||
// Make sure we send all select options, with updated values
|
|
||||||
formValues.extra_data.select_options = this._allSelectOptions
|
|
||||||
}
|
|
||||||
return formValues
|
|
||||||
}
|
|
||||||
|
|
||||||
getDataTypes() {
|
getDataTypes() {
|
||||||
return DATA_TYPE_LABELS
|
return DATA_TYPE_LABELS
|
||||||
}
|
}
|
||||||
@@ -152,41 +116,13 @@ export class CustomFieldEditDialogComponent
|
|||||||
return this.dialogMode === EditDialogMode.EDIT
|
return this.dialogMode === EditDialogMode.EDIT
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateSelectOptions() {
|
|
||||||
this.selectOptions.clear()
|
|
||||||
this._allSelectOptions
|
|
||||||
.slice(
|
|
||||||
(this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE,
|
|
||||||
this.selectOptionsPage * SELECT_OPTION_PAGE_SIZE
|
|
||||||
)
|
|
||||||
.forEach((option) =>
|
|
||||||
this.selectOptions.push(
|
|
||||||
new FormGroup({
|
|
||||||
label: new FormControl(option.label),
|
|
||||||
id: new FormControl(option.id),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public addSelectOption() {
|
public addSelectOption() {
|
||||||
this._allSelectOptions.push({ label: null, id: null })
|
this.selectOptions.push(
|
||||||
this.selectOptionsPage = Math.ceil(
|
new FormGroup({ label: new FormControl(null), id: new FormControl(null) })
|
||||||
this.allSelectOptions.length / SELECT_OPTION_PAGE_SIZE
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeSelectOption(index: number) {
|
public removeSelectOption(index: number) {
|
||||||
const globalIndex =
|
this.selectOptions.removeAt(index)
|
||||||
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE
|
|
||||||
this._allSelectOptions.splice(globalIndex, 1)
|
|
||||||
|
|
||||||
const totalPages = Math.max(
|
|
||||||
1,
|
|
||||||
Math.ceil(this._allSelectOptions.length / SELECT_OPTION_PAGE_SIZE)
|
|
||||||
)
|
|
||||||
const targetPage = Math.min(this.selectOptionsPage, totalPages)
|
|
||||||
|
|
||||||
this.selectOptionsPage = targetPage
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,13 +147,9 @@ export abstract class EditDialogComponent<
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getFormValues(): any {
|
|
||||||
return Object.assign({}, this.objectForm.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
save() {
|
save() {
|
||||||
this.error = null
|
this.error = null
|
||||||
const formValues = this.getFormValues()
|
const formValues = Object.assign({}, this.objectForm.value)
|
||||||
const permissionsObject: PermissionsFormObject =
|
const permissionsObject: PermissionsFormObject =
|
||||||
this.objectForm.get('permissions_form')?.value
|
this.objectForm.get('permissions_form')?.value
|
||||||
if (permissionsObject) {
|
if (permissionsObject) {
|
||||||
|
|||||||
@@ -12,8 +12,6 @@
|
|||||||
|
|
||||||
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
|
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
|
||||||
|
|
||||||
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
|
|
||||||
|
|
||||||
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
|
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
|
||||||
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||||
@if (patternRequired) {
|
@if (patternRequired) {
|
||||||
|
|||||||
@@ -35,16 +35,11 @@ import { TextComponent } from '../../input/text/text.component'
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
||||||
tags: Tag[]
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.service = inject(TagService)
|
this.service = inject(TagService)
|
||||||
this.userService = inject(UserService)
|
this.userService = inject(UserService)
|
||||||
this.settingsService = inject(SettingsService)
|
this.settingsService = inject(SettingsService)
|
||||||
this.service.listAll().subscribe((result) => {
|
|
||||||
this.tags = result.results
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getCreateTitle() {
|
getCreateTitle() {
|
||||||
@@ -60,7 +55,6 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
|||||||
name: new FormControl(''),
|
name: new FormControl(''),
|
||||||
color: new FormControl(randomColor()),
|
color: new FormControl(randomColor()),
|
||||||
is_inbox_tag: new FormControl(false),
|
is_inbox_tag: new FormControl(false),
|
||||||
parent: new FormControl(null),
|
|
||||||
matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM),
|
matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM),
|
||||||
match: new FormControl(''),
|
match: new FormControl(''),
|
||||||
is_insensitive: new FormControl(true),
|
is_insensitive: new FormControl(true),
|
||||||
|
|||||||
@@ -177,7 +177,6 @@
|
|||||||
<pngx-input-tags [allowCreate]="false" i18n-title title="Has any of tags" formControlName="filter_has_tags"></pngx-input-tags>
|
<pngx-input-tags [allowCreate]="false" i18n-title title="Has any of tags" formControlName="filter_has_tags"></pngx-input-tags>
|
||||||
<pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
|
<pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
|
||||||
<pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select>
|
<pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select>
|
||||||
<pngx-input-select i18n-title title="Has storage path" [items]="storagePaths" [allowNull]="true" formControlName="filter_has_storage_path"></pngx-input-select>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -412,9 +412,6 @@ export class WorkflowEditDialogComponent
|
|||||||
filter_has_document_type: new FormControl(
|
filter_has_document_type: new FormControl(
|
||||||
trigger.filter_has_document_type
|
trigger.filter_has_document_type
|
||||||
),
|
),
|
||||||
filter_has_storage_path: new FormControl(
|
|
||||||
trigger.filter_has_storage_path
|
|
||||||
),
|
|
||||||
schedule_offset_days: new FormControl(trigger.schedule_offset_days),
|
schedule_offset_days: new FormControl(trigger.schedule_offset_days),
|
||||||
schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
|
schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
|
||||||
schedule_recurring_interval_days: new FormControl(
|
schedule_recurring_interval_days: new FormControl(
|
||||||
@@ -539,7 +536,6 @@ export class WorkflowEditDialogComponent
|
|||||||
filter_has_tags: [],
|
filter_has_tags: [],
|
||||||
filter_has_correspondent: null,
|
filter_has_correspondent: null,
|
||||||
filter_has_document_type: null,
|
filter_has_document_type: null,
|
||||||
filter_has_storage_path: null,
|
|
||||||
matching_algorithm: MATCH_NONE,
|
matching_algorithm: MATCH_NONE,
|
||||||
match: '',
|
match: '',
|
||||||
is_insensitive: true,
|
is_insensitive: true,
|
||||||
|
|||||||
@@ -114,13 +114,6 @@ export class FilterableDropdownSelectionModel {
|
|||||||
b.id == NEGATIVE_NULL_FILTER_VALUE)
|
b.id == NEGATIVE_NULL_FILTER_VALUE)
|
||||||
) {
|
) {
|
||||||
return 1
|
return 1
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve hierarchical order when provided (e.g., Tags)
|
|
||||||
const ao = (a as any)['orderIndex']
|
|
||||||
const bo = (b as any)['orderIndex']
|
|
||||||
if (ao !== undefined && bo !== undefined) {
|
|
||||||
return ao - bo
|
|
||||||
} else if (
|
} else if (
|
||||||
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
|
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
|
||||||
this.getNonTemporary(b.id) != ToggleableItemState.NotSelected
|
this.getNonTemporary(b.id) != ToggleableItemState.NotSelected
|
||||||
|
|||||||
@@ -15,17 +15,12 @@
|
|||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="me-1 name-cell" [style.--depth]="isTag ? getDepth() + 1 : 1">
|
<div class="me-1">
|
||||||
@if (isTag && getDepth() > 0) {
|
@if (isTag) {
|
||||||
<div class="indicator"></div>
|
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
|
||||||
|
} @else {
|
||||||
|
<small>{{item.name}}</small>
|
||||||
}
|
}
|
||||||
<div>
|
|
||||||
@if (isTag) {
|
|
||||||
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
|
|
||||||
} @else {
|
|
||||||
<small>{{item.name}}</small>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
@if (!hideCount) {
|
@if (!hideCount) {
|
||||||
<div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{currentCount}}</div>
|
<div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{currentCount}}</div>
|
||||||
|
|||||||
@@ -2,19 +2,3 @@
|
|||||||
min-width: 1em;
|
min-width: 1em;
|
||||||
min-height: 1em;
|
min-height: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.name-cell {
|
|
||||||
padding-left: calc(calc(var(--depth) - 2) * 1rem);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.indicator {
|
|
||||||
display: inline-block;
|
|
||||||
width: .8rem;
|
|
||||||
height: .8rem;
|
|
||||||
border-left: 1px solid var(--bs-secondary);
|
|
||||||
border-bottom: 1px solid var(--bs-secondary);
|
|
||||||
margin-right: .25rem;
|
|
||||||
margin-left: .5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { MatchingModel } from 'src/app/data/matching-model'
|
import { MatchingModel } from 'src/app/data/matching-model'
|
||||||
import { Tag } from 'src/app/data/tag'
|
|
||||||
import { TagComponent } from '../../tag/tag.component'
|
import { TagComponent } from '../../tag/tag.component'
|
||||||
|
|
||||||
export enum ToggleableItemState {
|
export enum ToggleableItemState {
|
||||||
@@ -46,10 +45,6 @@ export class ToggleableDropdownButtonComponent {
|
|||||||
return 'is_inbox_tag' in this.item
|
return 'is_inbox_tag' in this.item
|
||||||
}
|
}
|
||||||
|
|
||||||
getDepth(): number {
|
|
||||||
return (this.item as Tag).depth ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
get currentCount(): number {
|
get currentCount(): number {
|
||||||
return this.count ?? this.item.document_count
|
return this.count ?? this.item.document_count
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@if (title) {
|
@if (title) {
|
||||||
<label class="form-label" [for]="inputId">{{title}}</label>
|
<label [for]="inputId">{{title}}</label>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="input-group" [class.is-invalid]="error">
|
<div class="input-group" [class.is-invalid]="error">
|
||||||
<button type="button" class="input-group-text" [style.background-color]="value" (click)="colorPicker.toggle()"> </button>
|
<span class="input-group-text" [style.background-color]="value"> </span>
|
||||||
|
|
||||||
<ng-template #popContent>
|
<ng-template #popContent>
|
||||||
<div style="min-width: 200px;" class="pb-3">
|
<div style="min-width: 200px;" class="pb-3">
|
||||||
<color-slider [color]="value" (onChangeComplete)="colorChanged($event.color.hex)"></color-slider>
|
<color-slider [color]="value" (onChangeComplete)="colorChanged($event.color.hex)"></color-slider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" #colorPicker="ngbPopover" placement="bottom" popoverClass="shadow">
|
<input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" placement="bottom" popoverClass="shadow">
|
||||||
|
|
||||||
<button class="btn btn-outline-secondary" type="button" (click)="randomize()">
|
<button class="btn btn-outline-secondary" type="button" (click)="randomize()">
|
||||||
<i-bs name="dice5"></i-bs>
|
<i-bs name="dice5"></i-bs>
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ describe('ColorComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should set swatch color', () => {
|
it('should set swatch color', () => {
|
||||||
const swatch: HTMLButtonElement = fixture.nativeElement.querySelector(
|
const swatch: HTMLSpanElement = fixture.nativeElement.querySelector(
|
||||||
'button.input-group-text'
|
'span.input-group-text'
|
||||||
)
|
)
|
||||||
expect(swatch.style.backgroundColor).toEqual('')
|
expect(swatch.style.backgroundColor).toEqual('')
|
||||||
component.value = '#ff0000'
|
component.value = '#ff0000'
|
||||||
|
|||||||
@@ -68,11 +68,6 @@
|
|||||||
[allowNull]="true"
|
[allowNull]="true"
|
||||||
[horizontal]="true"></pngx-input-select>
|
[horizontal]="true"></pngx-input-select>
|
||||||
}
|
}
|
||||||
@case (CustomFieldDataType.LongText) {
|
|
||||||
<pngx-input-textarea [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
|
|
||||||
[title]="getCustomField(fieldId)?.name"
|
|
||||||
class="flex-grow-1"></pngx-input-textarea>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
<button type="button" class="btn btn-link text-danger" (click)="removeSelectedField.next(fieldId)">
|
<button type="button" class="btn btn-link text-danger" (click)="removeSelectedField.next(fieldId)">
|
||||||
<i-bs name="trash"></i-bs>
|
<i-bs name="trash"></i-bs>
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { MonetaryComponent } from '../monetary/monetary.component'
|
|||||||
import { NumberComponent } from '../number/number.component'
|
import { NumberComponent } from '../number/number.component'
|
||||||
import { SelectComponent } from '../select/select.component'
|
import { SelectComponent } from '../select/select.component'
|
||||||
import { TextComponent } from '../text/text.component'
|
import { TextComponent } from '../text/text.component'
|
||||||
import { TextAreaComponent } from '../textarea/textarea.component'
|
|
||||||
import { UrlComponent } from '../url/url.component'
|
import { UrlComponent } from '../url/url.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -52,7 +51,6 @@ import { UrlComponent } from '../url/url.component'
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
TextAreaComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CustomFieldsValuesComponent extends AbstractInputComponent<Object> {
|
export class CustomFieldsValuesComponent extends AbstractInputComponent<Object> {
|
||||||
|
|||||||
@@ -7,14 +7,13 @@
|
|||||||
<div class="input-group flex-nowrap">
|
<div class="input-group flex-nowrap">
|
||||||
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[multiple]="multiple"
|
[multiple]="true"
|
||||||
[closeOnSelect]="false"
|
[closeOnSelect]="false"
|
||||||
[clearSearchOnAdd]="true"
|
[clearSearchOnAdd]="true"
|
||||||
[hideSelected]="tags.length > 0"
|
[hideSelected]="tags.length > 0"
|
||||||
[addTag]="allowCreate ? createTagRef : false"
|
[addTag]="allowCreate ? createTagRef : false"
|
||||||
addTagText="Add tag"
|
addTagText="Add tag"
|
||||||
i18n-addTagText
|
i18n-addTagText
|
||||||
(add)="onAdd($event)"
|
|
||||||
(change)="onChange(value)">
|
(change)="onChange(value)">
|
||||||
|
|
||||||
<ng-template ng-label-tmp let-item="item">
|
<ng-template ng-label-tmp let-item="item">
|
||||||
@@ -26,20 +25,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
|
<ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
|
||||||
<div class="tag-option-row d-flex align-items-center">
|
<div class="tag-wrap">
|
||||||
@if (item.id && tags) {
|
@if (item.id && tags) {
|
||||||
@if (getTag(item.id)?.parent) {
|
<pngx-tag class="me-2" [tag]="getTag(item.id)"></pngx-tag>
|
||||||
<i-bs name="list-nested" class="me-1"></i-bs>
|
|
||||||
<span class="hierarchy-reveal d-flex align-items-center">
|
|
||||||
<span class="parents d-flex align-items-center">
|
|
||||||
@for (p of getParentChain(item.id); track p.id) {
|
|
||||||
<span class="badge me-1" [style.background]="p.color" [style.color]="p.text_color">{{p.name}}</span>
|
|
||||||
<i-bs name="chevron-right" width=".8em" height=".8em" class="me-1"></i-bs>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<pngx-tag class="current-tag d-flex" [tag]="getTag(item.id)"></pngx-tag>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
@@ -20,33 +20,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dropdown hierarchy reveal for ng-select options
|
|
||||||
::ng-deep .ng-dropdown-panel .ng-option {
|
|
||||||
overflow-x: scroll;
|
|
||||||
|
|
||||||
.tag-option-row {
|
|
||||||
font-size: 1rem;
|
|
||||||
width: max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hierarchy-reveal {
|
|
||||||
overflow: hidden;
|
|
||||||
max-width: 0;
|
|
||||||
transition: max-width 200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.parents .badge {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-reveal,
|
|
||||||
::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-reveal {
|
|
||||||
max-width: 1000px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-indicator,
|
|
||||||
::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-indicator {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -177,59 +177,4 @@ describe('TagsComponent', () => {
|
|||||||
component.onFilterDocuments()
|
component.onFilterDocuments()
|
||||||
expect(emitSpy).toHaveBeenCalledWith([tags[2]])
|
expect(emitSpy).toHaveBeenCalledWith([tags[2]])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should remove all descendants from selection', () => {
|
|
||||||
const c: Tag = { id: 4, name: 'c' }
|
|
||||||
const b: Tag = { id: 3, name: 'b', children: [c] }
|
|
||||||
const a: Tag = { id: 2, name: 'a' }
|
|
||||||
const root: Tag = { id: 1, name: 'root', children: [a, b] }
|
|
||||||
|
|
||||||
const inputIDs = [2, 3, 4, 99]
|
|
||||||
const result = (component as any).removeChildren(inputIDs, root)
|
|
||||||
expect(result).toEqual([99])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should append all parents recursively', () => {
|
|
||||||
const root: Tag = { id: 1, name: 'root' }
|
|
||||||
const mid: Tag = { id: 2, name: 'mid', parent: 1 }
|
|
||||||
const leaf: Tag = { id: 3, name: 'leaf', parent: 2 }
|
|
||||||
component.tags = [root, mid, leaf]
|
|
||||||
|
|
||||||
component.value = []
|
|
||||||
component.onAdd(leaf)
|
|
||||||
expect(component.value).toEqual([2, 1])
|
|
||||||
|
|
||||||
// Calling onAdd on a root should not change value
|
|
||||||
component.onAdd(root)
|
|
||||||
expect(component.value).toEqual([2, 1])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return ancestors from root to parent using getParentChain', () => {
|
|
||||||
const root: Tag = { id: 1, name: 'root' }
|
|
||||||
const mid: Tag = { id: 2, name: 'mid', parent: 1 }
|
|
||||||
const leaf: Tag = { id: 3, name: 'leaf', parent: 2 }
|
|
||||||
component.tags = [root, mid, leaf]
|
|
||||||
|
|
||||||
expect(component.getParentChain(3).map((t) => t.id)).toEqual([1, 2])
|
|
||||||
expect(component.getParentChain(2).map((t) => t.id)).toEqual([1])
|
|
||||||
expect(component.getParentChain(1).map((t) => t.id)).toEqual([])
|
|
||||||
// Non-existent id
|
|
||||||
expect(component.getParentChain(999).map((t) => t.id)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle cyclic parents via guard in getParentChain', () => {
|
|
||||||
const one: Tag = { id: 1, name: 'one', parent: 2 }
|
|
||||||
const two: Tag = { id: 2, name: 'two', parent: 1 }
|
|
||||||
component.tags = [one, two]
|
|
||||||
|
|
||||||
const chain = component.getParentChain(1)
|
|
||||||
// Guard avoids infinite loop; chain contains both nodes once
|
|
||||||
expect(chain.map((t) => t.id)).toEqual([1, 2])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should stop when parent does not exist in getParentChain', () => {
|
|
||||||
const lone: Tag = { id: 5, name: 'lone', parent: 999 }
|
|
||||||
component.tags = [lone]
|
|
||||||
expect(component.getParentChain(5)).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -100,9 +100,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
@Input()
|
@Input()
|
||||||
horizontal: boolean = false
|
horizontal: boolean = false
|
||||||
|
|
||||||
@Input()
|
|
||||||
multiple: boolean = true
|
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
filterDocuments = new EventEmitter<Tag[]>()
|
filterDocuments = new EventEmitter<Tag[]>()
|
||||||
|
|
||||||
@@ -127,40 +124,13 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
|
|
||||||
let index = this.value.indexOf(tagID)
|
let index = this.value.indexOf(tagID)
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
const tag = this.getTag(tagID)
|
|
||||||
|
|
||||||
// remove tag
|
|
||||||
let oldValue = this.value
|
let oldValue = this.value
|
||||||
oldValue.splice(index, 1)
|
oldValue.splice(index, 1)
|
||||||
|
|
||||||
// remove children
|
|
||||||
oldValue = this.removeChildren(oldValue, tag)
|
|
||||||
|
|
||||||
this.value = [...oldValue]
|
this.value = [...oldValue]
|
||||||
this.onChange(this.value)
|
this.onChange(this.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeChildren(tagIDs: number[], tag: Tag) {
|
|
||||||
if (tag.children?.length) {
|
|
||||||
const childIDs = tag.children.map((child) => child.id)
|
|
||||||
tagIDs = tagIDs.filter((id) => !childIDs.includes(id))
|
|
||||||
for (const child of tag.children) {
|
|
||||||
tagIDs = this.removeChildren(tagIDs, child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tagIDs
|
|
||||||
}
|
|
||||||
|
|
||||||
public onAdd(tag: Tag) {
|
|
||||||
if (tag.parent) {
|
|
||||||
// add all parents recursively
|
|
||||||
const parent = this.getTag(tag.parent)
|
|
||||||
this.value = [...this.value, parent.id]
|
|
||||||
this.onAdd(parent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createTag(name: string = null, add: boolean = false) {
|
createTag(name: string = null, add: boolean = false) {
|
||||||
var modal = this.modalService.open(TagEditDialogComponent, {
|
var modal = this.modalService.open(TagEditDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
@@ -196,7 +166,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
|
|
||||||
addTag(id) {
|
addTag(id) {
|
||||||
this.value = [...this.value, id]
|
this.value = [...this.value, id]
|
||||||
this.onAdd(this.getTag(id))
|
|
||||||
this.onChange(this.value)
|
this.onChange(this.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,20 +180,4 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
this.tags.filter((t) => this.value.includes(t.id))
|
this.tags.filter((t) => this.value.includes(t.id))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
getParentChain(id: number): Tag[] {
|
|
||||||
// Returns ancestors from root → immediate parent for a tag id
|
|
||||||
const chain: Tag[] = []
|
|
||||||
let current = this.getTag(id)
|
|
||||||
const guard = new Set<number>()
|
|
||||||
while (current?.parent) {
|
|
||||||
if (guard.has(current.parent)) break
|
|
||||||
guard.add(current.parent)
|
|
||||||
const parent = this.getTag(current.parent)
|
|
||||||
if (!parent) break
|
|
||||||
chain.unshift(parent)
|
|
||||||
current = parent
|
|
||||||
}
|
|
||||||
return chain
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
NG_VALUE_ACCESSOR,
|
NG_VALUE_ACCESSOR,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||||
import { AbstractInputComponent } from '../abstract-input'
|
import { AbstractInputComponent } from '../abstract-input'
|
||||||
|
|
||||||
@@ -19,12 +18,7 @@ import { AbstractInputComponent } from '../abstract-input'
|
|||||||
selector: 'pngx-input-textarea',
|
selector: 'pngx-input-textarea',
|
||||||
templateUrl: './textarea.component.html',
|
templateUrl: './textarea.component.html',
|
||||||
styleUrls: ['./textarea.component.scss'],
|
styleUrls: ['./textarea.component.scss'],
|
||||||
imports: [
|
imports: [FormsModule, ReactiveFormsModule, SafeHtmlPipe],
|
||||||
FormsModule,
|
|
||||||
ReactiveFormsModule,
|
|
||||||
SafeHtmlPipe,
|
|
||||||
NgxBootstrapIconsModule,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class TextAreaComponent extends AbstractInputComponent<string> {
|
export class TextAreaComponent extends AbstractInputComponent<string> {
|
||||||
@Input()
|
@Input()
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
|
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
|
||||||
<div class="col-md text-truncate">
|
<div class="col-md text-truncate">
|
||||||
<h3 class="text-truncate d-flex align-items-center" style="line-height: 1.4">
|
<h3 class="text-truncate" style="line-height: 1.4">
|
||||||
{{title}}
|
{{title}}
|
||||||
@if (id) {
|
|
||||||
<span class="badge bg-primary text-primary-text-contrast ms-2 small fs-normal">ID: {{id}}</span>
|
|
||||||
}
|
|
||||||
@if (subTitle) {
|
@if (subTitle) {
|
||||||
<span class="h6 mb-0 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
|
<span class="h6 mb-0 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
h3 {
|
h3 {
|
||||||
min-height: calc(1.325rem + 0.9vw);
|
min-height: calc(1.325rem + 0.9vw);
|
||||||
|
|
||||||
.badge {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
@media (min-width: 1200px) {
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ export class PageHeaderComponent {
|
|||||||
return this._title
|
return this._title
|
||||||
}
|
}
|
||||||
|
|
||||||
@Input()
|
|
||||||
id: number
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
subTitle: string = ''
|
subTitle: string = ''
|
||||||
|
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
<pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer>
|
|
||||||
<div class="modal-header">
|
|
||||||
<h4 class="modal-title">{{ title }}</h4>
|
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="btn-toolbar mb-2">
|
|
||||||
<div class="btn-group me-3">
|
|
||||||
<button class="btn btn-sm btn-secondary" (click)="selectAll()" title="Select all pages" i18n-title>
|
|
||||||
<i-bs name="check-all"></i-bs>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-secondary" (click)="deselectAll()" [disabled]="!hasSelection()" title="Deselect all pages" i18n-title>
|
|
||||||
<i-bs name="x"></i-bs>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-sm btn-secondary" (click)="rotateSelected(-90)" [disabled]="!hasSelection()" title="Rotate selected pages counter-clockwise" i18n-title>
|
|
||||||
<i-bs name="arrow-counterclockwise"></i-bs>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-secondary" (click)="rotateSelected(90)" [disabled]="!hasSelection()" title="Rotate selected pages clockwise" i18n-title>
|
|
||||||
<i-bs name="arrow-clockwise"></i-bs>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-danger" (click)="deleteSelected()" [disabled]="!hasSelection()" title="Delete selected pages" i18n-title>
|
|
||||||
<i-bs name="trash"></i-bs>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div cdkDropList (cdkDropListDropped)="drop($event)" cdkDropListOrientation="mixed" class="d-flex flex-wrap row-cols-2 row-cols-md-5">
|
|
||||||
@for (p of pages; track p.page; let i = $index) {
|
|
||||||
<div class="page-item rounded p-2" cdkDrag (click)="toggleSelection(i)" [class.selected]="p.selected">
|
|
||||||
<div class="btn-toolbar hover-actions z-10">
|
|
||||||
<div class="btn-group me-2">
|
|
||||||
<button class="btn btn-sm btn-dark" (click)="rotate(i, true); $event.stopPropagation()" title="Rotate page counter-clockwise" i18n-title>
|
|
||||||
<i-bs name="arrow-counterclockwise"></i-bs>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page clockwise" i18n-title>
|
|
||||||
<i-bs name="arrow-clockwise"></i-bs>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-sm btn-dark text-danger" (click)="remove(i); $event.stopPropagation()" title="Delete page" i18n-title>
|
|
||||||
<i-bs name="trash"></i-bs>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-dark" (click)="toggleSplit(i); $event.stopPropagation()" title="Add / remove document split here" i18n-title>
|
|
||||||
<i-bs name="scissors"></i-bs>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="border-end border-bottom bg-light py-1 px-2 document-check z-10">
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="page{{i}}" [checked]="p.selected" (click)="toggleSelection(i); $event.stopPropagation()">
|
|
||||||
<label class="form-check-label" for="page{{i}}"></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="pdf-viewer-container w-100" [class.selected]="p.selected">
|
|
||||||
@defer (on viewport) {
|
|
||||||
@if (!p.loaded) {
|
|
||||||
<div class="placeholder-glow w-100 h-100 z-10">
|
|
||||||
<span class="placeholder w-100 h-100"></span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [original-size]="false" [show-all]="false" [render-text]="false" (page-rendered)="p.loaded = true"></pdf-viewer>
|
|
||||||
} @placeholder {
|
|
||||||
<div class="placeholder-glow w-100 h-100 z-10">
|
|
||||||
<span class="placeholder w-100 h-100"></span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (p.splitAfter) {
|
|
||||||
<div class="split-after rounded position-absolute top-0 end-0 bg-dark text-uppercase text-center h-100 px-1 small fw-bold">— <span i18n>Split here</span> —</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<div class="d-flex flex-column flex-md-row w-100 gap-3 align-items-center">
|
|
||||||
<div class="btn-group" role="group">
|
|
||||||
<input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Create" id="editModeCreate" name="editmode">
|
|
||||||
<label for="editModeCreate" class="btn btn-outline-primary btn-sm">
|
|
||||||
<i-bs name="plus"></i-bs>
|
|
||||||
<span class="form-check-label ms-1" i18n>Create new document(s)</span>
|
|
||||||
</label>
|
|
||||||
<input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Update" id="editModeUpdate" name="editmode" [disabled]="hasSplit()">
|
|
||||||
<label for="editModeUpdate" class="btn btn-outline-primary btn-sm">
|
|
||||||
<i-bs name="pencil"></i-bs>
|
|
||||||
<span class="form-check-label ms-2" i18n>Update existing document</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
@if (editMode === PdfEditorEditMode.Create) {
|
|
||||||
<div class="form-group d-flex">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="copyMeta" [(ngModel)]="includeMetadata">
|
|
||||||
<label class="form-check-label" for="copyMeta" i18n>Copy metadata</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check ms-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="deleteOriginal" [(ngModel)]="deleteOriginal">
|
|
||||||
<label class="form-check-label" for="deleteOriginal" i18n>Delete original</label>
|
|
||||||
</div>
|
|
||||||
</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" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
.page-item {
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background-origin: border-box;
|
|
||||||
|
|
||||||
&.selected {
|
|
||||||
background-color: var(--pngx-primary-darken-5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pdf-viewer-container {
|
|
||||||
background-color: gray;
|
|
||||||
height: 240px;
|
|
||||||
|
|
||||||
pdf-viewer {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .ng2-pdf-viewer-container {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover-actions {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-item:hover .hover-actions {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-check {
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-top-left-radius: 0.25rem;
|
|
||||||
border-bottom-right-radius: 0.25rem;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
.form-check {
|
|
||||||
padding: 0;
|
|
||||||
min-height: 0;
|
|
||||||
margin-bottom: 0;
|
|
||||||
|
|
||||||
.form-check-input {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-item:hover .document-check, .selected .document-check {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.z-10 {
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-after {
|
|
||||||
writing-mode: vertical-rl;
|
|
||||||
}
|
|
||||||
@@ -1,142 +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 { PDFEditorComponent } from './pdf-editor.component'
|
|
||||||
|
|
||||||
describe('PDFEditorComponent', () => {
|
|
||||||
let component: PDFEditorComponent
|
|
||||||
let fixture: ComponentFixture<PDFEditorComponent>
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [PDFEditorComponent, NgxBootstrapIconsModule.pick(allIcons)],
|
|
||||||
providers: [
|
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
|
||||||
provideHttpClientTesting(),
|
|
||||||
{ provide: NgbActiveModal, useValue: {} },
|
|
||||||
],
|
|
||||||
}).compileComponents()
|
|
||||||
fixture = TestBed.createComponent(PDFEditorComponent)
|
|
||||||
component = fixture.componentInstance
|
|
||||||
fixture.detectChanges()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return correct operations with no changes', () => {
|
|
||||||
component.pages = [
|
|
||||||
{ page: 1, rotate: 0, splitAfter: false },
|
|
||||||
{ page: 2, rotate: 0, splitAfter: false },
|
|
||||||
{ page: 3, rotate: 0, splitAfter: false },
|
|
||||||
]
|
|
||||||
const ops = component.getOperations()
|
|
||||||
expect(ops).toEqual([
|
|
||||||
{ page: 1, rotate: 0, doc: 0 },
|
|
||||||
{ page: 2, rotate: 0, doc: 0 },
|
|
||||||
{ page: 3, rotate: 0, doc: 0 },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should rotate, delete and reorder pages', () => {
|
|
||||||
component.pages = [
|
|
||||||
{ page: 1, rotate: 0, splitAfter: false, selected: false },
|
|
||||||
{ page: 2, rotate: 0, splitAfter: false, selected: false },
|
|
||||||
]
|
|
||||||
component.toggleSelection(0)
|
|
||||||
component.rotateSelected(90)
|
|
||||||
expect(component.pages[0].rotate).toBe(90)
|
|
||||||
component.toggleSelection(0) // deselect
|
|
||||||
component.toggleSelection(1)
|
|
||||||
component.deleteSelected()
|
|
||||||
expect(component.pages.length).toBe(1)
|
|
||||||
component.pages.push({ page: 2, rotate: 0, splitAfter: false })
|
|
||||||
component.drop({ previousIndex: 0, currentIndex: 1 } as any)
|
|
||||||
expect(component.pages[0].page).toBe(2)
|
|
||||||
component.rotate(0)
|
|
||||||
expect(component.pages[0].rotate).toBe(90)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle empty pages array', () => {
|
|
||||||
component.pages = []
|
|
||||||
expect(component.getOperations()).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should increment doc index after splitAfter', () => {
|
|
||||||
component.pages = [
|
|
||||||
{ page: 1, rotate: 0, splitAfter: true },
|
|
||||||
{ page: 2, rotate: 0, splitAfter: false },
|
|
||||||
{ page: 3, rotate: 0, splitAfter: true },
|
|
||||||
{ page: 4, rotate: 0, splitAfter: false },
|
|
||||||
]
|
|
||||||
const ops = component.getOperations()
|
|
||||||
expect(ops).toEqual([
|
|
||||||
{ page: 1, rotate: 0, doc: 0 },
|
|
||||||
{ page: 2, rotate: 0, doc: 1 },
|
|
||||||
{ page: 3, rotate: 0, doc: 1 },
|
|
||||||
{ page: 4, rotate: 0, doc: 2 },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should include rotations in operations', () => {
|
|
||||||
component.pages = [
|
|
||||||
{ page: 1, rotate: 90, splitAfter: false },
|
|
||||||
{ page: 2, rotate: 180, splitAfter: true },
|
|
||||||
{ page: 3, rotate: 270, splitAfter: false },
|
|
||||||
]
|
|
||||||
const ops = component.getOperations()
|
|
||||||
expect(ops).toEqual([
|
|
||||||
{ page: 1, rotate: 90, doc: 0 },
|
|
||||||
{ page: 2, rotate: 180, doc: 0 },
|
|
||||||
{ page: 3, rotate: 270, doc: 1 },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle remove operation', () => {
|
|
||||||
component.pages = [
|
|
||||||
{ page: 1, rotate: 0, splitAfter: false, selected: false },
|
|
||||||
{ page: 2, rotate: 0, splitAfter: false, selected: true },
|
|
||||||
{ page: 3, rotate: 0, splitAfter: false, selected: false },
|
|
||||||
]
|
|
||||||
component.remove(1) // remove page 2
|
|
||||||
expect(component.pages.length).toBe(2)
|
|
||||||
expect(component.pages[0].page).toBe(1)
|
|
||||||
expect(component.pages[1].page).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should toggle splitAfter correctly', () => {
|
|
||||||
component.pages = [
|
|
||||||
{ page: 1, rotate: 0, splitAfter: false },
|
|
||||||
{ page: 2, rotate: 0, splitAfter: false },
|
|
||||||
]
|
|
||||||
component.toggleSplit(0)
|
|
||||||
expect(component.pages[0].splitAfter).toBeTruthy()
|
|
||||||
component.toggleSplit(1)
|
|
||||||
expect(component.pages[1].splitAfter).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should select and deselect all pages', () => {
|
|
||||||
component.pages = [
|
|
||||||
{ page: 1, rotate: 0, splitAfter: false, selected: false },
|
|
||||||
{ page: 2, rotate: 0, splitAfter: false, selected: false },
|
|
||||||
]
|
|
||||||
component.selectAll()
|
|
||||||
expect(component.pages.every((p) => p.selected)).toBeTruthy()
|
|
||||||
expect(component.hasSelection()).toBeTruthy()
|
|
||||||
component.deselectAll()
|
|
||||||
expect(component.pages.every((p) => !p.selected)).toBeTruthy()
|
|
||||||
expect(component.hasSelection()).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle pdf loading and page generation', () => {
|
|
||||||
const mockPdf = {
|
|
||||||
numPages: 3,
|
|
||||||
getPage: (pageNum: number) => Promise.resolve({ pageNumber: pageNum }),
|
|
||||||
}
|
|
||||||
component.pdfLoaded(mockPdf as any)
|
|
||||||
expect(component.totalPages).toBe(3)
|
|
||||||
expect(component.pages.length).toBe(3)
|
|
||||||
expect(component.pages[0].page).toBe(1)
|
|
||||||
expect(component.pages[1].page).toBe(2)
|
|
||||||
expect(component.pages[2].page).toBe(3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import {
|
|
||||||
CdkDragDrop,
|
|
||||||
DragDropModule,
|
|
||||||
moveItemInArray,
|
|
||||||
} from '@angular/cdk/drag-drop'
|
|
||||||
import { Component, inject } from '@angular/core'
|
|
||||||
import { FormsModule } from '@angular/forms'
|
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
|
|
||||||
|
|
||||||
interface PageOperation {
|
|
||||||
page: number
|
|
||||||
rotate: number
|
|
||||||
splitAfter: boolean
|
|
||||||
selected?: boolean
|
|
||||||
loaded?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum PdfEditorEditMode {
|
|
||||||
Update = 'update',
|
|
||||||
Create = 'create',
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'pngx-pdf-editor',
|
|
||||||
templateUrl: './pdf-editor.component.html',
|
|
||||||
styleUrl: './pdf-editor.component.scss',
|
|
||||||
imports: [
|
|
||||||
DragDropModule,
|
|
||||||
FormsModule,
|
|
||||||
PdfViewerModule,
|
|
||||||
NgxBootstrapIconsModule,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class PDFEditorComponent extends ConfirmDialogComponent {
|
|
||||||
public PdfEditorEditMode = PdfEditorEditMode
|
|
||||||
|
|
||||||
private documentService = inject(DocumentService)
|
|
||||||
activeModal: NgbActiveModal = inject(NgbActiveModal)
|
|
||||||
|
|
||||||
documentID: number
|
|
||||||
pages: PageOperation[] = []
|
|
||||||
totalPages = 0
|
|
||||||
editMode: PdfEditorEditMode = PdfEditorEditMode.Create
|
|
||||||
deleteOriginal: boolean = false
|
|
||||||
includeMetadata: boolean = true
|
|
||||||
|
|
||||||
get pdfSrc(): string {
|
|
||||||
return this.documentService.getPreviewUrl(this.documentID)
|
|
||||||
}
|
|
||||||
|
|
||||||
pdfLoaded(pdf: PDFDocumentProxy) {
|
|
||||||
this.totalPages = pdf.numPages
|
|
||||||
this.pages = Array.from({ length: this.totalPages }, (_, i) => ({
|
|
||||||
page: i + 1,
|
|
||||||
rotate: 0,
|
|
||||||
splitAfter: false,
|
|
||||||
selected: false,
|
|
||||||
loaded: false,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleSelection(i: number) {
|
|
||||||
this.pages[i].selected = !this.pages[i].selected
|
|
||||||
}
|
|
||||||
|
|
||||||
rotate(i: number, counterclockwise: boolean = false) {
|
|
||||||
this.pages[i].rotate =
|
|
||||||
(this.pages[i].rotate + (counterclockwise ? -90 : 90) + 360) % 360
|
|
||||||
}
|
|
||||||
|
|
||||||
rotateSelected(dir: number) {
|
|
||||||
for (let p of this.pages) {
|
|
||||||
if (p.selected) {
|
|
||||||
p.rotate = (p.rotate + dir + 360) % 360
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(i: number) {
|
|
||||||
this.pages.splice(i, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleSplit(i: number) {
|
|
||||||
this.pages[i].splitAfter = !this.pages[i].splitAfter
|
|
||||||
if (this.pages[i].splitAfter) {
|
|
||||||
// force create mode
|
|
||||||
this.editMode = PdfEditorEditMode.Create
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
selectAll() {
|
|
||||||
this.pages.forEach((p) => (p.selected = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
deselectAll() {
|
|
||||||
this.pages.forEach((p) => (p.selected = false))
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteSelected() {
|
|
||||||
this.pages = this.pages.filter((p) => !p.selected)
|
|
||||||
}
|
|
||||||
|
|
||||||
hasSelection(): boolean {
|
|
||||||
return this.pages.some((p) => p.selected)
|
|
||||||
}
|
|
||||||
|
|
||||||
hasSplit(): boolean {
|
|
||||||
return this.pages.some((p) => p.splitAfter)
|
|
||||||
}
|
|
||||||
|
|
||||||
drop(event: CdkDragDrop<PageOperation[]>) {
|
|
||||||
moveItemInArray(this.pages, event.previousIndex, event.currentIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
getOperations() {
|
|
||||||
return this.pages.map((p, idx) => ({
|
|
||||||
page: p.page,
|
|
||||||
rotate: p.rotate,
|
|
||||||
doc: this.computeDocIndex(idx),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
private computeDocIndex(index: number): number {
|
|
||||||
let docIndex = 0
|
|
||||||
for (let i = 0; i <= index; i++) {
|
|
||||||
if (this.pages[i].splitAfter && i < index) docIndex++
|
|
||||||
}
|
|
||||||
return docIndex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -254,18 +254,6 @@
|
|||||||
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.sanity_check_error}}</span>
|
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.sanity_check_error}}</span>
|
||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<dt i18n>WebSocket Connection</dt>
|
|
||||||
<dd>
|
|
||||||
<span class="btn btn-sm pe-none align-items-center btn-dark text-uppercase small">
|
|
||||||
@if (status.websocket_connected === 'OK') {
|
|
||||||
<ng-container i18n>OK</ng-container>
|
|
||||||
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
|
|
||||||
} @else {
|
|
||||||
<ng-container i18n>Error</ng-container>
|
|
||||||
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
} from '@angular/core/testing'
|
} from '@angular/core/testing'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { PaperlessTaskName } from 'src/app/data/paperless-task'
|
import { PaperlessTaskName } from 'src/app/data/paperless-task'
|
||||||
import {
|
import {
|
||||||
InstallType,
|
InstallType,
|
||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
import { SystemStatusService } from 'src/app/services/system-status.service'
|
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
|
||||||
import { SystemStatusDialogComponent } from './system-status-dialog.component'
|
import { SystemStatusDialogComponent } from './system-status-dialog.component'
|
||||||
|
|
||||||
const status: SystemStatus = {
|
const status: SystemStatus = {
|
||||||
@@ -78,8 +77,6 @@ describe('SystemStatusDialogComponent', () => {
|
|||||||
let tasksService: TasksService
|
let tasksService: TasksService
|
||||||
let systemStatusService: SystemStatusService
|
let systemStatusService: SystemStatusService
|
||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
let websocketStatusService: WebsocketStatusService
|
|
||||||
let websocketSubject: Subject<boolean> = new Subject<boolean>()
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
@@ -101,12 +98,6 @@ describe('SystemStatusDialogComponent', () => {
|
|||||||
tasksService = TestBed.inject(TasksService)
|
tasksService = TestBed.inject(TasksService)
|
||||||
systemStatusService = TestBed.inject(SystemStatusService)
|
systemStatusService = TestBed.inject(SystemStatusService)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
websocketStatusService = TestBed.inject(WebsocketStatusService)
|
|
||||||
jest
|
|
||||||
.spyOn(websocketStatusService, 'onConnectionStatus')
|
|
||||||
.mockImplementation(() => {
|
|
||||||
return websocketSubject.asObservable()
|
|
||||||
})
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -177,19 +168,4 @@ describe('SystemStatusDialogComponent', () => {
|
|||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.versionMismatch).toBeFalsy()
|
expect(component.versionMismatch).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update websocket connection status', () => {
|
|
||||||
websocketSubject.next(true)
|
|
||||||
expect(component.status.websocket_connected).toEqual(
|
|
||||||
SystemStatusItemStatus.OK
|
|
||||||
)
|
|
||||||
websocketSubject.next(false)
|
|
||||||
expect(component.status.websocket_connected).toEqual(
|
|
||||||
SystemStatusItemStatus.ERROR
|
|
||||||
)
|
|
||||||
websocketSubject.next(true)
|
|
||||||
expect(component.status.websocket_connected).toEqual(
|
|
||||||
SystemStatusItemStatus.OK
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'
|
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'
|
||||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
import { Component, OnInit, inject } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
NgbActiveModal,
|
NgbActiveModal,
|
||||||
NgbModalModule,
|
NgbModalModule,
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
NgbProgressbarModule,
|
NgbProgressbarModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, takeUntil } from 'rxjs'
|
|
||||||
import { PaperlessTaskName } from 'src/app/data/paperless-task'
|
import { PaperlessTaskName } from 'src/app/data/paperless-task'
|
||||||
import {
|
import {
|
||||||
SystemStatus,
|
SystemStatus,
|
||||||
@@ -19,7 +18,6 @@ import { PermissionsService } from 'src/app/services/permissions.service'
|
|||||||
import { SystemStatusService } from 'src/app/services/system-status.service'
|
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -36,14 +34,13 @@ import { environment } from 'src/environments/environment'
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class SystemStatusDialogComponent implements OnInit, OnDestroy {
|
export class SystemStatusDialogComponent implements OnInit {
|
||||||
activeModal = inject(NgbActiveModal)
|
activeModal = inject(NgbActiveModal)
|
||||||
private clipboard = inject(Clipboard)
|
private clipboard = inject(Clipboard)
|
||||||
private systemStatusService = inject(SystemStatusService)
|
private systemStatusService = inject(SystemStatusService)
|
||||||
private tasksService = inject(TasksService)
|
private tasksService = inject(TasksService)
|
||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
private permissionsService = inject(PermissionsService)
|
private permissionsService = inject(PermissionsService)
|
||||||
private websocketStatusService = inject(WebsocketStatusService)
|
|
||||||
|
|
||||||
public SystemStatusItemStatus = SystemStatusItemStatus
|
public SystemStatusItemStatus = SystemStatusItemStatus
|
||||||
public PaperlessTaskName = PaperlessTaskName
|
public PaperlessTaskName = PaperlessTaskName
|
||||||
@@ -54,7 +51,6 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
|
|||||||
public copied: boolean = false
|
public copied: boolean = false
|
||||||
|
|
||||||
private runningTasks: Set<PaperlessTaskName> = new Set()
|
private runningTasks: Set<PaperlessTaskName> = new Set()
|
||||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
|
||||||
|
|
||||||
get currentUserIsSuperUser(): boolean {
|
get currentUserIsSuperUser(): boolean {
|
||||||
return this.permissionsService.isSuperUser()
|
return this.permissionsService.isSuperUser()
|
||||||
@@ -69,17 +65,6 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
|
|||||||
if (this.versionMismatch) {
|
if (this.versionMismatch) {
|
||||||
this.status.pngx_version = `${this.status.pngx_version} (frontend: ${this.frontendVersion})`
|
this.status.pngx_version = `${this.status.pngx_version} (frontend: ${this.frontendVersion})`
|
||||||
}
|
}
|
||||||
this.status.websocket_connected = this.websocketStatusService.isConnected()
|
|
||||||
? SystemStatusItemStatus.OK
|
|
||||||
: SystemStatusItemStatus.ERROR
|
|
||||||
this.websocketStatusService
|
|
||||||
.onConnectionStatus()
|
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
|
||||||
.subscribe((connected) => {
|
|
||||||
this.status.websocket_connected = connected
|
|
||||||
? SystemStatusItemStatus.OK
|
|
||||||
: SystemStatusItemStatus.ERROR
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public close() {
|
public close() {
|
||||||
@@ -112,7 +97,7 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
|
|||||||
this.runningTasks.delete(taskName)
|
this.runningTasks.delete(taskName)
|
||||||
this.systemStatusService.get().subscribe({
|
this.systemStatusService.get().subscribe({
|
||||||
next: (status) => {
|
next: (status) => {
|
||||||
Object.assign(this.status, status)
|
this.status = status
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -125,9 +110,4 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.unsubscribeNotifier.next(this)
|
|
||||||
this.unsubscribeNotifier.complete()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
@if (tag) {
|
@if (tag) {
|
||||||
@if (showParents && tag.parent) {
|
|
||||||
<pngx-tag [tagID]="tag.parent" [clickable]="clickable" [linkTitle]="linkTitle"></pngx-tag>
|
|
||||||
>
|
|
||||||
}
|
|
||||||
@if (!clickable) {
|
@if (!clickable) {
|
||||||
<span class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span>
|
<span class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,4 @@ export class TagComponent {
|
|||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
clickable: boolean = false
|
clickable: boolean = false
|
||||||
|
|
||||||
@Input()
|
|
||||||
showParents: boolean = false
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
|
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
|
||||||
}
|
}
|
||||||
<div>
|
<div>
|
||||||
<p class="ms-2 mb-0 text-break">{{toast.content}}</p>
|
<p class="ms-2 mb-0">{{toast.content}}</p>
|
||||||
@if (toast.error) {
|
@if (toast.error) {
|
||||||
<details class="ms-2">
|
<details class="ms-2">
|
||||||
<div class="mt-2 ms-n4 me-n2 small">
|
<div class="mt-2 ms-n4 me-n2 small">
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ describe('DashboardComponent', () => {
|
|||||||
}),
|
}),
|
||||||
dashboardViews: saved_views.filter((v) => v.show_on_dashboard),
|
dashboardViews: saved_views.filter((v) => v.show_on_dashboard),
|
||||||
allViews: saved_views,
|
allViews: saved_views,
|
||||||
setDocumentCount: jest.fn(),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ import {
|
|||||||
} from 'src/app/services/permissions.service'
|
} from 'src/app/services/permissions.service'
|
||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||||
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
|
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
|
||||||
@@ -95,7 +94,6 @@ export class SavedViewWidgetComponent
|
|||||||
permissionsService = inject(PermissionsService)
|
permissionsService = inject(PermissionsService)
|
||||||
private settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
private customFieldService = inject(CustomFieldsService)
|
private customFieldService = inject(CustomFieldsService)
|
||||||
private savedViewService = inject(SavedViewService)
|
|
||||||
|
|
||||||
public DisplayMode = DisplayMode
|
public DisplayMode = DisplayMode
|
||||||
public DisplayField = DisplayField
|
public DisplayField = DisplayField
|
||||||
@@ -183,7 +181,6 @@ export class SavedViewWidgetComponent
|
|||||||
this.show = true
|
this.show = true
|
||||||
this.documents = result.results
|
this.documents = result.results
|
||||||
this.count = result.count
|
this.count = result.count
|
||||||
this.savedViewService.setDocumentCount(this.savedView, result.count)
|
|
||||||
}),
|
}),
|
||||||
delay(500)
|
delay(500)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<pngx-page-header [(title)]="title" [id]="documentId">
|
<pngx-page-header [(title)]="title">
|
||||||
@if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
|
@if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
|
||||||
@if (previewNumPages) {
|
@if (previewNumPages) {
|
||||||
<div class="input-group input-group-sm d-none d-md-flex">
|
<div class="input-group input-group-sm d-none d-md-flex">
|
||||||
@@ -54,16 +54,20 @@
|
|||||||
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs> <span i18n>Reprocess</span>
|
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs> <span i18n>Reprocess</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile">
|
|
||||||
<i-bs width="1em" height="1em" name="printer"></i-bs> <span i18n>Print</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button ngbDropdownItem (click)="moreLike()">
|
<button ngbDropdownItem (click)="moreLike()">
|
||||||
<i-bs width="1em" height="1em" name="diagram-3"></i-bs> <span i18n>More like this</span>
|
<i-bs width="1em" height="1em" name="diagram-3"></i-bs> <span i18n>More like this</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
|
<button ngbDropdownItem (click)="splitDocument()" [disabled]="!userCanAdd || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
|
||||||
<i-bs name="pencil"></i-bs> <ng-container i18n>PDF Editor</ng-container>
|
<i-bs width="1em" height="1em" name="scissors"></i-bs> <span i18n>Split</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
|
||||||
|
<i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button ngbDropdownItem (click)="deletePages()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
|
||||||
|
<i-bs name="file-earmark-minus"></i-bs> <ng-container i18n>Delete page(s)</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,14 +220,6 @@
|
|||||||
(removed)="removeField(fieldInstance)"
|
(removed)="removeField(fieldInstance)"
|
||||||
[error]="getCustomFieldError(i)"></pngx-input-select>
|
[error]="getCustomFieldError(i)"></pngx-input-select>
|
||||||
}
|
}
|
||||||
@case (CustomFieldDataType.LongText) {
|
|
||||||
<pngx-input-textarea formControlName="value"
|
|
||||||
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
|
|
||||||
[removable]="userCanEdit"
|
|
||||||
(removed)="removeField(fieldInstance)"
|
|
||||||
[horizontal]="true"
|
|
||||||
[error]="getCustomFieldError(i)"></pngx-input-textarea>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -452,18 +452,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true })
|
expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should navigate to 404 if error on load', () => {
|
|
||||||
jest
|
|
||||||
.spyOn(activatedRoute, 'paramMap', 'get')
|
|
||||||
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
|
|
||||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
|
||||||
jest
|
|
||||||
.spyOn(documentService, 'get')
|
|
||||||
.mockReturnValue(throwError(() => new Error('not found')))
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support save, close and show success toast', () => {
|
it('should support save, close and show success toast', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.title = 'Foo Bar'
|
component.title = 'Foo Bar'
|
||||||
@@ -1170,49 +1158,87 @@ describe('DocumentDetailComponent', () => {
|
|||||||
).not.toBeUndefined()
|
).not.toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support pdf editor, handle error', () => {
|
it('should support split', () => {
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
|
|
||||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
|
||||||
initNormally()
|
initNormally()
|
||||||
component.editPdf()
|
component.splitDocument()
|
||||||
expect(modal).not.toBeUndefined()
|
expect(modal).not.toBeUndefined()
|
||||||
modal.componentInstance.documentID = doc.id
|
modal.componentInstance.documentID = doc.id
|
||||||
modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: false }]
|
modal.componentInstance.totalPages = 5
|
||||||
|
modal.componentInstance.page = 2
|
||||||
|
modal.componentInstance.addSplit()
|
||||||
modal.componentInstance.confirm()
|
modal.componentInstance.confirm()
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [doc.id],
|
documents: [doc.id],
|
||||||
method: 'edit_pdf',
|
method: 'split',
|
||||||
parameters: {
|
parameters: { pages: '1-2,3-5', delete_originals: false },
|
||||||
operations: [{ page: 1, rotate: 0, doc: 0 }],
|
|
||||||
delete_original: false,
|
|
||||||
update_document: false,
|
|
||||||
include_metadata: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
req.error(new ErrorEvent('failed'))
|
req.error(new ProgressEvent('failed'))
|
||||||
expect(errorSpy).toHaveBeenCalled()
|
modal.componentInstance.confirm()
|
||||||
|
req = httpTestingController.expectOne(
|
||||||
component.editPdf()
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
modal.componentInstance.documentID = doc.id
|
)
|
||||||
modal.componentInstance.pages = [{ page: 1, rotate: 0, splitAfter: true }]
|
req.flush(true)
|
||||||
modal.componentInstance.deleteOriginal = true
|
})
|
||||||
|
|
||||||
|
it('should support rotate', () => {
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
|
initNormally()
|
||||||
|
component.rotateDocument()
|
||||||
|
expect(modal).not.toBeUndefined()
|
||||||
|
modal.componentInstance.documentID = doc.id
|
||||||
|
modal.componentInstance.rotate()
|
||||||
|
modal.componentInstance.confirm()
|
||||||
|
let req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
|
)
|
||||||
|
expect(req.request.body).toEqual({
|
||||||
|
documents: [doc.id],
|
||||||
|
method: 'rotate',
|
||||||
|
parameters: { degrees: 90 },
|
||||||
|
})
|
||||||
|
req.error(new ProgressEvent('failed'))
|
||||||
|
modal.componentInstance.confirm()
|
||||||
|
req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
|
)
|
||||||
|
req.flush(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support delete pages', () => {
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
|
initNormally()
|
||||||
|
component.deletePages()
|
||||||
|
expect(modal).not.toBeUndefined()
|
||||||
|
modal.componentInstance.documentID = doc.id
|
||||||
|
modal.componentInstance.pages = [1, 2]
|
||||||
|
modal.componentInstance.confirm()
|
||||||
|
let req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
|
)
|
||||||
|
expect(req.request.body).toEqual({
|
||||||
|
documents: [doc.id],
|
||||||
|
method: 'delete_pages',
|
||||||
|
parameters: { pages: [1, 2] },
|
||||||
|
})
|
||||||
|
req.error(new ProgressEvent('failed'))
|
||||||
modal.componentInstance.confirm()
|
modal.componentInstance.confirm()
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
req.flush(true)
|
req.flush(true)
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support keyboard shortcuts', () => {
|
it('should support keyboard shortcuts', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
|
|
||||||
const hasNextSpy = jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
||||||
const nextSpy = jest.spyOn(component, 'nextDoc')
|
const nextSpy = jest.spyOn(component, 'nextDoc')
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true })
|
new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true })
|
||||||
@@ -1226,32 +1252,21 @@ describe('DocumentDetailComponent', () => {
|
|||||||
)
|
)
|
||||||
expect(prevSpy).toHaveBeenCalled()
|
expect(prevSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
const isDirtySpy = jest
|
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
|
||||||
.spyOn(openDocumentsService, 'isDirty')
|
|
||||||
.mockReturnValue(true)
|
|
||||||
const saveSpy = jest.spyOn(component, 'save')
|
const saveSpy = jest.spyOn(component, 'save')
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
|
new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
|
||||||
)
|
)
|
||||||
expect(saveSpy).toHaveBeenCalled()
|
expect(saveSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
hasNextSpy.mockReturnValue(true)
|
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
|
||||||
|
jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
||||||
const saveNextSpy = jest.spyOn(component, 'saveEditNext')
|
const saveNextSpy = jest.spyOn(component, 'saveEditNext')
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true })
|
new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true })
|
||||||
)
|
)
|
||||||
expect(saveNextSpy).toHaveBeenCalled()
|
expect(saveNextSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
saveSpy.mockClear()
|
|
||||||
saveNextSpy.mockClear()
|
|
||||||
isDirtySpy.mockReturnValue(true)
|
|
||||||
hasNextSpy.mockReturnValue(false)
|
|
||||||
document.dispatchEvent(
|
|
||||||
new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true })
|
|
||||||
)
|
|
||||||
expect(saveNextSpy).not.toHaveBeenCalled()
|
|
||||||
expect(saveSpy).toHaveBeenCalledWith(true)
|
|
||||||
|
|
||||||
const closeSpy = jest.spyOn(component, 'close')
|
const closeSpy = jest.spyOn(component, 'close')
|
||||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
@@ -1411,166 +1426,4 @@ describe('DocumentDetailComponent', () => {
|
|||||||
component.openEmailDocument()
|
component.openEmailDocument()
|
||||||
expect(modalSpy).toHaveBeenCalled()
|
expect(modalSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set previewText', () => {
|
|
||||||
initNormally()
|
|
||||||
const previewText = 'Hello world, this is a test'
|
|
||||||
httpTestingController.expectOne(component.previewUrl).flush(previewText)
|
|
||||||
expect(component.previewText).toEqual(previewText)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should set previewText to error message if preview fails', () => {
|
|
||||||
initNormally()
|
|
||||||
httpTestingController
|
|
||||||
.expectOne(component.previewUrl)
|
|
||||||
.flush('fail', { status: 500, statusText: 'Server Error' })
|
|
||||||
expect(component.previewText).toContain('An error occurred loading content')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should print document successfully', fakeAsync(() => {
|
|
||||||
initNormally()
|
|
||||||
|
|
||||||
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')
|
|
||||||
const revokeObjectURLSpy = jest
|
|
||||||
.spyOn(URL, 'revokeObjectURL')
|
|
||||||
.mockImplementation(() => {})
|
|
||||||
|
|
||||||
const mockContentWindow = {
|
|
||||||
focus: jest.fn(),
|
|
||||||
print: jest.fn(),
|
|
||||||
onafterprint: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockIframe = {
|
|
||||||
style: {},
|
|
||||||
src: '',
|
|
||||||
onload: null,
|
|
||||||
contentWindow: mockContentWindow,
|
|
||||||
}
|
|
||||||
|
|
||||||
const createElementSpy = jest
|
|
||||||
.spyOn(document, 'createElement')
|
|
||||||
.mockReturnValue(mockIframe as any)
|
|
||||||
|
|
||||||
const blob = new Blob(['test'], { type: 'application/pdf' })
|
|
||||||
component.printDocument()
|
|
||||||
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}documents/${doc.id}/download/`
|
|
||||||
)
|
|
||||||
req.flush(blob)
|
|
||||||
|
|
||||||
tick()
|
|
||||||
|
|
||||||
expect(createElementSpy).toHaveBeenCalledWith('iframe')
|
|
||||||
expect(appendChildSpy).toHaveBeenCalledWith(mockIframe)
|
|
||||||
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
|
|
||||||
|
|
||||||
if (mockIframe.onload) {
|
|
||||||
mockIframe.onload({} as any)
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(mockContentWindow.focus).toHaveBeenCalled()
|
|
||||||
expect(mockContentWindow.print).toHaveBeenCalled()
|
|
||||||
|
|
||||||
if (mockIframe.onload) {
|
|
||||||
mockIframe.onload(new Event('load'))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mockContentWindow.onafterprint) {
|
|
||||||
mockContentWindow.onafterprint(new Event('afterprint'))
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
|
||||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
|
||||||
|
|
||||||
createElementSpy.mockRestore()
|
|
||||||
appendChildSpy.mockRestore()
|
|
||||||
removeChildSpy.mockRestore()
|
|
||||||
createObjectURLSpy.mockRestore()
|
|
||||||
revokeObjectURLSpy.mockRestore()
|
|
||||||
}))
|
|
||||||
|
|
||||||
it('should show error toast if print document fails', () => {
|
|
||||||
initNormally()
|
|
||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
|
||||||
component.printDocument()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}documents/${doc.id}/download/`
|
|
||||||
)
|
|
||||||
req.error(new ErrorEvent('failed'))
|
|
||||||
expect(toastSpy).toHaveBeenCalledWith(
|
|
||||||
'Error loading document for printing.'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show error toast if printing throws inside iframe', fakeAsync(() => {
|
|
||||||
initNormally()
|
|
||||||
|
|
||||||
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')
|
|
||||||
const revokeObjectURLSpy = jest
|
|
||||||
.spyOn(URL, 'revokeObjectURL')
|
|
||||||
.mockImplementation(() => {})
|
|
||||||
|
|
||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
|
||||||
|
|
||||||
const mockContentWindow = {
|
|
||||||
focus: jest.fn().mockImplementation(() => {
|
|
||||||
throw new Error('focus failed')
|
|
||||||
}),
|
|
||||||
print: jest.fn(),
|
|
||||||
onafterprint: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockIframe: any = {
|
|
||||||
style: {},
|
|
||||||
src: '',
|
|
||||||
onload: null,
|
|
||||||
contentWindow: mockContentWindow,
|
|
||||||
}
|
|
||||||
|
|
||||||
const createElementSpy = jest
|
|
||||||
.spyOn(document, 'createElement')
|
|
||||||
.mockReturnValue(mockIframe as any)
|
|
||||||
|
|
||||||
const blob = new Blob(['test'], { type: 'application/pdf' })
|
|
||||||
component.printDocument()
|
|
||||||
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}documents/${doc.id}/download/`
|
|
||||||
)
|
|
||||||
req.flush(blob)
|
|
||||||
|
|
||||||
tick()
|
|
||||||
|
|
||||||
if (mockIframe.onload) {
|
|
||||||
mockIframe.onload(new Event('load'))
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
|
||||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
|
||||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
|
||||||
|
|
||||||
createElementSpy.mockRestore()
|
|
||||||
appendChildSpy.mockRestore()
|
|
||||||
removeChildSpy.mockRestore()
|
|
||||||
createObjectURLSpy.mockRestore()
|
|
||||||
revokeObjectURLSpy.mockRestore()
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,9 +21,8 @@ import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
|
|||||||
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector'
|
import { DeviceDetectorService } from 'ngx-device-detector'
|
||||||
import { BehaviorSubject, Observable, of, Subject } from 'rxjs'
|
import { BehaviorSubject, Observable, Subject } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
catchError,
|
|
||||||
debounceTime,
|
debounceTime,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
filter,
|
filter,
|
||||||
@@ -83,6 +82,9 @@ import { getFilenameFromContentDisposition } from 'src/app/utils/http'
|
|||||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||||
import * as UTIF from 'utif'
|
import * as UTIF from 'utif'
|
||||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||||
|
import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
|
||||||
|
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||||
|
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
||||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
@@ -98,13 +100,8 @@ import { PermissionsFormComponent } from '../common/input/permissions/permission
|
|||||||
import { SelectComponent } from '../common/input/select/select.component'
|
import { SelectComponent } from '../common/input/select/select.component'
|
||||||
import { TagsComponent } from '../common/input/tags/tags.component'
|
import { TagsComponent } from '../common/input/tags/tags.component'
|
||||||
import { TextComponent } from '../common/input/text/text.component'
|
import { TextComponent } from '../common/input/text/text.component'
|
||||||
import { TextAreaComponent } from '../common/input/textarea/textarea.component'
|
|
||||||
import { UrlComponent } from '../common/input/url/url.component'
|
import { UrlComponent } from '../common/input/url/url.component'
|
||||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
||||||
import {
|
|
||||||
PDFEditorComponent,
|
|
||||||
PdfEditorEditMode,
|
|
||||||
} from '../common/pdf-editor/pdf-editor.component'
|
|
||||||
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
|
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
|
||||||
import { DocumentHistoryComponent } from '../document-history/document-history.component'
|
import { DocumentHistoryComponent } from '../document-history/document-history.component'
|
||||||
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
||||||
@@ -174,7 +171,6 @@ export enum ZoomSetting {
|
|||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
PdfViewerModule,
|
PdfViewerModule,
|
||||||
TextAreaComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentDetailComponent
|
export class DocumentDetailComponent
|
||||||
@@ -293,10 +289,6 @@ export class DocumentDetailComponent
|
|||||||
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
|
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
|
||||||
}
|
}
|
||||||
|
|
||||||
get isMobile(): boolean {
|
|
||||||
return this.deviceDetectorService.isMobile()
|
|
||||||
}
|
|
||||||
|
|
||||||
get archiveContentRenderType(): ContentRenderType {
|
get archiveContentRenderType(): ContentRenderType {
|
||||||
return this.document?.archived_file_name
|
return this.document?.archived_file_name
|
||||||
? this.getRenderType('application/pdf')
|
? this.getRenderType('application/pdf')
|
||||||
@@ -334,164 +326,19 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapDocToForm(doc: Document): any {
|
|
||||||
return {
|
|
||||||
...doc,
|
|
||||||
permissions_form: { owner: doc.owner, set_permissions: doc.permissions },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapFormToDoc(value: any): any {
|
|
||||||
const docValues = { ...value }
|
|
||||||
docValues['owner'] = value['permissions_form']?.owner
|
|
||||||
docValues['set_permissions'] = value['permissions_form']?.set_permissions
|
|
||||||
delete docValues['permissions_form']
|
|
||||||
return docValues
|
|
||||||
}
|
|
||||||
|
|
||||||
private prepareForm(doc: Document): void {
|
|
||||||
this.documentForm.reset(this.mapDocToForm(doc), { emitEvent: false })
|
|
||||||
if (!this.userCanEditDoc(doc)) {
|
|
||||||
this.documentForm.disable({ emitEvent: false })
|
|
||||||
} else {
|
|
||||||
this.documentForm.enable({ emitEvent: false })
|
|
||||||
}
|
|
||||||
if (doc.__changedFields) {
|
|
||||||
doc.__changedFields.forEach((field) => {
|
|
||||||
if (field === 'owner' || field === 'set_permissions') {
|
|
||||||
this.documentForm.get('permissions_form')?.markAsDirty()
|
|
||||||
} else {
|
|
||||||
this.documentForm.get(field)?.markAsDirty()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupDirtyTracking(
|
|
||||||
currentDocument: Document,
|
|
||||||
originalDocument: Document
|
|
||||||
): void {
|
|
||||||
this.store = new BehaviorSubject({
|
|
||||||
title: originalDocument.title,
|
|
||||||
content: originalDocument.content,
|
|
||||||
created: originalDocument.created,
|
|
||||||
correspondent: originalDocument.correspondent,
|
|
||||||
document_type: originalDocument.document_type,
|
|
||||||
storage_path: originalDocument.storage_path,
|
|
||||||
archive_serial_number: originalDocument.archive_serial_number,
|
|
||||||
tags: [...originalDocument.tags],
|
|
||||||
permissions_form: {
|
|
||||||
owner: originalDocument.owner,
|
|
||||||
set_permissions: originalDocument.permissions,
|
|
||||||
},
|
|
||||||
custom_fields: [...originalDocument.custom_fields],
|
|
||||||
})
|
|
||||||
this.isDirty$ = dirtyCheck(this.documentForm, this.store.asObservable())
|
|
||||||
this.isDirty$
|
|
||||||
.pipe(
|
|
||||||
takeUntil(this.unsubscribeNotifier),
|
|
||||||
takeUntil(this.docChangeNotifier)
|
|
||||||
)
|
|
||||||
.subscribe((dirty) =>
|
|
||||||
this.openDocumentService.setDirty(
|
|
||||||
currentDocument,
|
|
||||||
dirty,
|
|
||||||
this.getChangedFields()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadDocument(documentId: number): void {
|
|
||||||
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
|
|
||||||
this.http
|
|
||||||
.get(this.previewUrl, { responseType: 'text' })
|
|
||||||
.pipe(
|
|
||||||
first(),
|
|
||||||
takeUntil(this.unsubscribeNotifier),
|
|
||||||
takeUntil(this.docChangeNotifier)
|
|
||||||
)
|
|
||||||
.subscribe({
|
|
||||||
next: (res) => (this.previewText = res.toString()),
|
|
||||||
error: (err) =>
|
|
||||||
(this.previewText = $localize`An error occurred loading content: ${
|
|
||||||
err.message ?? err.toString()
|
|
||||||
}`),
|
|
||||||
})
|
|
||||||
this.thumbUrl = this.documentsService.getThumbUrl(documentId)
|
|
||||||
this.documentsService
|
|
||||||
.get(documentId)
|
|
||||||
.pipe(
|
|
||||||
catchError(() => {
|
|
||||||
// 404 is handled in the subscribe below
|
|
||||||
return of(null)
|
|
||||||
}),
|
|
||||||
first(),
|
|
||||||
takeUntil(this.unsubscribeNotifier),
|
|
||||||
takeUntil(this.docChangeNotifier)
|
|
||||||
)
|
|
||||||
.subscribe({
|
|
||||||
next: (doc) => {
|
|
||||||
if (!doc) {
|
|
||||||
this.router.navigate(['404'], { replaceUrl: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.documentId = doc.id
|
|
||||||
this.suggestions = null
|
|
||||||
const openDocument = this.openDocumentService.getOpenDocument(
|
|
||||||
this.documentId
|
|
||||||
)
|
|
||||||
const useDoc = openDocument || doc
|
|
||||||
if (openDocument) {
|
|
||||||
if (
|
|
||||||
new Date(doc.modified) > new Date(openDocument.modified) &&
|
|
||||||
!this.modalService.hasOpenModals()
|
|
||||||
) {
|
|
||||||
const modal = this.modalService.open(ConfirmDialogComponent)
|
|
||||||
modal.componentInstance.title = $localize`Document changes detected`
|
|
||||||
modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
|
|
||||||
modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.`
|
|
||||||
modal.componentInstance.cancelBtnClass = 'visually-hidden'
|
|
||||||
modal.componentInstance.btnCaption = $localize`Ok`
|
|
||||||
modal.componentInstance.confirmClicked.subscribe(() =>
|
|
||||||
modal.close()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.openDocumentService
|
|
||||||
.openDocument(doc)
|
|
||||||
.pipe(
|
|
||||||
first(),
|
|
||||||
takeUntil(this.unsubscribeNotifier),
|
|
||||||
takeUntil(this.docChangeNotifier)
|
|
||||||
)
|
|
||||||
.subscribe()
|
|
||||||
}
|
|
||||||
this.updateComponent(useDoc)
|
|
||||||
this.titleSubject
|
|
||||||
.pipe(
|
|
||||||
debounceTime(1000),
|
|
||||||
distinctUntilChanged(),
|
|
||||||
takeUntil(this.docChangeNotifier),
|
|
||||||
takeUntil(this.unsubscribeNotifier)
|
|
||||||
)
|
|
||||||
.subscribe((titleValue) => {
|
|
||||||
if (titleValue !== this.titleInput.value) return
|
|
||||||
this.title = titleValue
|
|
||||||
this.documentForm.patchValue({ title: titleValue })
|
|
||||||
this.documentForm.get('title').markAsDirty()
|
|
||||||
})
|
|
||||||
this.setupDirtyTracking(useDoc, doc)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING))
|
this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING))
|
||||||
this.documentForm.valueChanges
|
this.documentForm.valueChanges
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((values) => {
|
.subscribe(() => {
|
||||||
this.error = null
|
this.error = null
|
||||||
Object.assign(this.document, this.mapFormToDoc(values))
|
const docValues = Object.assign({}, this.documentForm.value)
|
||||||
|
docValues['owner'] =
|
||||||
|
this.documentForm.get('permissions_form').value['owner']
|
||||||
|
docValues['set_permissions'] =
|
||||||
|
this.documentForm.get('permissions_form').value['set_permissions']
|
||||||
|
delete docValues['permissions_form']
|
||||||
|
Object.assign(this.document, docValues)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -543,37 +390,164 @@ export class DocumentDetailComponent
|
|||||||
|
|
||||||
this.route.paramMap
|
this.route.paramMap
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(
|
filter((paramMap) => {
|
||||||
(paramMap) =>
|
// only init when changing docs & section is set
|
||||||
|
return (
|
||||||
+paramMap.get('id') !== this.documentId &&
|
+paramMap.get('id') !== this.documentId &&
|
||||||
paramMap.get('section')?.length > 0
|
paramMap.get('section')?.length > 0
|
||||||
),
|
|
||||||
takeUntil(this.unsubscribeNotifier)
|
|
||||||
)
|
|
||||||
.subscribe((paramMap) => {
|
|
||||||
const documentId = +paramMap.get('id')
|
|
||||||
this.docChangeNotifier.next(documentId)
|
|
||||||
this.loadDocument(documentId)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.route.paramMap
|
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
|
||||||
.subscribe((paramMap) => {
|
|
||||||
const section = paramMap.get('section')
|
|
||||||
if (section) {
|
|
||||||
const navIDKey: string = Object.keys(DocumentDetailNavIDs).find(
|
|
||||||
(navID) => navID.toLowerCase() == section
|
|
||||||
)
|
)
|
||||||
if (navIDKey) {
|
}),
|
||||||
this.activeNavID = DocumentDetailNavIDs[navIDKey]
|
takeUntil(this.unsubscribeNotifier),
|
||||||
|
switchMap((paramMap) => {
|
||||||
|
const documentId = +paramMap.get('id')
|
||||||
|
this.docChangeNotifier.next(documentId)
|
||||||
|
// Dont wait to get the preview
|
||||||
|
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
|
||||||
|
this.http.get(this.previewUrl, { responseType: 'text' }).subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this.previewText = res.toString()
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.previewText = $localize`An error occurred loading content: ${
|
||||||
|
err.message ?? err.toString()
|
||||||
|
}`
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.thumbUrl = this.documentsService.getThumbUrl(documentId)
|
||||||
|
return this.documentsService.get(documentId)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
switchMap((doc) => {
|
||||||
|
this.documentId = doc.id
|
||||||
|
this.suggestions = null
|
||||||
|
const openDocument = this.openDocumentService.getOpenDocument(
|
||||||
|
this.documentId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (openDocument) {
|
||||||
|
if (
|
||||||
|
new Date(doc.modified) > new Date(openDocument.modified) &&
|
||||||
|
!this.modalService.hasOpenModals()
|
||||||
|
) {
|
||||||
|
let modal = this.modalService.open(ConfirmDialogComponent)
|
||||||
|
modal.componentInstance.title = $localize`Document changes detected`
|
||||||
|
modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
|
||||||
|
modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.`
|
||||||
|
modal.componentInstance.cancelBtnClass = 'visually-hidden'
|
||||||
|
modal.componentInstance.btnCaption = $localize`Ok`
|
||||||
|
modal.componentInstance.confirmClicked.subscribe(() =>
|
||||||
|
modal.close()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.documentForm.dirty) {
|
||||||
|
Object.assign(openDocument, this.documentForm.value)
|
||||||
|
openDocument['owner'] =
|
||||||
|
this.documentForm.get('permissions_form').value['owner']
|
||||||
|
openDocument['permissions'] =
|
||||||
|
this.documentForm.get('permissions_form').value[
|
||||||
|
'set_permissions'
|
||||||
|
]
|
||||||
|
delete openDocument['permissions_form']
|
||||||
|
}
|
||||||
|
if (openDocument.__changedFields) {
|
||||||
|
openDocument.__changedFields.forEach((field) => {
|
||||||
|
if (field === 'owner' || field === 'set_permissions') {
|
||||||
|
this.documentForm.get('permissions_form').markAsDirty()
|
||||||
|
} else {
|
||||||
|
this.documentForm.get(field)?.markAsDirty()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.updateComponent(openDocument)
|
||||||
|
} else {
|
||||||
|
this.openDocumentService.openDocument(doc)
|
||||||
|
this.updateComponent(doc)
|
||||||
}
|
}
|
||||||
} else if (paramMap.get('id')) {
|
|
||||||
this.router.navigate(['documents', +paramMap.get('id'), 'details'], {
|
this.titleSubject
|
||||||
|
.pipe(
|
||||||
|
debounceTime(1000),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
takeUntil(this.docChangeNotifier),
|
||||||
|
takeUntil(this.unsubscribeNotifier)
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (titleValue) => {
|
||||||
|
// In the rare case when the field changed just after debounced event was fired.
|
||||||
|
// We dont want to overwrite what's actually in the text field, so just return
|
||||||
|
if (titleValue !== this.titleInput.value) return
|
||||||
|
|
||||||
|
this.title = titleValue
|
||||||
|
this.documentForm.patchValue({ title: titleValue })
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
// doc changed so we manually check dirty in case title was changed
|
||||||
|
if (
|
||||||
|
this.store.getValue().title !==
|
||||||
|
this.documentForm.get('title').value
|
||||||
|
) {
|
||||||
|
this.openDocumentService.setDirty(doc, true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize dirtyCheck
|
||||||
|
this.store = new BehaviorSubject({
|
||||||
|
title: doc.title,
|
||||||
|
content: doc.content,
|
||||||
|
created: doc.created,
|
||||||
|
correspondent: doc.correspondent,
|
||||||
|
document_type: doc.document_type,
|
||||||
|
storage_path: doc.storage_path,
|
||||||
|
archive_serial_number: doc.archive_serial_number,
|
||||||
|
tags: [...doc.tags],
|
||||||
|
permissions_form: {
|
||||||
|
owner: doc.owner,
|
||||||
|
set_permissions: doc.permissions,
|
||||||
|
},
|
||||||
|
custom_fields: [...doc.custom_fields],
|
||||||
|
})
|
||||||
|
|
||||||
|
this.isDirty$ = dirtyCheck(
|
||||||
|
this.documentForm,
|
||||||
|
this.store.asObservable()
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.isDirty$.pipe(
|
||||||
|
takeUntil(this.unsubscribeNotifier),
|
||||||
|
map((dirty) => ({ doc, dirty }))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: ({ doc, dirty }) => {
|
||||||
|
this.openDocumentService.setDirty(doc, dirty, this.getChangedFields())
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.router.navigate(['404'], {
|
||||||
replaceUrl: true,
|
replaceUrl: true,
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.route.paramMap.subscribe((paramMap) => {
|
||||||
|
const section = paramMap.get('section')
|
||||||
|
if (section) {
|
||||||
|
const navIDKey: string = Object.keys(DocumentDetailNavIDs).find(
|
||||||
|
(navID) => navID.toLowerCase() == section
|
||||||
|
)
|
||||||
|
if (navIDKey) {
|
||||||
|
this.activeNavID = DocumentDetailNavIDs[navIDKey]
|
||||||
|
}
|
||||||
|
} else if (paramMap.get('id')) {
|
||||||
|
this.router.navigate(['documents', +paramMap.get('id'), 'details'], {
|
||||||
|
replaceUrl: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
this.hotKeyService
|
this.hotKeyService
|
||||||
.addShortcut({
|
.addShortcut({
|
||||||
keys: 'control.arrowright',
|
keys: 'control.arrowright',
|
||||||
@@ -615,10 +589,7 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
if (this.openDocumentService.isDirty(this.document)) {
|
if (this.openDocumentService.isDirty(this.document)) this.saveEditNext()
|
||||||
if (this.hasNext()) this.saveEditNext()
|
|
||||||
else this.save(true)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,7 +673,14 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
this.title = this.documentTitlePipe.transform(doc.title)
|
this.title = this.documentTitlePipe.transform(doc.title)
|
||||||
this.prepareForm(doc)
|
const docFormValues = Object.assign({}, doc)
|
||||||
|
docFormValues['permissions_form'] = {
|
||||||
|
owner: doc.owner,
|
||||||
|
set_permissions: doc.permissions,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.documentForm.patchValue(docFormValues, { emitEvent: false })
|
||||||
|
if (!this.userCanEdit) this.documentForm.disable()
|
||||||
}
|
}
|
||||||
|
|
||||||
get customFieldFormFields(): FormArray {
|
get customFieldFormFields(): FormArray {
|
||||||
@@ -805,11 +783,7 @@ export class DocumentDetailComponent
|
|||||||
discard() {
|
discard() {
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.get(this.documentId)
|
.get(this.documentId)
|
||||||
.pipe(
|
.pipe(first())
|
||||||
first(),
|
|
||||||
takeUntil(this.unsubscribeNotifier),
|
|
||||||
takeUntil(this.docChangeNotifier)
|
|
||||||
)
|
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (doc) => {
|
next: (doc) => {
|
||||||
Object.assign(this.document, doc)
|
Object.assign(this.document, doc)
|
||||||
@@ -912,11 +886,9 @@ export class DocumentDetailComponent
|
|||||||
.patch(this.getChangedFields())
|
.patch(this.getChangedFields())
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap((updateResult) => {
|
switchMap((updateResult) => {
|
||||||
this.savedViewService.maybeRefreshDocumentCounts()
|
return this.documentListViewService
|
||||||
return this.documentListViewService.getNext(this.documentId).pipe(
|
.getNext(this.documentId)
|
||||||
map((nextDocId) => ({ nextDocId, updateResult })),
|
.pipe(map((nextDocId) => ({ nextDocId, updateResult })))
|
||||||
takeUntil(this.unsubscribeNotifier)
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -926,10 +898,7 @@ export class DocumentDetailComponent
|
|||||||
return this.openDocumentService
|
return this.openDocumentService
|
||||||
.closeDocument(this.document)
|
.closeDocument(this.document)
|
||||||
.pipe(
|
.pipe(
|
||||||
map(
|
map((closeResult) => ({ updateResult, nextDocId, closeResult }))
|
||||||
(closeResult) => ({ updateResult, nextDocId, closeResult }),
|
|
||||||
takeUntil(this.unsubscribeNotifier)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1255,19 +1224,16 @@ export class DocumentDetailComponent
|
|||||||
) {
|
) {
|
||||||
doc.owner = this.store.value.permissions_form.owner
|
doc.owner = this.store.value.permissions_form.owner
|
||||||
}
|
}
|
||||||
return !this.document || this.userCanEditDoc(doc)
|
|
||||||
}
|
|
||||||
|
|
||||||
private userCanEditDoc(doc: Document): boolean {
|
|
||||||
return (
|
return (
|
||||||
this.permissionsService.currentUserCan(
|
!this.document ||
|
||||||
|
(this.permissionsService.currentUserCan(
|
||||||
PermissionAction.Change,
|
PermissionAction.Change,
|
||||||
PermissionType.Document
|
PermissionType.Document
|
||||||
) &&
|
) &&
|
||||||
this.permissionsService.currentUserHasObjectPermissions(
|
this.permissionsService.currentUserHasObjectPermissions(
|
||||||
PermissionAction.Change,
|
PermissionAction.Change,
|
||||||
doc
|
doc
|
||||||
)
|
))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1383,13 +1349,13 @@ export class DocumentDetailComponent
|
|||||||
this.documentForm.updateValueAndValidity()
|
this.documentForm.updateValueAndValidity()
|
||||||
}
|
}
|
||||||
|
|
||||||
editPdf() {
|
splitDocument() {
|
||||||
let modal = this.modalService.open(PDFEditorComponent, {
|
let modal = this.modalService.open(SplitConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
size: 'xl',
|
size: 'lg',
|
||||||
scrollable: true,
|
|
||||||
})
|
})
|
||||||
modal.componentInstance.title = $localize`PDF Editor`
|
modal.componentInstance.title = $localize`Split confirm`
|
||||||
|
modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.`
|
||||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
modal.componentInstance.documentID = this.document.id
|
modal.componentInstance.documentID = this.document.id
|
||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
@@ -1397,30 +1363,24 @@ export class DocumentDetailComponent
|
|||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.bulkEdit([this.document.id], 'edit_pdf', {
|
.bulkEdit([this.document.id], 'split', {
|
||||||
operations: modal.componentInstance.getOperations(),
|
pages: modal.componentInstance.pagesString,
|
||||||
delete_original: modal.componentInstance.deleteOriginal,
|
delete_originals: modal.componentInstance.deleteOriginal,
|
||||||
update_document:
|
|
||||||
modal.componentInstance.editMode == PdfEditorEditMode.Update,
|
|
||||||
include_metadata: modal.componentInstance.includeMetadata,
|
|
||||||
})
|
})
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
$localize`PDF edit operation for "${this.document.title}" will begin in the background.`
|
$localize`Split operation for "${this.document.title}" will begin in the background.`
|
||||||
)
|
)
|
||||||
modal.close()
|
modal.close()
|
||||||
if (modal.componentInstance.deleteOriginal) {
|
|
||||||
this.openDocumentService.closeDocument(this.document)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.componentInstance.buttonsEnabled = true
|
modal.componentInstance.buttonsEnabled = true
|
||||||
}
|
}
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error executing PDF edit operation`,
|
$localize`Error executing split operation`,
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -1428,41 +1388,82 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
printDocument() {
|
rotateDocument() {
|
||||||
const printUrl = this.documentsService.getDownloadUrl(
|
let modal = this.modalService.open(RotateConfirmDialogComponent, {
|
||||||
this.document.id,
|
backdrop: 'static',
|
||||||
false
|
size: 'lg',
|
||||||
)
|
})
|
||||||
this.http
|
modal.componentInstance.title = $localize`Rotate confirm`
|
||||||
.get(printUrl, { responseType: 'blob' })
|
modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of the current document.`
|
||||||
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
|
modal.componentInstance.documentID = this.document.id
|
||||||
|
modal.componentInstance.showPDFNote = false
|
||||||
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe(() => {
|
||||||
next: (blob) => {
|
modal.componentInstance.buttonsEnabled = false
|
||||||
const blobUrl = URL.createObjectURL(blob)
|
this.documentsService
|
||||||
const iframe = document.createElement('iframe')
|
.bulkEdit([this.document.id], 'rotate', {
|
||||||
iframe.style.display = 'none'
|
degrees: modal.componentInstance.degrees,
|
||||||
iframe.src = blobUrl
|
})
|
||||||
document.body.appendChild(iframe)
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
iframe.onload = () => {
|
.subscribe({
|
||||||
try {
|
next: () => {
|
||||||
iframe.contentWindow.focus()
|
this.toastService.show({
|
||||||
iframe.contentWindow.print()
|
content: $localize`Rotation of "${this.document.title}" will begin in the background. Close and re-open the document after the operation has completed to see the changes.`,
|
||||||
iframe.contentWindow.onafterprint = () => {
|
delay: 8000,
|
||||||
document.body.removeChild(iframe)
|
action: this.close.bind(this),
|
||||||
URL.revokeObjectURL(blobUrl)
|
actionName: $localize`Close`,
|
||||||
|
})
|
||||||
|
modal.close()
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
if (modal) {
|
||||||
|
modal.componentInstance.buttonsEnabled = true
|
||||||
}
|
}
|
||||||
} catch (err) {
|
this.toastService.showError(
|
||||||
this.toastService.showError($localize`Print failed.`, err)
|
$localize`Error executing rotate operation`,
|
||||||
document.body.removeChild(iframe)
|
error
|
||||||
URL.revokeObjectURL(blobUrl)
|
)
|
||||||
}
|
},
|
||||||
}
|
})
|
||||||
},
|
})
|
||||||
error: () => {
|
}
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Error loading document for printing.`
|
deletePages() {
|
||||||
)
|
let modal = this.modalService.open(DeletePagesConfirmDialogComponent, {
|
||||||
},
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.title = $localize`Delete pages confirm`
|
||||||
|
modal.componentInstance.messageBold = $localize`This operation will permanently delete the selected pages from the original document.`
|
||||||
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
|
modal.componentInstance.documentID = this.document.id
|
||||||
|
modal.componentInstance.confirmClicked
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
modal.componentInstance.buttonsEnabled = false
|
||||||
|
this.documentsService
|
||||||
|
.bulkEdit([this.document.id], 'delete_pages', {
|
||||||
|
pages: modal.componentInstance.pages,
|
||||||
|
})
|
||||||
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toastService.showInfo(
|
||||||
|
$localize`Delete pages operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.`
|
||||||
|
)
|
||||||
|
modal.close()
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
if (modal) {
|
||||||
|
modal.componentInstance.buttonsEnabled = true
|
||||||
|
}
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error executing delete pages operation`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1487,50 +1488,43 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
private tryRenderTiff() {
|
private tryRenderTiff() {
|
||||||
this.http
|
this.http.get(this.previewUrl, { responseType: 'arraybuffer' }).subscribe({
|
||||||
.get(this.previewUrl, { responseType: 'arraybuffer' })
|
next: (res) => {
|
||||||
.pipe(
|
/* istanbul ignore next */
|
||||||
first(),
|
try {
|
||||||
takeUntil(this.unsubscribeNotifier),
|
// See UTIF.js > _imgLoaded
|
||||||
takeUntil(this.docChangeNotifier)
|
const tiffIfds: any[] = UTIF.decode(res)
|
||||||
)
|
var vsns = tiffIfds,
|
||||||
.subscribe({
|
ma = 0,
|
||||||
next: (res) => {
|
page = vsns[0]
|
||||||
/* istanbul ignore next */
|
if (tiffIfds[0].subIFD) vsns = vsns.concat(tiffIfds[0].subIFD)
|
||||||
try {
|
for (var i = 0; i < vsns.length; i++) {
|
||||||
// See UTIF.js > _imgLoaded
|
var img = vsns[i]
|
||||||
const tiffIfds: any[] = UTIF.decode(res)
|
if (img['t258'] == null || img['t258'].length < 3) continue
|
||||||
var vsns = tiffIfds,
|
var ar = img['t256'] * img['t257']
|
||||||
ma = 0,
|
if (ar > ma) {
|
||||||
page = vsns[0]
|
ma = ar
|
||||||
if (tiffIfds[0].subIFD) vsns = vsns.concat(tiffIfds[0].subIFD)
|
page = img
|
||||||
for (var i = 0; i < vsns.length; i++) {
|
|
||||||
var img = vsns[i]
|
|
||||||
if (img['t258'] == null || img['t258'].length < 3) continue
|
|
||||||
var ar = img['t256'] * img['t257']
|
|
||||||
if (ar > ma) {
|
|
||||||
ma = ar
|
|
||||||
page = img
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
UTIF.decodeImage(res, page, tiffIfds)
|
|
||||||
const rgba = UTIF.toRGBA8(page)
|
|
||||||
const { width: w, height: h } = page
|
|
||||||
var cnv = document.createElement('canvas')
|
|
||||||
cnv.width = w
|
|
||||||
cnv.height = h
|
|
||||||
var ctx = cnv.getContext('2d'),
|
|
||||||
imgd = ctx.createImageData(w, h)
|
|
||||||
for (var i = 0; i < rgba.length; i++) imgd.data[i] = rgba[i]
|
|
||||||
ctx.putImageData(imgd, 0, 0)
|
|
||||||
this.tiffURL = cnv.toDataURL()
|
|
||||||
} catch (err) {
|
|
||||||
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
|
|
||||||
}
|
}
|
||||||
},
|
UTIF.decodeImage(res, page, tiffIfds)
|
||||||
error: (err) => {
|
const rgba = UTIF.toRGBA8(page)
|
||||||
|
const { width: w, height: h } = page
|
||||||
|
var cnv = document.createElement('canvas')
|
||||||
|
cnv.width = w
|
||||||
|
cnv.height = h
|
||||||
|
var ctx = cnv.getContext('2d'),
|
||||||
|
imgd = ctx.createImageData(w, h)
|
||||||
|
for (var i = 0; i < rgba.length; i++) imgd.data[i] = rgba[i]
|
||||||
|
ctx.putImageData(imgd, 0, 0)
|
||||||
|
this.tiffURL = cnv.toDataURL()
|
||||||
|
} catch (err) {
|
||||||
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
|
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
|
||||||
},
|
}
|
||||||
})
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,144 +1,161 @@
|
|||||||
<div class="d-flex flex-wrap gap-4">
|
<div class="d-flex flex-wrap gap-4">
|
||||||
<div class="d-flex flex-wrap align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
<div class="d-flex align-items-center" role="group" aria-label="Select">
|
||||||
<label class="me-2" i18n>Edit:</label>
|
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
<i-bs name="slash-circle"></i-bs> <ng-container i18n>Cancel</ng-container>
|
||||||
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
|
||||||
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
|
||||||
[disabled]="!userCanEditAll || disabled"
|
|
||||||
[editing]="true"
|
|
||||||
[applyOnClose]="applyOnClose"
|
|
||||||
[createRef]="createTag.bind(this)"
|
|
||||||
(opened)="openTagsDropdown()"
|
|
||||||
[(selectionModel)]="tagSelectionModel"
|
|
||||||
[documentCounts]="tagDocumentCounts"
|
|
||||||
(apply)="setTags($event)"
|
|
||||||
shortcutKey="t">
|
|
||||||
</pngx-filterable-dropdown>
|
|
||||||
}
|
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
|
||||||
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
|
||||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
|
||||||
[disabled]="!userCanEditAll || disabled"
|
|
||||||
[editing]="true"
|
|
||||||
[applyOnClose]="applyOnClose"
|
|
||||||
[createRef]="createCorrespondent.bind(this)"
|
|
||||||
(opened)="openCorrespondentDropdown()"
|
|
||||||
[(selectionModel)]="correspondentSelectionModel"
|
|
||||||
[documentCounts]="correspondentDocumentCounts"
|
|
||||||
(apply)="setCorrespondents($event)"
|
|
||||||
shortcutKey="y">
|
|
||||||
</pngx-filterable-dropdown>
|
|
||||||
}
|
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
|
||||||
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
|
||||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
|
||||||
[disabled]="!userCanEditAll || disabled"
|
|
||||||
[editing]="true"
|
|
||||||
[applyOnClose]="applyOnClose"
|
|
||||||
[createRef]="createDocumentType.bind(this)"
|
|
||||||
(opened)="openDocumentTypeDropdown()"
|
|
||||||
[(selectionModel)]="documentTypeSelectionModel"
|
|
||||||
[documentCounts]="documentTypeDocumentCounts"
|
|
||||||
(apply)="setDocumentTypes($event)"
|
|
||||||
shortcutKey="u">
|
|
||||||
</pngx-filterable-dropdown>
|
|
||||||
}
|
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
|
||||||
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
|
||||||
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
|
||||||
[disabled]="!userCanEditAll || disabled"
|
|
||||||
[editing]="true"
|
|
||||||
[applyOnClose]="applyOnClose"
|
|
||||||
[createRef]="createStoragePath.bind(this)"
|
|
||||||
(opened)="openStoragePathDropdown()"
|
|
||||||
[(selectionModel)]="storagePathsSelectionModel"
|
|
||||||
[documentCounts]="storagePathDocumentCounts"
|
|
||||||
(apply)="setStoragePaths($event)"
|
|
||||||
shortcutKey="i">
|
|
||||||
</pngx-filterable-dropdown>
|
|
||||||
}
|
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
|
|
||||||
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
|
|
||||||
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
|
|
||||||
[disabled]="!userCanEditAll"
|
|
||||||
[editing]="true"
|
|
||||||
[applyOnClose]="applyOnClose"
|
|
||||||
[createRef]="createCustomField.bind(this)"
|
|
||||||
(opened)="openCustomFieldsDropdown()"
|
|
||||||
[(selectionModel)]="customFieldsSelectionModel"
|
|
||||||
[documentCounts]="customFieldDocumentCounts"
|
|
||||||
extraButtonTitle="Set values"
|
|
||||||
i18n-extraButtonTitle
|
|
||||||
(extraButton)="setCustomFieldValues($event)"
|
|
||||||
(apply)="setCustomFields($event)">
|
|
||||||
</pngx-filterable-dropdown>
|
|
||||||
}
|
|
||||||
<div class="btn-group">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
|
|
||||||
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="d-flex align-items-center gap-2" role="group" aria-label="Select">
|
||||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
<label class="me-2" i18n>Select:</label>
|
||||||
<div class="btn-toolbar">
|
<div class="btn-group">
|
||||||
<div ngbDropdown>
|
<button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
|
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
||||||
<i-bs name="three-dots"></i-bs>
|
|
||||||
<div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div>
|
|
||||||
</button>
|
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
|
||||||
<button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll">
|
|
||||||
<i-bs name="body-text"></i-bs> <ng-container i18n>Reprocess</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
|
<button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()">
|
||||||
<i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
|
<i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
|
|
||||||
<i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
|
||||||
@if (!awaitingDownload) {
|
|
||||||
<i-bs name="arrow-down"></i-bs>
|
|
||||||
}
|
|
||||||
@if (awaitingDownload) {
|
|
||||||
<div class="spinner-border spinner-border-sm" role="status">
|
|
||||||
<span class="visually-hidden">Preparing download...</span>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
<div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div>
|
|
||||||
</button>
|
|
||||||
<div ngbDropdown class="me-2 d-flex btn-group" role="group">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button>
|
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
|
||||||
<form [formGroup]="downloadForm" class="px-3 py-1">
|
|
||||||
<p class="mb-1" i18n>Include:</p>
|
|
||||||
<div class="form-group ps-3 mb-2">
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
|
|
||||||
<label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
|
|
||||||
<label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
|
|
||||||
<label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||||
</div>
|
<label class="me-2" i18n>Edit:</label>
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||||
|
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
||||||
|
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
||||||
|
[disabled]="!userCanEditAll || disabled"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
[createRef]="createTag.bind(this)"
|
||||||
|
(opened)="openTagsDropdown()"
|
||||||
|
[(selectionModel)]="tagSelectionModel"
|
||||||
|
[documentCounts]="tagDocumentCounts"
|
||||||
|
(apply)="setTags($event)"
|
||||||
|
shortcutKey="t">
|
||||||
|
</pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||||
|
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
||||||
|
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
||||||
|
[disabled]="!userCanEditAll || disabled"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
[createRef]="createCorrespondent.bind(this)"
|
||||||
|
(opened)="openCorrespondentDropdown()"
|
||||||
|
[(selectionModel)]="correspondentSelectionModel"
|
||||||
|
[documentCounts]="correspondentDocumentCounts"
|
||||||
|
(apply)="setCorrespondents($event)"
|
||||||
|
shortcutKey="y">
|
||||||
|
</pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
||||||
|
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
||||||
|
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
||||||
|
[disabled]="!userCanEditAll || disabled"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
[createRef]="createDocumentType.bind(this)"
|
||||||
|
(opened)="openDocumentTypeDropdown()"
|
||||||
|
[(selectionModel)]="documentTypeSelectionModel"
|
||||||
|
[documentCounts]="documentTypeDocumentCounts"
|
||||||
|
(apply)="setDocumentTypes($event)"
|
||||||
|
shortcutKey="u">
|
||||||
|
</pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
||||||
|
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
||||||
|
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
||||||
|
[disabled]="!userCanEditAll || disabled"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
[createRef]="createStoragePath.bind(this)"
|
||||||
|
(opened)="openStoragePathDropdown()"
|
||||||
|
[(selectionModel)]="storagePathsSelectionModel"
|
||||||
|
[documentCounts]="storagePathDocumentCounts"
|
||||||
|
(apply)="setStoragePaths($event)"
|
||||||
|
shortcutKey="i">
|
||||||
|
</pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
|
||||||
|
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
|
||||||
|
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
|
||||||
|
[disabled]="!userCanEditAll"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
[createRef]="createCustomField.bind(this)"
|
||||||
|
(opened)="openCustomFieldsDropdown()"
|
||||||
|
[(selectionModel)]="customFieldsSelectionModel"
|
||||||
|
[documentCounts]="customFieldDocumentCounts"
|
||||||
|
extraButtonTitle="Set values"
|
||||||
|
i18n-extraButtonTitle
|
||||||
|
(extraButton)="setCustomFieldValues($event)"
|
||||||
|
(apply)="setCustomFields($event)">
|
||||||
|
</pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||||
|
<div class="btn-toolbar">
|
||||||
|
|
||||||
<div class="btn-group btn-group-sm">
|
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll">
|
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div>
|
||||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
<div ngbDropdown>
|
||||||
</div>
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
|
||||||
</div>
|
<i-bs name="three-dots"></i-bs>
|
||||||
|
<div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div>
|
||||||
|
</button>
|
||||||
|
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||||
|
<button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll">
|
||||||
|
<i-bs name="body-text"></i-bs> <ng-container i18n>Reprocess</ng-container>
|
||||||
|
</button>
|
||||||
|
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
|
||||||
|
<i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
|
||||||
|
</button>
|
||||||
|
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
|
||||||
|
<i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
||||||
|
@if (!awaitingDownload) {
|
||||||
|
<i-bs name="arrow-down"></i-bs>
|
||||||
|
}
|
||||||
|
@if (awaitingDownload) {
|
||||||
|
<div class="spinner-border spinner-border-sm" role="status">
|
||||||
|
<span class="visually-hidden">Preparing download...</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div>
|
||||||
|
</button>
|
||||||
|
<div ngbDropdown class="me-2 d-flex btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button>
|
||||||
|
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||||
|
<form [formGroup]="downloadForm" class="px-3 py-1">
|
||||||
|
<p class="mb-1" i18n>Include:</p>
|
||||||
|
<div class="form-group ps-3 mb-2">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
|
||||||
|
<label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
|
||||||
|
<label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
|
||||||
|
<label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll">
|
||||||
|
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,3 @@
|
|||||||
.dropdown-menu{
|
.dropdown-menu{
|
||||||
--bs-dropdown-min-width: 12rem;
|
--bs-dropdown-min-width: 12rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-group .btn {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user