Merge branch 'dev' into feature-devcontainer
3
.codespellrc
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[codespell]
|
||||||
|
write-changes = True
|
||||||
|
ignore-words-list = criterias,afterall,valeu,ureue,equest,ure,assertIn
|
1
.env
@ -1,2 +1 @@
|
|||||||
COMPOSE_PROJECT_NAME=paperless
|
COMPOSE_PROJECT_NAME=paperless
|
||||||
export PROMPT="(pipenv-projectname)$P$G"
|
|
||||||
|
22
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@ -20,7 +20,7 @@ body:
|
|||||||
- [The troubleshooting documentation](https://docs.paperless-ngx.com/troubleshooting/).
|
- [The troubleshooting documentation](https://docs.paperless-ngx.com/troubleshooting/).
|
||||||
- [The installation instructions](https://docs.paperless-ngx.com/setup/#installation).
|
- [The installation instructions](https://docs.paperless-ngx.com/setup/#installation).
|
||||||
- [Existing issues and discussions](https://github.com/paperless-ngx/paperless-ngx/search?q=&type=issues).
|
- [Existing issues and discussions](https://github.com/paperless-ngx/paperless-ngx/search?q=&type=issues).
|
||||||
- Disable any customer container initialization scripts, if using
|
- Disable any custom container initialization scripts, if using
|
||||||
|
|
||||||
If you encounter issues while installing or configuring Paperless-ngx, please post in the ["Support" section of the discussions](https://github.com/paperless-ngx/paperless-ngx/discussions/new?category=support).
|
If you encounter issues while installing or configuring Paperless-ngx, please post in the ["Support" section of the discussions](https://github.com/paperless-ngx/paperless-ngx/discussions/new?category=support).
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@ -86,6 +86,12 @@ body:
|
|||||||
description: Note there are significant differences from the official image and linuxserver.io, please check if your issue is specific to the third-party image.
|
description: Note there are significant differences from the official image and linuxserver.io, please check if your issue is specific to the third-party image.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: system-status
|
||||||
|
attributes:
|
||||||
|
label: System status
|
||||||
|
description: If available, copy & paste the system status output from Settings > System Status > Copy
|
||||||
|
render: json
|
||||||
- type: input
|
- type: input
|
||||||
id: browser
|
id: browser
|
||||||
attributes:
|
attributes:
|
||||||
@ -97,8 +103,14 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Configuration changes
|
label: Configuration changes
|
||||||
description: Any configuration changes you made in `docker-compose.yml`, `docker-compose.env` or `paperless.conf`.
|
description: Any configuration changes you made in `docker-compose.yml`, `docker-compose.env` or `paperless.conf`.
|
||||||
- type: input
|
- type: checkboxes
|
||||||
id: other
|
id: required-checks
|
||||||
attributes:
|
attributes:
|
||||||
label: Other
|
label: Please confirm the following
|
||||||
description: Any other relevant details.
|
options:
|
||||||
|
- label: I believe this issue is a bug that affects all users of Paperless-ngx, not something specific to my installation.
|
||||||
|
required: true
|
||||||
|
- label: I have already searched for relevant existing issues and discussions before opening this report.
|
||||||
|
required: true
|
||||||
|
- label: I have updated the title field above with a concise description.
|
||||||
|
required: true
|
||||||
|
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -8,7 +8,11 @@ Note: All PRs with code changes should be targeted to the `dev` branch, pure doc
|
|||||||
Please include a summary of the change and which issue is fixed (if any) and any relevant motivation / context. List any dependencies that are required for this change. If appropriate, please include an explanation of how your proposed change can be tested. Screenshots and / or videos can also be helpful if appropriate.
|
Please include a summary of the change and which issue is fixed (if any) and any relevant motivation / context. List any dependencies that are required for this change. If appropriate, please include an explanation of how your proposed change can be tested. Screenshots and / or videos can also be helpful if appropriate.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
Fixes # (issue)
|
<!--
|
||||||
|
⚠️ 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. If that is not currently the case, please open a feature request instead of this PR to gather feedback from both users and the project maintainers.
|
||||||
|
-->
|
||||||
|
|
||||||
|
Closes #(issue or discussion)
|
||||||
|
|
||||||
## Type of change
|
## Type of change
|
||||||
|
|
||||||
@ -17,10 +21,11 @@ What type of change does your PR introduce to Paperless-ngx?
|
|||||||
NOTE: Please check only one box!
|
NOTE: Please check only one box!
|
||||||
-->
|
-->
|
||||||
|
|
||||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
- [ ] Bug fix: non-breaking change which fixes an issue.
|
||||||
- [ ] New feature (non-breaking change which adds functionality)
|
- [ ] New feature / Enhancement: non-breaking change which adds functionality. _Please read the important note above._
|
||||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
- [ ] Breaking change: fix or feature that would cause existing functionality to not work as expected.
|
||||||
- [ ] Other (please explain):
|
- [ ] Documentation only.
|
||||||
|
- [ ] Other. Please explain:
|
||||||
|
|
||||||
## Checklist:
|
## Checklist:
|
||||||
|
|
||||||
|
7
.github/dependabot.yml
vendored
@ -47,11 +47,16 @@ updates:
|
|||||||
# Add reviewers
|
# Add reviewers
|
||||||
reviewers:
|
reviewers:
|
||||||
- "paperless-ngx/backend"
|
- "paperless-ngx/backend"
|
||||||
|
ignore:
|
||||||
|
- dependency-name: "uvicorn"
|
||||||
|
- dependency-name: "djangorestframework"
|
||||||
|
versions:
|
||||||
|
- "3.15.0"
|
||||||
|
- "3.15.1"
|
||||||
groups:
|
groups:
|
||||||
development:
|
development:
|
||||||
patterns:
|
patterns:
|
||||||
- "*pytest*"
|
- "*pytest*"
|
||||||
- "black"
|
|
||||||
- "ruff"
|
- "ruff"
|
||||||
- "mkdocs-material"
|
- "mkdocs-material"
|
||||||
django:
|
django:
|
||||||
|
109
.github/workflows/ci.yml
vendored
@ -16,7 +16,7 @@ on:
|
|||||||
env:
|
env:
|
||||||
# This is the version of pipenv all the steps will use
|
# This is the version of pipenv all the steps will use
|
||||||
# If changing this, change Dockerfile
|
# If changing this, change Dockerfile
|
||||||
DEFAULT_PIP_ENV_VERSION: "2023.10.24"
|
DEFAULT_PIP_ENV_VERSION: "2023.12.1"
|
||||||
# This is the default version of Python to use in most steps which aren't specific
|
# This is the default version of Python to use in most steps which aren't specific
|
||||||
DEFAULT_PYTHON_VERSION: "3.10"
|
DEFAULT_PYTHON_VERSION: "3.10"
|
||||||
|
|
||||||
@ -37,15 +37,15 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
-
|
-
|
||||||
name: Install python
|
name: Install python
|
||||||
uses: actions/setup-python@v4
|
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
|
||||||
uses: pre-commit/action@v3.0.0
|
uses: pre-commit/action@v3.0.1
|
||||||
|
|
||||||
documentation:
|
documentation:
|
||||||
name: "Build Documentation"
|
name: "Build & Deploy Documentation"
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs:
|
needs:
|
||||||
- pre-commit
|
- pre-commit
|
||||||
@ -56,7 +56,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Set up Python
|
name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
cache: "pipenv"
|
cache: "pipenv"
|
||||||
@ -77,34 +77,22 @@ jobs:
|
|||||||
name: Make documentation
|
name: Make documentation
|
||||||
run: |
|
run: |
|
||||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} run mkdocs build --config-file ./mkdocs.yml
|
pipenv --python ${{ steps.setup-python.outputs.python-version }} run mkdocs build --config-file ./mkdocs.yml
|
||||||
|
-
|
||||||
|
name: Deploy documentation
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME"
|
||||||
|
git config --global user.name "${{ github.actor }}"
|
||||||
|
git config --global user.email "${{ github.actor }}@users.noreply.github.com"
|
||||||
|
pipenv --python ${{ steps.setup-python.outputs.python-version }} run mkdocs gh-deploy --force --no-history
|
||||||
-
|
-
|
||||||
name: Upload artifact
|
name: Upload artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: documentation
|
name: documentation
|
||||||
path: site/
|
path: site/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
documentation-deploy:
|
|
||||||
name: "Deploy Documentation"
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
needs:
|
|
||||||
- documentation
|
|
||||||
steps:
|
|
||||||
-
|
|
||||||
name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
-
|
|
||||||
name: Deploy docs
|
|
||||||
uses: mhausenblas/mkdocs-deploy-gh-pages@master
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
CUSTOM_DOMAIN: docs.paperless-ngx.com
|
|
||||||
CONFIG_FILE: mkdocs.yml
|
|
||||||
EXTRA_PACKAGES: build-base
|
|
||||||
REQUIREMENTS: docs/requirements.txt
|
|
||||||
|
|
||||||
tests-backend:
|
tests-backend:
|
||||||
name: "Backend Tests (Python ${{ matrix.python-version }})"
|
name: "Backend Tests (Python ${{ matrix.python-version }})"
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
@ -121,12 +109,12 @@ jobs:
|
|||||||
-
|
-
|
||||||
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@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
cache: "pipenv"
|
cache: "pipenv"
|
||||||
@ -167,7 +155,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Upload coverage
|
name: Upload coverage
|
||||||
if: ${{ matrix.python-version == env.DEFAULT_PYTHON_VERSION }}
|
if: ${{ matrix.python-version == env.DEFAULT_PYTHON_VERSION }}
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: backend-coverage-report
|
name: backend-coverage-report
|
||||||
path: src/coverage.xml
|
path: src/coverage.xml
|
||||||
@ -177,11 +165,11 @@ jobs:
|
|||||||
name: Stop containers
|
name: Stop containers
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
docker compose --file ${GITHUB_WORKSPACE}/docker/compose/docker-compose.ci-test.yml logs
|
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml logs
|
||||||
docker compose --file ${GITHUB_WORKSPACE}/docker/compose/docker-compose.ci-test.yml down
|
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml down
|
||||||
|
|
||||||
install-frontend-depedendencies:
|
install-frontend-depedendencies:
|
||||||
name: "Install Frontend Dependendencies"
|
name: "Install Frontend Dependencies"
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs:
|
needs:
|
||||||
- pre-commit
|
- pre-commit
|
||||||
@ -194,9 +182,9 @@ jobs:
|
|||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: 'src-ui/package-lock.json'
|
cache-dependency-path: 'src-ui/package-lock.json'
|
||||||
- name: Cache frontend depdendencies
|
- name: Cache frontend dependencies
|
||||||
id: cache-frontend-deps
|
id: cache-frontend-deps
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.npm
|
~/.npm
|
||||||
@ -231,9 +219,9 @@ jobs:
|
|||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: 'src-ui/package-lock.json'
|
cache-dependency-path: 'src-ui/package-lock.json'
|
||||||
- name: Cache frontend depdendencies
|
- name: Cache frontend dependencies
|
||||||
id: cache-frontend-deps
|
id: cache-frontend-deps
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.npm
|
~/.npm
|
||||||
@ -250,7 +238,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Upload Jest coverage
|
name: Upload Jest coverage
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: jest-coverage-report-${{ matrix.shard-index }}
|
name: jest-coverage-report-${{ matrix.shard-index }}
|
||||||
path: |
|
path: |
|
||||||
@ -265,9 +253,9 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Upload Playwright test results
|
name: Upload Playwright test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report-${{ matrix.shard-index }}
|
||||||
path: src-ui/playwright-report
|
path: src-ui/playwright-report
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
@ -281,13 +269,21 @@ jobs:
|
|||||||
-
|
-
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
-
|
-
|
||||||
name: Download frontend coverage
|
name: Download frontend jest coverage
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: src-ui/coverage/
|
path: src-ui/coverage/
|
||||||
|
pattern: jest-coverage-report-*
|
||||||
|
-
|
||||||
|
name: Download frontend playwright coverage
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: src-ui/coverage/
|
||||||
|
pattern: playwright-report-*
|
||||||
|
merge-multiple: true
|
||||||
-
|
-
|
||||||
name: Upload frontend coverage to Codecov
|
name: Upload frontend coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
# not required for public repos, but intermittently fails otherwise
|
# not required for public repos, but intermittently fails otherwise
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
@ -297,13 +293,13 @@ jobs:
|
|||||||
files: '!coverage.xml'
|
files: '!coverage.xml'
|
||||||
-
|
-
|
||||||
name: Download backend coverage
|
name: Download backend coverage
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: backend-coverage-report
|
name: backend-coverage-report
|
||||||
path: src/
|
path: src/
|
||||||
-
|
-
|
||||||
name: Upload coverage to Codecov
|
name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
# not required for public repos, but intermittently fails otherwise
|
# not required for public repos, but intermittently fails otherwise
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
@ -314,7 +310,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-22.04
|
runs-on: ubuntu-22.04
|
||||||
if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v'))
|
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
|
||||||
@ -428,7 +424,7 @@ jobs:
|
|||||||
docker cp frontend-extract:/usr/src/paperless/src/documents/static/frontend src/documents/static/frontend/
|
docker cp frontend-extract:/usr/src/paperless/src/documents/static/frontend src/documents/static/frontend/
|
||||||
-
|
-
|
||||||
name: Upload frontend artifact
|
name: Upload frontend artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: frontend-compiled
|
name: frontend-compiled
|
||||||
path: src/documents/static/frontend/
|
path: src/documents/static/frontend/
|
||||||
@ -438,6 +434,7 @@ jobs:
|
|||||||
name: "Build Release"
|
name: "Build Release"
|
||||||
needs:
|
needs:
|
||||||
- build-docker-image
|
- build-docker-image
|
||||||
|
- documentation
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
@ -446,7 +443,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Set up Python
|
name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
cache: "pipenv"
|
cache: "pipenv"
|
||||||
@ -472,13 +469,13 @@ jobs:
|
|||||||
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@v3
|
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@v3
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: documentation
|
name: documentation
|
||||||
path: docs/_build/html/
|
path: docs/_build/html/
|
||||||
@ -544,7 +541,7 @@ jobs:
|
|||||||
tar -cJf paperless-ngx.tar.xz paperless-ngx/
|
tar -cJf paperless-ngx.tar.xz paperless-ngx/
|
||||||
-
|
-
|
||||||
name: Upload release artifact
|
name: Upload release artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release
|
name: release
|
||||||
path: dist/paperless-ngx.tar.xz
|
path: dist/paperless-ngx.tar.xz
|
||||||
@ -563,7 +560,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Download release artifact
|
name: Download release artifact
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release
|
name: release
|
||||||
path: ./
|
path: ./
|
||||||
@ -580,7 +577,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Create Release and Changelog
|
name: Create Release and Changelog
|
||||||
id: create-release
|
id: create-release
|
||||||
uses: release-drafter/release-drafter@v5
|
uses: release-drafter/release-drafter@v6
|
||||||
with:
|
with:
|
||||||
name: Paperless-ngx ${{ steps.get_version.outputs.version }}
|
name: Paperless-ngx ${{ steps.get_version.outputs.version }}
|
||||||
tag: ${{ steps.get_version.outputs.version }}
|
tag: ${{ steps.get_version.outputs.version }}
|
||||||
@ -614,7 +611,7 @@ jobs:
|
|||||||
ref: main
|
ref: main
|
||||||
-
|
-
|
||||||
name: Set up Python
|
name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
cache: "pipenv"
|
cache: "pipenv"
|
||||||
@ -643,12 +640,12 @@ jobs:
|
|||||||
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@v6
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const { repo, owner } = context.repo;
|
const { repo, owner } = context.repo;
|
||||||
const result = await github.rest.pulls.create({
|
const result = await github.rest.pulls.create({
|
||||||
title: '[Documentation] Add ${{ needs.publish-release.outputs.version }} changelog',
|
title: 'Documentation: Add ${{ needs.publish-release.outputs.version }} changelog',
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
head: '${{ needs.publish-release.outputs.version }}-changelog',
|
head: '${{ needs.publish-release.outputs.version }}-changelog',
|
||||||
|
6
.github/workflows/cleanup-tags.yml
vendored
@ -33,7 +33,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Clean temporary images
|
name: Clean temporary images
|
||||||
if: "${{ env.TOKEN != '' }}"
|
if: "${{ env.TOKEN != '' }}"
|
||||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.3.0
|
uses: stumpylog/image-cleaner-action/ephemeral@v0.7.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "${{ github.repository_owner }}"
|
owner: "${{ github.repository_owner }}"
|
||||||
@ -41,7 +41,7 @@ jobs:
|
|||||||
package_name: "${{ matrix.primary-name }}"
|
package_name: "${{ matrix.primary-name }}"
|
||||||
scheme: "branch"
|
scheme: "branch"
|
||||||
repo_name: "paperless-ngx"
|
repo_name: "paperless-ngx"
|
||||||
match_regex: "feature-"
|
match_regex: "(feature|fix)"
|
||||||
do_delete: "true"
|
do_delete: "true"
|
||||||
|
|
||||||
cleanup-untagged-images:
|
cleanup-untagged-images:
|
||||||
@ -61,7 +61,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Clean untagged images
|
name: Clean untagged images
|
||||||
if: "${{ env.TOKEN != '' }}"
|
if: "${{ env.TOKEN != '' }}"
|
||||||
uses: stumpylog/image-cleaner-action/untagged@v0.3.0
|
uses: stumpylog/image-cleaner-action/untagged@v0.7.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "${{ github.repository_owner }}"
|
owner: "${{ github.repository_owner }}"
|
||||||
|
4
.github/workflows/codeql-analysis.yml
vendored
@ -42,7 +42,7 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@ -51,4 +51,4 @@ jobs:
|
|||||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
uses: github/codeql-action/analyze@v3
|
||||||
|
34
.github/workflows/crowdin.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
name: Crowdin Action
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: '2 */12 * * *'
|
||||||
|
push:
|
||||||
|
paths: [
|
||||||
|
'src/locale/**',
|
||||||
|
'src-ui/messages.xlf',
|
||||||
|
'src-ui/src/locale/**'
|
||||||
|
]
|
||||||
|
branches: [ dev ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
synchronize-with-crowdin:
|
||||||
|
name: Crowdin Sync
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: crowdin action
|
||||||
|
uses: crowdin/github-action@v2
|
||||||
|
with:
|
||||||
|
upload_translations: false
|
||||||
|
download_translations: true
|
||||||
|
crowdin_branch_name: 'dev'
|
||||||
|
localization_branch_name: l10n_dev
|
||||||
|
pull_request_labels: 'skip-changelog, translation'
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
2
.github/workflows/project-actions.yml
vendored
@ -22,6 +22,6 @@ jobs:
|
|||||||
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
|
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
|
||||||
steps:
|
steps:
|
||||||
- name: Label PR with release-drafter
|
- name: Label PR with release-drafter
|
||||||
uses: release-drafter/release-drafter@v5
|
uses: release-drafter/release-drafter@v6
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
182
.github/workflows/repo-maintenance.yml
vendored
@ -18,17 +18,17 @@ jobs:
|
|||||||
name: 'Stale'
|
name: 'Stale'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v8
|
- uses: actions/stale@v9
|
||||||
with:
|
with:
|
||||||
days-before-stale: 7
|
days-before-stale: 7
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
any-of-labels: 'cant-reproduce,not a bug'
|
any-of-labels: 'stale,cant-reproduce,not a bug'
|
||||||
stale-issue-label: stale
|
stale-issue-label: stale
|
||||||
stale-pr-label: stale
|
stale-pr-label: stale
|
||||||
stale-issue-message: >
|
stale-issue-message: >
|
||||||
This issue has been automatically marked as stale because it has not had
|
This issue has been automatically marked as stale because it has not had
|
||||||
recent activity. It will be closed if no further activity occurs. Thank you
|
recent activity. It will be closed if no further activity occurs. Thank you
|
||||||
for your contributions.
|
for your contributions. See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
||||||
lock-threads:
|
lock-threads:
|
||||||
name: 'Lock Old Threads'
|
name: 'Lock Old Threads'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -43,14 +43,17 @@ jobs:
|
|||||||
This issue has been automatically locked since there
|
This issue has been automatically locked since there
|
||||||
has not been any recent activity after it was closed.
|
has not been any recent activity after it was closed.
|
||||||
Please open a new discussion or issue for related concerns.
|
Please open a new discussion or issue for related concerns.
|
||||||
|
See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
||||||
pr-comment: >
|
pr-comment: >
|
||||||
This pull request has been automatically locked since there
|
This pull request has been automatically locked since there
|
||||||
has not been any recent activity after it was closed.
|
has not been any recent activity after it was closed.
|
||||||
Please open a new discussion or issue for related concerns.
|
Please open a new discussion or issue for related concerns.
|
||||||
|
See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
||||||
discussion-comment: >
|
discussion-comment: >
|
||||||
This discussion has been automatically locked since there
|
This discussion has been automatically locked since there
|
||||||
has not been any recent activity after it was closed.
|
has not been any recent activity after it was closed.
|
||||||
Please open a new discussion for related concerns.
|
Please open a new discussion for related concerns.
|
||||||
|
See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
||||||
close-answered-discussions:
|
close-answered-discussions:
|
||||||
name: 'Close Answered Discussions'
|
name: 'Close Answered Discussions'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -81,7 +84,7 @@ jobs:
|
|||||||
console.log(`Found ${result.repository.discussions.nodes.length} open answered discussions`)
|
console.log(`Found ${result.repository.discussions.nodes.length} open answered discussions`)
|
||||||
|
|
||||||
for (const discussion of result.repository.discussions.nodes) {
|
for (const discussion of result.repository.discussions.nodes) {
|
||||||
console.log(`Closing dicussion #${discussion.number} (${discussion.id})`)
|
console.log(`Closing discussion #${discussion.number} (${discussion.id})`)
|
||||||
|
|
||||||
const addCommentMutation = `mutation($discussion:ID!, $body:String!) {
|
const addCommentMutation = `mutation($discussion:ID!, $body:String!) {
|
||||||
addDiscussionComment(input:{discussionId:$discussion, body:$body}) {
|
addDiscussionComment(input:{discussionId:$discussion, body:$body}) {
|
||||||
@ -90,7 +93,7 @@ jobs:
|
|||||||
}`;
|
}`;
|
||||||
const commentVariables = {
|
const commentVariables = {
|
||||||
discussion: discussion.id,
|
discussion: discussion.id,
|
||||||
body: 'This discussion has been automatically closed because it was marked as answered.',
|
body: 'This discussion has been automatically closed because it was marked as answered. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.',
|
||||||
}
|
}
|
||||||
await github.graphql(addCommentMutation, commentVariables)
|
await github.graphql(addCommentMutation, commentVariables)
|
||||||
|
|
||||||
@ -107,3 +110,172 @@ jobs:
|
|||||||
|
|
||||||
await sleep(1000)
|
await sleep(1000)
|
||||||
}
|
}
|
||||||
|
close-outdated-discussions:
|
||||||
|
name: 'Close Outdated Discussions'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUTOFF_DAYS = 180;
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - CUTOFF_DAYS);
|
||||||
|
|
||||||
|
const query = `query(
|
||||||
|
$owner:String!,
|
||||||
|
$name:String!,
|
||||||
|
$supportCategory:ID!,
|
||||||
|
$generalCategory:ID!,
|
||||||
|
) {
|
||||||
|
supportDiscussions: repository(owner:$owner, name:$name){
|
||||||
|
discussions(
|
||||||
|
categoryId:$supportCategory,
|
||||||
|
last:50,
|
||||||
|
answered:false,
|
||||||
|
states:[OPEN],
|
||||||
|
) {
|
||||||
|
nodes {
|
||||||
|
id,
|
||||||
|
number,
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
generalDiscussions: repository(owner:$owner, name:$name){
|
||||||
|
discussions(
|
||||||
|
categoryId:$generalCategory,
|
||||||
|
last:50,
|
||||||
|
states:[OPEN],
|
||||||
|
) {
|
||||||
|
nodes {
|
||||||
|
id,
|
||||||
|
number,
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const variables = {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
name: context.repo.repo,
|
||||||
|
supportCategory: "DIC_kwDOG1Zs184CBKWK",
|
||||||
|
generalCategory: "DIC_kwDOG1Zs184CBKWJ"
|
||||||
|
}
|
||||||
|
const result = await github.graphql(query, variables);
|
||||||
|
const combinedDiscussions = [
|
||||||
|
...result.supportDiscussions.discussions.nodes,
|
||||||
|
...result.generalDiscussions.discussions.nodes,
|
||||||
|
]
|
||||||
|
|
||||||
|
console.log(`Checking ${combinedDiscussions.length} open discussions`);
|
||||||
|
|
||||||
|
for (const discussion of combinedDiscussions) {
|
||||||
|
if (new Date(discussion.updatedAt) < cutoff) {
|
||||||
|
console.log(`Closing outdated discussion #${discussion.number} (${discussion.id}), last updated at ${discussion.updatedAt}`);
|
||||||
|
const addCommentMutation = `mutation($discussion:ID!, $body:String!) {
|
||||||
|
addDiscussionComment(input:{discussionId:$discussion, body:$body}) {
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const commentVariables = {
|
||||||
|
discussion: discussion.id,
|
||||||
|
body: 'This discussion has been automatically closed due to inactivity. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.',
|
||||||
|
}
|
||||||
|
await github.graphql(addCommentMutation, commentVariables);
|
||||||
|
|
||||||
|
const closeDiscussionMutation = `mutation($discussion:ID!, $reason:DiscussionCloseReason!) {
|
||||||
|
closeDiscussion(input:{discussionId:$discussion, reason:$reason}) {
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const closeVariables = {
|
||||||
|
discussion: discussion.id,
|
||||||
|
reason: "OUTDATED",
|
||||||
|
}
|
||||||
|
await github.graphql(closeDiscussionMutation, closeVariables);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close-unsupported-feature-requests:
|
||||||
|
name: 'Close Unsupported Feature Requests'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUTOFF_1_DAYS = 180;
|
||||||
|
const CUTOFF_1_COUNT = 5;
|
||||||
|
const CUTOFF_2_DAYS = 365;
|
||||||
|
const CUTOFF_2_COUNT = 10;
|
||||||
|
|
||||||
|
const cutoff1Date = new Date();
|
||||||
|
cutoff1Date.setDate(cutoff1Date.getDate() - CUTOFF_1_DAYS);
|
||||||
|
const cutoff2Date = new Date();
|
||||||
|
cutoff2Date.setDate(cutoff2Date.getDate() - CUTOFF_2_DAYS);
|
||||||
|
|
||||||
|
const query = `query(
|
||||||
|
$owner:String!,
|
||||||
|
$name:String!,
|
||||||
|
$featureRequestsCategory:ID!,
|
||||||
|
) {
|
||||||
|
repository(owner:$owner, name:$name){
|
||||||
|
discussions(
|
||||||
|
categoryId:$featureRequestsCategory,
|
||||||
|
last:100,
|
||||||
|
states:[OPEN],
|
||||||
|
) {
|
||||||
|
nodes {
|
||||||
|
id,
|
||||||
|
number,
|
||||||
|
updatedAt,
|
||||||
|
upvoteCount,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const variables = {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
name: context.repo.repo,
|
||||||
|
featureRequestsCategory: "DIC_kwDOG1Zs184CBNr4"
|
||||||
|
}
|
||||||
|
const result = await github.graphql(query, variables);
|
||||||
|
|
||||||
|
for (const discussion of result.repository.discussions.nodes) {
|
||||||
|
const discussionDate = new Date(discussion.updatedAt);
|
||||||
|
if ((discussionDate < cutoff1Date && discussion.upvoteCount < CUTOFF_1_COUNT) ||
|
||||||
|
(discussionDate < cutoff2Date && discussion.upvoteCount < CUTOFF_2_COUNT)) {
|
||||||
|
console.log(`Closing discussion #${discussion.number} (${discussion.id}), last updated at ${discussion.updatedAt} with votes ${discussion.upvoteCount}`);
|
||||||
|
const addCommentMutation = `mutation($discussion:ID!, $body:String!) {
|
||||||
|
addDiscussionComment(input:{discussionId:$discussion, body:$body}) {
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const commentVariables = {
|
||||||
|
discussion: discussion.id,
|
||||||
|
body: 'This discussion has been automatically closed due to lack of community support. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.',
|
||||||
|
}
|
||||||
|
await github.graphql(addCommentMutation, commentVariables);
|
||||||
|
|
||||||
|
const closeDiscussionMutation = `mutation($discussion:ID!, $reason:DiscussionCloseReason!) {
|
||||||
|
closeDiscussion(input:{discussionId:$discussion, reason:$reason}) {
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const closeVariables = {
|
||||||
|
discussion: discussion.id,
|
||||||
|
reason: "OUTDATED",
|
||||||
|
}
|
||||||
|
await github.graphql(closeDiscussionMutation, closeVariables);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1
.gitignore
vendored
@ -22,6 +22,7 @@ var/
|
|||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
|
/src/paperless_mail/templates/node_modules
|
||||||
|
|
||||||
# PyInstaller
|
# PyInstaller
|
||||||
# Usually these files are written by a python script from a template
|
# Usually these files are written by a python script from a template
|
||||||
|
@ -5,12 +5,14 @@
|
|||||||
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: v4.5.0
|
rev: v4.6.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-docstring-first
|
- id: check-docstring-first
|
||||||
- id: check-json
|
- id: check-json
|
||||||
exclude: "tsconfig.*json"
|
exclude: "tsconfig.*json"
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
|
args:
|
||||||
|
- "--unsafe"
|
||||||
- id: check-toml
|
- id: check-toml
|
||||||
- id: check-executables-have-shebangs
|
- id: check-executables-have-shebangs
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
@ -26,6 +28,14 @@ repos:
|
|||||||
- svg
|
- svg
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: detect-private-key
|
- id: detect-private-key
|
||||||
|
- repo: https://github.com/codespell-project/codespell
|
||||||
|
rev: v2.3.0
|
||||||
|
hooks:
|
||||||
|
- id: codespell
|
||||||
|
exclude: "(^src-ui/src/locale/)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)"
|
||||||
|
exclude_types:
|
||||||
|
- pofile
|
||||||
|
- json
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
rev: 'v3.1.0'
|
rev: 'v3.1.0'
|
||||||
hooks:
|
hooks:
|
||||||
@ -37,13 +47,10 @@ repos:
|
|||||||
exclude: "(^Pipfile\\.lock$)"
|
exclude: "(^Pipfile\\.lock$)"
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: 'v0.1.5'
|
rev: 'v0.4.8'
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
- id: ruff-format
|
||||||
rev: 23.11.0
|
|
||||||
hooks:
|
|
||||||
- id: black
|
|
||||||
# Dockerfile hooks
|
# Dockerfile hooks
|
||||||
- repo: https://github.com/AleksaC/hadolint-py
|
- repo: https://github.com/AleksaC/hadolint-py
|
||||||
rev: v2.12.0.3
|
rev: v2.12.0.3
|
||||||
@ -57,6 +64,6 @@ repos:
|
|||||||
args:
|
args:
|
||||||
- "--tab"
|
- "--tab"
|
||||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||||
rev: "v0.9.0.6"
|
rev: "v0.10.0.1"
|
||||||
hooks:
|
hooks:
|
||||||
- id: shellcheck
|
- id: shellcheck
|
||||||
|
42
.ruff.toml
@ -1,8 +1,3 @@
|
|||||||
# https://beta.ruff.rs/docs/settings/
|
|
||||||
# https://beta.ruff.rs/docs/rules/
|
|
||||||
extend-select = ["I", "W", "UP", "COM", "DJ", "EXE", "ISC", "ICN", "G201", "INP", "PIE", "RSE", "SIM", "TID", "PLC", "PLE", "RUF"]
|
|
||||||
# TODO PTH
|
|
||||||
ignore = ["DJ001", "SIM105", "RUF012"]
|
|
||||||
fix = true
|
fix = true
|
||||||
line-length = 88
|
line-length = 88
|
||||||
respect-gitignore = true
|
respect-gitignore = true
|
||||||
@ -11,13 +6,42 @@ target-version = "py39"
|
|||||||
output-format = "grouped"
|
output-format = "grouped"
|
||||||
show-fixes = true
|
show-fixes = true
|
||||||
|
|
||||||
[per-file-ignores]
|
# https://docs.astral.sh/ruff/settings/
|
||||||
|
# https://docs.astral.sh/ruff/rules/
|
||||||
|
[lint]
|
||||||
|
extend-select = [
|
||||||
|
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
||||||
|
"I", # https://docs.astral.sh/ruff/rules/#isort-i
|
||||||
|
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
||||||
|
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
|
||||||
|
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
|
||||||
|
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
|
||||||
|
"ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
|
||||||
|
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
|
||||||
|
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
|
||||||
|
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
|
||||||
|
"PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
|
||||||
|
"Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q
|
||||||
|
"RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse
|
||||||
|
"T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
|
||||||
|
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
|
||||||
|
"TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
|
||||||
|
"TCH", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tch
|
||||||
|
"PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
||||||
|
"PLE", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
||||||
|
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
|
||||||
|
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
|
||||||
|
]
|
||||||
|
# TODO PTH https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
|
||||||
|
ignore = ["DJ001", "SIM105", "RUF012"]
|
||||||
|
|
||||||
|
[lint.per-file-ignores]
|
||||||
".github/scripts/*.py" = ["E501", "INP001", "SIM117"]
|
".github/scripts/*.py" = ["E501", "INP001", "SIM117"]
|
||||||
"docker/wait-for-redis.py" = ["INP001"]
|
"docker/wait-for-redis.py" = ["INP001", "T201"]
|
||||||
"*/tests/*.py" = ["E501", "SIM117"]
|
"*/tests/*.py" = ["E501", "SIM117"]
|
||||||
"*/migrations/*.py" = ["E501", "SIM"]
|
"*/migrations/*.py" = ["E501", "SIM", "T201"]
|
||||||
"src/paperless_tesseract/tests/test_parser.py" = ["RUF001"]
|
"src/paperless_tesseract/tests/test_parser.py" = ["RUF001"]
|
||||||
"src/documents/models.py" = ["SIM115"]
|
"src/documents/models.py" = ["SIM115"]
|
||||||
|
|
||||||
[isort]
|
[lint.isort]
|
||||||
force-single-line = true
|
force-single-line = true
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
We as members, contributors, and leaders pledge to make participation in our
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
community a harassment-free experience for everyone, regardless of age, body
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
identity and expression, level of experience, education, socio-economic status,
|
identity and expression, level of experience, education, socioeconomic status,
|
||||||
nationality, personal appearance, race, religion, or sexual identity
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
and orientation.
|
and orientation.
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ If you want to implement something big:
|
|||||||
|
|
||||||
## Python
|
## Python
|
||||||
|
|
||||||
Paperless supports python 3.9 - 3.11. We format Python code with [Black](https://github.com/psf/black).
|
Paperless supports python 3.9 - 3.11 at this time. We format Python code with [ruff](https://docs.astral.sh/ruff/formatter/).
|
||||||
|
|
||||||
## Branches
|
## Branches
|
||||||
|
|
||||||
@ -94,7 +94,7 @@ The following files need to be changed:
|
|||||||
|
|
||||||
- src-ui/angular.json (under the _projects/paperless-ui/i18n/locales_ JSON key)
|
- src-ui/angular.json (under the _projects/paperless-ui/i18n/locales_ JSON key)
|
||||||
- src/paperless/settings.py (in the _LANGUAGES_ array)
|
- src/paperless/settings.py (in the _LANGUAGES_ array)
|
||||||
- src-ui/src/app/services/settings.service.ts (inside the _getLanguageOptions_ method)
|
- src-ui/src/app/services/settings.service.ts (inside the _LANGUAGE_OPTIONS_ array)
|
||||||
- src-ui/src/app/app.module.ts (import locale from _angular/common/locales_ and call _registerLocaleData_)
|
- src-ui/src/app/app.module.ts (import locale from _angular/common/locales_ and call _registerLocaleData_)
|
||||||
|
|
||||||
Please add the language in the correct order, alphabetically by locale.
|
Please add the language in the correct order, alphabetically by locale.
|
||||||
@ -137,3 +137,19 @@ All team members are notified when mentioned or assigned to a relevant issue or
|
|||||||
We are not overly strict with inviting people to the organization. If you have read the [team permissions](#permissions) and think having additional access would enhance your contributions, please reach out to an [admin](#structure) of the team.
|
We are not overly strict with inviting people to the organization. If you have read the [team permissions](#permissions) and think having additional access would enhance your contributions, please reach out to an [admin](#structure) of the team.
|
||||||
|
|
||||||
The admins occasionally invite contributors directly if we believe having them on a team will accelerate their work.
|
The admins occasionally invite contributors directly if we believe having them on a team will accelerate their work.
|
||||||
|
|
||||||
|
# Automatic Repository Maintenance
|
||||||
|
|
||||||
|
The Paperless-ngx team appreciates all effort and interest from the community in filing bug reports, creating feature requests, sharing ideas and helping other
|
||||||
|
community members. That said, in an effort to keep the repository organized and managebale the project uses automatic handling of certain areas:
|
||||||
|
|
||||||
|
- Issues that cannot be reproduced will be marked 'stale' after 7 days of inactivity and closed after 14 further 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 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: 5 "up-votes" after 180 days of inactivity or 10 "up-votes" after 365 days.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Thank you all for your contributions.
|
||||||
|
47
Dockerfile
@ -12,7 +12,7 @@ COPY ./src-ui /src/src-ui
|
|||||||
WORKDIR /src/src-ui
|
WORKDIR /src/src-ui
|
||||||
RUN set -eux \
|
RUN set -eux \
|
||||||
&& npm update npm -g \
|
&& npm update npm -g \
|
||||||
&& npm ci --omit=optional
|
&& npm ci
|
||||||
RUN set -eux \
|
RUN set -eux \
|
||||||
&& ./node_modules/.bin/ng build --configuration production
|
&& ./node_modules/.bin/ng build --configuration production
|
||||||
|
|
||||||
@ -29,7 +29,7 @@ COPY Pipfile* ./
|
|||||||
|
|
||||||
RUN set -eux \
|
RUN set -eux \
|
||||||
&& echo "Installing pipenv" \
|
&& echo "Installing pipenv" \
|
||||||
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2023.10.24 \
|
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2023.12.1 \
|
||||||
&& echo "Generating requirement.txt" \
|
&& echo "Generating requirement.txt" \
|
||||||
&& pipenv requirements > requirements.txt
|
&& pipenv requirements > requirements.txt
|
||||||
|
|
||||||
@ -52,8 +52,15 @@ ARG TARGETARCH
|
|||||||
|
|
||||||
# Can be workflow provided, defaults set for manual building
|
# Can be workflow provided, defaults set for manual building
|
||||||
ARG JBIG2ENC_VERSION=0.29
|
ARG JBIG2ENC_VERSION=0.29
|
||||||
ARG QPDF_VERSION=11.6.3
|
ARG QPDF_VERSION=11.9.0
|
||||||
ARG GS_VERSION=10.02.0
|
ARG GS_VERSION=10.03.1
|
||||||
|
|
||||||
|
# Set Python environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
# Ignore warning from Whitenoise
|
||||||
|
PYTHONWARNINGS="ignore:::django.http.response:517" \
|
||||||
|
PNGX_CONTAINERIZED=1
|
||||||
|
|
||||||
#
|
#
|
||||||
# Begin installation and configuration
|
# Begin installation and configuration
|
||||||
@ -76,7 +83,6 @@ ARG RUNTIME_PACKAGES="\
|
|||||||
icc-profiles-free \
|
icc-profiles-free \
|
||||||
imagemagick \
|
imagemagick \
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
libpq5 \
|
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
# MySQL / MariaDB
|
# MySQL / MariaDB
|
||||||
mariadb-client \
|
mariadb-client \
|
||||||
@ -122,17 +128,17 @@ RUN set -eux \
|
|||||||
&& dpkg --install ./qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
|
&& dpkg --install ./qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
|
||||||
&& echo "Installing Ghostscript ${GS_VERSION}" \
|
&& echo "Installing Ghostscript ${GS_VERSION}" \
|
||||||
&& curl --fail --silent --show-error --location \
|
&& curl --fail --silent --show-error --location \
|
||||||
--output libgs10_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
|
--output libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
|
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
&& curl --fail --silent --show-error --location \
|
&& curl --fail --silent --show-error --location \
|
||||||
--output ghostscript_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
|
--output ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
|
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
&& curl --fail --silent --show-error --location \
|
&& curl --fail --silent --show-error --location \
|
||||||
--output libgs10-common_${GS_VERSION}.dfsg-2_all.deb \
|
--output libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
|
||||||
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg-2_all.deb \
|
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
|
||||||
&& dpkg --install ./libgs10-common_${GS_VERSION}.dfsg-2_all.deb \
|
&& dpkg --install ./libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
|
||||||
&& dpkg --install ./libgs10_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
|
&& dpkg --install ./libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
&& dpkg --install ./ghostscript_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
|
&& dpkg --install ./ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
&& echo "Installing jbig2enc" \
|
&& echo "Installing jbig2enc" \
|
||||||
&& curl --fail --silent --show-error --location \
|
&& curl --fail --silent --show-error --location \
|
||||||
--output jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
--output jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
||||||
@ -187,7 +193,7 @@ RUN set -eux \
|
|||||||
&& chmod 755 /usr/local/bin/paperless_cmd.sh \
|
&& chmod 755 /usr/local/bin/paperless_cmd.sh \
|
||||||
&& mv flower-conditional.sh /usr/local/bin/flower-conditional.sh \
|
&& mv flower-conditional.sh /usr/local/bin/flower-conditional.sh \
|
||||||
&& chmod 755 /usr/local/bin/flower-conditional.sh \
|
&& chmod 755 /usr/local/bin/flower-conditional.sh \
|
||||||
&& echo "Installing managment commands" \
|
&& echo "Installing management commands" \
|
||||||
&& chmod +x install_management_commands.sh \
|
&& chmod +x install_management_commands.sh \
|
||||||
&& ./install_management_commands.sh
|
&& ./install_management_commands.sh
|
||||||
|
|
||||||
@ -216,7 +222,13 @@ RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \
|
|||||||
&& apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \
|
&& apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \
|
||||||
&& python3 -m pip install --no-cache-dir --upgrade wheel \
|
&& python3 -m pip install --no-cache-dir --upgrade wheel \
|
||||||
&& echo "Installing Python requirements" \
|
&& echo "Installing Python requirements" \
|
||||||
&& python3 -m pip install --default-timeout=1000 --requirement requirements.txt \
|
&& curl --fail --silent --show-error --location \
|
||||||
|
--output psycopg_c-3.1.19-cp311-cp311-linux_x86_64.whl \
|
||||||
|
https://github.com/paperless-ngx/builder/releases/download/psycopg-3.1.19/psycopg_c-3.1.19-cp311-cp311-linux_x86_64.whl \
|
||||||
|
&& curl --fail --silent --show-error --location \
|
||||||
|
--output psycopg_c-3.1.19-cp311-cp311-linux_aarch64.whl \
|
||||||
|
https://github.com/paperless-ngx/builder/releases/download/psycopg-3.1.19/psycopg_c-3.1.19-cp311-cp311-linux_aarch64.whl \
|
||||||
|
&& python3 -m pip install --default-timeout=1000 --find-links . --requirement requirements.txt \
|
||||||
&& echo "Patching whitenoise for compression speedup" \
|
&& echo "Patching whitenoise for compression speedup" \
|
||||||
&& curl --fail --silent --show-error --location --output 484.patch https://github.com/evansd/whitenoise/pull/484.patch \
|
&& curl --fail --silent --show-error --location --output 484.patch https://github.com/evansd/whitenoise/pull/484.patch \
|
||||||
&& patch -d /usr/local/lib/python3.11/site-packages --verbose -p2 < 484.patch \
|
&& patch -d /usr/local/lib/python3.11/site-packages --verbose -p2 < 484.patch \
|
||||||
@ -229,6 +241,7 @@ RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \
|
|||||||
&& apt-get --yes purge ${BUILD_PACKAGES} \
|
&& apt-get --yes purge ${BUILD_PACKAGES} \
|
||||||
&& apt-get --yes autoremove --purge \
|
&& apt-get --yes autoremove --purge \
|
||||||
&& apt-get clean --yes \
|
&& apt-get clean --yes \
|
||||||
|
&& rm --recursive --force --verbose *.whl \
|
||||||
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
|
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
|
||||||
&& rm --recursive --force --verbose /tmp/* \
|
&& rm --recursive --force --verbose /tmp/* \
|
||||||
&& rm --recursive --force --verbose /var/tmp/* \
|
&& rm --recursive --force --verbose /var/tmp/* \
|
||||||
@ -268,3 +281,5 @@ ENTRYPOINT ["/sbin/docker-entrypoint.sh"]
|
|||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["/usr/local/bin/paperless_cmd.sh"]
|
CMD ["/usr/local/bin/paperless_cmd.sh"]
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --retries=5 CMD [ "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000" ]
|
||||||
|
23
Pipfile
@ -4,24 +4,25 @@ verify_ssl = true
|
|||||||
name = "pypi"
|
name = "pypi"
|
||||||
|
|
||||||
[packages]
|
[packages]
|
||||||
dateparser = "~=1.1"
|
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 = "~=4.2.7"
|
django = "~=4.2.13"
|
||||||
|
django-allauth = {extras = ["socialaccount"], version = "*"}
|
||||||
django-auditlog = "*"
|
django-auditlog = "*"
|
||||||
django-celery-results = "*"
|
django-celery-results = "*"
|
||||||
django-compression-middleware = "*"
|
django-compression-middleware = "*"
|
||||||
django-cors-headers = "*"
|
django-cors-headers = "*"
|
||||||
django-extensions = "*"
|
django-extensions = "*"
|
||||||
django-filter = "~=23.3"
|
django-filter = "~=24.2"
|
||||||
django-guardian = "*"
|
django-guardian = "*"
|
||||||
django-multiselectfield = "*"
|
django-multiselectfield = "*"
|
||||||
djangorestframework = "~=3.14"
|
djangorestframework = "==3.14.0"
|
||||||
djangorestframework-guardian = "*"
|
djangorestframework-guardian = "*"
|
||||||
drf-writable-nested = "*"
|
drf-writable-nested = "*"
|
||||||
bleach = "*"
|
bleach = "*"
|
||||||
celery = {extras = ["redis"], version = "*"}
|
celery = {extras = ["redis"], version = "*"}
|
||||||
channels = "~=4.0"
|
channels = "~=4.1"
|
||||||
channels-redis = "*"
|
channels-redis = "*"
|
||||||
concurrent-log-handler = "*"
|
concurrent-log-handler = "*"
|
||||||
filelock = "*"
|
filelock = "*"
|
||||||
@ -33,10 +34,10 @@ inotifyrecursive = "~=0.3"
|
|||||||
langdetect = "*"
|
langdetect = "*"
|
||||||
mysqlclient = "*"
|
mysqlclient = "*"
|
||||||
nltk = "*"
|
nltk = "*"
|
||||||
ocrmypdf = "~=15.0"
|
ocrmypdf = "~=15.4"
|
||||||
pathvalidate = "*"
|
pathvalidate = "*"
|
||||||
pdf2image = "*"
|
pdf2image = "*"
|
||||||
psycopg2 = "*"
|
psycopg = {version = "*", extras = ["c"]}
|
||||||
python-dateutil = "*"
|
python-dateutil = "*"
|
||||||
python-dotenv = "*"
|
python-dotenv = "*"
|
||||||
python-gnupg = "*"
|
python-gnupg = "*"
|
||||||
@ -45,19 +46,19 @@ python-magic = "*"
|
|||||||
pyzbar = "*"
|
pyzbar = "*"
|
||||||
rapidfuzz = "*"
|
rapidfuzz = "*"
|
||||||
redis = {extras = ["hiredis"], version = "*"}
|
redis = {extras = ["hiredis"], version = "*"}
|
||||||
scikit-learn = "~=1.3"
|
scikit-learn = "~=1.5"
|
||||||
setproctitle = "*"
|
setproctitle = "*"
|
||||||
tika-client = "*"
|
tika-client = "*"
|
||||||
tqdm = "*"
|
tqdm = "*"
|
||||||
uvicorn = {extras = ["standard"], version = "*"}
|
# See https://github.com/paperless-ngx/paperless-ngx/issues/5494
|
||||||
watchdog = "~=3.0"
|
uvicorn = {extras = ["standard"], version = "==0.25.0"}
|
||||||
|
watchdog = "~=4.0"
|
||||||
whitenoise = "~=6.6"
|
whitenoise = "~=6.6"
|
||||||
whoosh="~=2.7"
|
whoosh="~=2.7"
|
||||||
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
# Linting
|
# Linting
|
||||||
black = "*"
|
|
||||||
pre-commit = "*"
|
pre-commit = "*"
|
||||||
ruff = "*"
|
ruff = "*"
|
||||||
# Testing
|
# Testing
|
||||||
|
3848
Pipfile.lock
generated
20
README.md
@ -21,7 +21,7 @@ Paperless-ngx is a document management system that transforms your physical docu
|
|||||||
|
|
||||||
Paperless-ngx is the official successor to the original [Paperless](https://github.com/the-paperless-project/paperless) & [Paperless-ng](https://github.com/jonaswinkler/paperless-ng) projects and is designed to distribute the responsibility of advancing and supporting the project among a team of people. [Consider joining us!](#community-support)
|
Paperless-ngx is the official successor to the original [Paperless](https://github.com/the-paperless-project/paperless) & [Paperless-ng](https://github.com/jonaswinkler/paperless-ng) projects and is designed to distribute the responsibility of advancing and supporting the project among a team of people. [Consider joining us!](#community-support)
|
||||||
|
|
||||||
A demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com) using login `demo` / `demo`. _Note: demo content is reset frequently and confidential information should not be uploaded._
|
Thanks to the generous folks at [DigitalOcean](https://m.do.co/c/8d70b916d462), a demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com) using login `demo` / `demo`. _Note: demo content is reset frequently and confidential information should not be uploaded._
|
||||||
|
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Getting started](#getting-started)
|
- [Getting started](#getting-started)
|
||||||
@ -30,9 +30,19 @@ A demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com)
|
|||||||
- [Translation](#translation)
|
- [Translation](#translation)
|
||||||
- [Feature Requests](#feature-requests)
|
- [Feature Requests](#feature-requests)
|
||||||
- [Bugs](#bugs)
|
- [Bugs](#bugs)
|
||||||
- [Affiliated Projects](#affiliated-projects)
|
- [Related Projects](#related-projects)
|
||||||
- [Important Note](#important-note)
|
- [Important Note](#important-note)
|
||||||
|
|
||||||
|
<p align="right">This project is supported by:<br/>
|
||||||
|
<a href="https://m.do.co/c/8d70b916d462" style="padding-top: 4px; display: block;">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_white.svg" width="140px">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
|
||||||
|
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
@ -53,7 +63,7 @@ If you'd like to jump right in, you can configure a `docker compose` environment
|
|||||||
bash -c "$(curl -L https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/install-paperless-ngx.sh)"
|
bash -c "$(curl -L https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/install-paperless-ngx.sh)"
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, you can install the dependencies and setup apache and a database server yourself. The [documentation](https://docs.paperless-ngx.com/setup/#installation) has a step by step guide on how to do it.
|
More details and step-by-step guides for alternative installation methods can be found in [the documentation](https://docs.paperless-ngx.com/setup/#installation).
|
||||||
|
|
||||||
Migrating from Paperless-ng is easy, just drop in the new docker image! See the [documentation on migrating](https://docs.paperless-ngx.com/setup/#migrating-to-paperless-ngx) for more details.
|
Migrating from Paperless-ng is easy, just drop in the new docker image! See the [documentation on migrating](https://docs.paperless-ngx.com/setup/#migrating-to-paperless-ngx) for more details.
|
||||||
|
|
||||||
@ -83,9 +93,9 @@ Feature requests can be submitted via [GitHub Discussions](https://github.com/pa
|
|||||||
|
|
||||||
For bugs please [open an issue](https://github.com/paperless-ngx/paperless-ngx/issues) or [start a discussion](https://github.com/paperless-ngx/paperless-ngx/discussions) if you have questions.
|
For bugs please [open an issue](https://github.com/paperless-ngx/paperless-ngx/issues) or [start a discussion](https://github.com/paperless-ngx/paperless-ngx/discussions) if you have questions.
|
||||||
|
|
||||||
# Affiliated Projects
|
# Related Projects
|
||||||
|
|
||||||
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Affiliated-Projects) for a user-maintained list of affiliated projects and software that is compatible with Paperless-ngx.
|
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects) for a user-maintained list of related projects and software that is compatible with Paperless-ngx.
|
||||||
|
|
||||||
# Important Note
|
# Important Note
|
||||||
|
|
||||||
|
9
SECURITY.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
The Paperless-ngx team and community take security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
||||||
|
|
||||||
|
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/paperless-ngx/paperless-ngx/security/advisories/new) tab.
|
||||||
|
|
||||||
|
The team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
@ -1,8 +1,6 @@
|
|||||||
commit_message: '[ci skip]'
|
project_id_env: CROWDIN_PROJECT_ID
|
||||||
pull_request_labels: [
|
api_token_env: CROWDIN_PERSONAL_TOKEN
|
||||||
"skip-changelog",
|
preserve_hierarchy: true
|
||||||
"translation"
|
|
||||||
]
|
|
||||||
files:
|
files:
|
||||||
- source: /src/locale/en_US/LC_MESSAGES/django.po
|
- source: /src/locale/en_US/LC_MESSAGES/django.po
|
||||||
translation: /src/locale/%locale_with_underscore%/LC_MESSAGES/django.po
|
translation: /src/locale/%locale_with_underscore%/LC_MESSAGES/django.po
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
# Docker Compose file for running paperless testing with actual gotenberg
|
# Docker Compose file for running paperless testing with actual gotenberg
|
||||||
# and Tika containers for a more end to end test of the Tika related functionality
|
# and Tika containers for a more end to end test of the Tika related functionality
|
||||||
# Can be used locally or by the CI to start the nessecary containers with the
|
# Can be used locally or by the CI to start the necessary containers with the
|
||||||
# correct networking for the tests
|
# correct networking for the tests
|
||||||
|
|
||||||
version: "3.7"
|
|
||||||
services:
|
services:
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:7.8
|
image: docker.io/gotenberg/gotenberg:7.10
|
||||||
hostname: gotenberg
|
hostname: gotenberg
|
||||||
container_name: gotenberg
|
container_name: gotenberg
|
||||||
network_mode: host
|
network_mode: host
|
||||||
@ -17,8 +16,10 @@ services:
|
|||||||
- "gotenberg"
|
- "gotenberg"
|
||||||
- "--chromium-disable-javascript=true"
|
- "--chromium-disable-javascript=true"
|
||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
|
- "--log-level=warn"
|
||||||
|
- "--log-format=text"
|
||||||
tika:
|
tika:
|
||||||
image: ghcr.io/paperless-ngx/tika:latest
|
image: docker.io/apache/tika:latest
|
||||||
hostname: tika
|
hostname: tika
|
||||||
container_name: tika
|
container_name: tika
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
@ -30,7 +30,6 @@
|
|||||||
# For more extensive installation and update instructions, refer to the
|
# For more extensive installation and update instructions, refer to the
|
||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
version: "3.4"
|
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/library/redis:7
|
image: docker.io/library/redis:7
|
||||||
@ -39,7 +38,7 @@ services:
|
|||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/mariadb:10
|
image: docker.io/library/mariadb:11
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- dbdata:/var/lib/mysql
|
- dbdata:/var/lib/mysql
|
||||||
@ -60,11 +59,6 @@ services:
|
|||||||
- tika
|
- tika
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
volumes:
|
volumes:
|
||||||
- data:/usr/src/paperless/data
|
- data:/usr/src/paperless/data
|
||||||
- media:/usr/src/paperless/media
|
- media:/usr/src/paperless/media
|
||||||
@ -83,7 +77,7 @@ services:
|
|||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
|
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:7.8
|
image: docker.io/gotenberg/gotenberg:7.10
|
||||||
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.
|
||||||
@ -93,7 +87,7 @@ services:
|
|||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
|
|
||||||
tika:
|
tika:
|
||||||
image: ghcr.io/paperless-ngx/tika:latest
|
image: docker.io/apache/tika:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
@ -26,7 +26,6 @@
|
|||||||
# For more extensive installation and update instructions, refer to the
|
# For more extensive installation and update instructions, refer to the
|
||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
version: "3.4"
|
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/library/redis:7
|
image: docker.io/library/redis:7
|
||||||
@ -35,7 +34,7 @@ services:
|
|||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/mariadb:10
|
image: docker.io/library/mariadb:11
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- dbdata:/var/lib/mysql
|
- dbdata:/var/lib/mysql
|
||||||
@ -54,11 +53,6 @@ services:
|
|||||||
- broker
|
- broker
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
volumes:
|
volumes:
|
||||||
- data:/usr/src/paperless/data
|
- data:/usr/src/paperless/data
|
||||||
- media:/usr/src/paperless/media
|
- media:/usr/src/paperless/media
|
||||||
@ -73,7 +67,6 @@ services:
|
|||||||
PAPERLESS_DBPASS: paperless # only needed if non-default password
|
PAPERLESS_DBPASS: paperless # only needed if non-default password
|
||||||
PAPERLESS_DBPORT: 3306
|
PAPERLESS_DBPORT: 3306
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
media:
|
media:
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
# To install and update paperless with this file, do the following:
|
# To install and update paperless with this file, do the following:
|
||||||
#
|
#
|
||||||
# - Open portainer Stacks list and click 'Add stack'
|
# - Open portainer Stacks list and click 'Add stack'
|
||||||
# - Paste the contents of this file and assign a name, e.g. 'Paperless'
|
# - Paste the contents of this file and assign a name, e.g. 'paperless'
|
||||||
# - Click 'Deploy the stack' and wait for it to be deployed
|
# - Click 'Deploy the stack' and wait for it to be deployed
|
||||||
# - Open the list of containers, select paperless_webserver_1
|
# - Open the list of containers, select paperless_webserver_1
|
||||||
# - Click 'Console' and then 'Connect' to open the command line inside the container
|
# - Click 'Console' and then 'Connect' to open the command line inside the container
|
||||||
@ -28,7 +28,6 @@
|
|||||||
# For more extensive installation and update instructions, refer to the
|
# For more extensive installation and update instructions, refer to the
|
||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
version: "3.4"
|
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/library/redis:7
|
image: docker.io/library/redis:7
|
||||||
@ -37,7 +36,7 @@ services:
|
|||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:15
|
image: docker.io/library/postgres:16
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
@ -54,11 +53,6 @@ services:
|
|||||||
- broker
|
- broker
|
||||||
ports:
|
ports:
|
||||||
- "8010:8000"
|
- "8010:8000"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
volumes:
|
volumes:
|
||||||
- data:/usr/src/paperless/data
|
- data:/usr/src/paperless/data
|
||||||
- media:/usr/src/paperless/media
|
- media:/usr/src/paperless/media
|
||||||
|
@ -30,7 +30,6 @@
|
|||||||
# For more extensive installation and update instructions, refer to the
|
# For more extensive installation and update instructions, refer to the
|
||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
version: "3.4"
|
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/library/redis:7
|
image: docker.io/library/redis:7
|
||||||
@ -39,7 +38,7 @@ services:
|
|||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:15
|
image: docker.io/library/postgres:16
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
@ -58,11 +57,6 @@ services:
|
|||||||
- tika
|
- tika
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
volumes:
|
volumes:
|
||||||
- data:/usr/src/paperless/data
|
- data:/usr/src/paperless/data
|
||||||
- media:/usr/src/paperless/media
|
- media:/usr/src/paperless/media
|
||||||
@ -77,7 +71,7 @@ services:
|
|||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
|
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:7.8
|
image: docker.io/gotenberg/gotenberg:7.10
|
||||||
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
|
||||||
@ -88,7 +82,7 @@ services:
|
|||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
|
|
||||||
tika:
|
tika:
|
||||||
image: ghcr.io/paperless-ngx/tika:latest
|
image: docker.io/apache/tika:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
@ -26,7 +26,6 @@
|
|||||||
# For more extensive installation and update instructions, refer to the
|
# For more extensive installation and update instructions, refer to the
|
||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
version: "3.4"
|
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/library/redis:7
|
image: docker.io/library/redis:7
|
||||||
@ -35,7 +34,7 @@ services:
|
|||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:15
|
image: docker.io/library/postgres:16
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
@ -52,11 +51,6 @@ services:
|
|||||||
- broker
|
- broker
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
volumes:
|
volumes:
|
||||||
- data:/usr/src/paperless/data
|
- data:/usr/src/paperless/data
|
||||||
- media:/usr/src/paperless/media
|
- media:/usr/src/paperless/media
|
||||||
@ -67,7 +61,6 @@ services:
|
|||||||
PAPERLESS_REDIS: redis://broker:6379
|
PAPERLESS_REDIS: redis://broker:6379
|
||||||
PAPERLESS_DBHOST: db
|
PAPERLESS_DBHOST: db
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
media:
|
media:
|
||||||
|
@ -30,7 +30,6 @@
|
|||||||
# For more extensive installation and update instructions, refer to the
|
# For more extensive installation and update instructions, refer to the
|
||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
version: "3.4"
|
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/library/redis:7
|
image: docker.io/library/redis:7
|
||||||
@ -47,11 +46,6 @@ services:
|
|||||||
- tika
|
- tika
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
volumes:
|
volumes:
|
||||||
- data:/usr/src/paperless/data
|
- data:/usr/src/paperless/data
|
||||||
- media:/usr/src/paperless/media
|
- media:/usr/src/paperless/media
|
||||||
@ -65,7 +59,7 @@ services:
|
|||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
|
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:7.8
|
image: docker.io/gotenberg/gotenberg:7.10
|
||||||
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
|
||||||
@ -76,7 +70,7 @@ services:
|
|||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
|
|
||||||
tika:
|
tika:
|
||||||
image: ghcr.io/paperless-ngx/tika:latest
|
image: docker.io/apache/tika:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
@ -23,7 +23,6 @@
|
|||||||
# For more extensive installation and update instructions, refer to the
|
# For more extensive installation and update instructions, refer to the
|
||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
version: "3.4"
|
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/library/redis:7
|
image: docker.io/library/redis:7
|
||||||
@ -38,11 +37,6 @@ services:
|
|||||||
- broker
|
- broker
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
volumes:
|
volumes:
|
||||||
- data:/usr/src/paperless/data
|
- data:/usr/src/paperless/data
|
||||||
- media:/usr/src/paperless/media
|
- media:/usr/src/paperless/media
|
||||||
@ -52,7 +46,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PAPERLESS_REDIS: redis://broker:6379
|
PAPERLESS_REDIS: redis://broker:6379
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
media:
|
media:
|
||||||
|
@ -86,17 +86,17 @@ initialize() {
|
|||||||
"${CONSUME_DIR}"; do
|
"${CONSUME_DIR}"; do
|
||||||
if [[ ! -d "${dir}" ]]; then
|
if [[ ! -d "${dir}" ]]; then
|
||||||
echo "Creating directory ${dir}"
|
echo "Creating directory ${dir}"
|
||||||
mkdir "${dir}"
|
mkdir --parents "${dir}"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
local -r tmp_dir="/tmp/paperless"
|
local -r tmp_dir="${PAPERLESS_SCRATCH_DIR:=/tmp/paperless}"
|
||||||
echo "Creating directory ${tmp_dir}"
|
echo "Creating directory scratch directory ${tmp_dir}"
|
||||||
mkdir -p "${tmp_dir}"
|
mkdir --parents "${tmp_dir}"
|
||||||
|
|
||||||
set +e
|
set +e
|
||||||
echo "Adjusting permissions of paperless files. This may take a while."
|
echo "Adjusting permissions of paperless files. This may take a while."
|
||||||
chown -R paperless:paperless ${tmp_dir}
|
chown -R paperless:paperless "${tmp_dir}"
|
||||||
for dir in \
|
for dir in \
|
||||||
"${export_dir}" \
|
"${export_dir}" \
|
||||||
"${DATA_DIR}" \
|
"${DATA_DIR}" \
|
||||||
|
@ -80,7 +80,7 @@ django_checks() {
|
|||||||
|
|
||||||
search_index() {
|
search_index() {
|
||||||
|
|
||||||
local -r index_version=7
|
local -r index_version=9
|
||||||
local -r index_version_file=${DATA_DIR}/.index_version
|
local -r index_version_file=${DATA_DIR}/.index_version
|
||||||
|
|
||||||
if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then
|
if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
SUPERVISORD_WORKING_DIR="${PAPERLESS_SUPERVISORD_WORKING_DIR:-$PWD}"
|
||||||
rootless_args=()
|
rootless_args=()
|
||||||
if [ "$(id -u)" == "$(id -u paperless)" ]; then
|
if [ "$(id -u)" == "$(id -u paperless)" ]; then
|
||||||
rootless_args=(
|
rootless_args=(
|
||||||
--user
|
--user
|
||||||
paperless
|
paperless
|
||||||
--logfile
|
--logfile
|
||||||
supervisord.log
|
"${SUPERVISORD_WORKING_DIR}/supervisord.log"
|
||||||
--pidfile
|
--pidfile
|
||||||
supervisord.pid
|
"${SUPERVISORD_WORKING_DIR}/supervisord.pid"
|
||||||
)
|
)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ Simple script which attempts to ping the Redis broker as set in the environment
|
|||||||
a certain number of times, waiting a little bit in between
|
a certain number of times, waiting a little bit in between
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
@ -67,15 +67,15 @@ you installed paperless-ngx in the first place. The releases are
|
|||||||
available at the [release
|
available at the [release
|
||||||
page](https://github.com/paperless-ngx/paperless-ngx/releases).
|
page](https://github.com/paperless-ngx/paperless-ngx/releases).
|
||||||
|
|
||||||
First of all, ensure that paperless is stopped.
|
First of all, make sure no active processes (like consumption) are running, then [make a backup](#backup).
|
||||||
|
|
||||||
|
After that, ensure that paperless is stopped:
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
$ cd /path/to/paperless
|
$ cd /path/to/paperless
|
||||||
$ docker compose down
|
$ docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
After that, [make a backup](#backup).
|
|
||||||
|
|
||||||
1. If you pull the image from the docker hub, all you need to do is:
|
1. If you pull the image from the docker hub, all you need to do is:
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
@ -185,34 +185,12 @@ For PostgreSQL, refer to [Upgrading a PostgreSQL Cluster](https://www.postgresql
|
|||||||
|
|
||||||
For MariaDB, refer to [Upgrading MariaDB](https://mariadb.com/kb/en/upgrading/)
|
For MariaDB, refer to [Upgrading MariaDB](https://mariadb.com/kb/en/upgrading/)
|
||||||
|
|
||||||
## Downgrading Paperless {#downgrade-paperless}
|
You may also use the exporter and importer with the `--data-only` flag, after creating a new database with the updated version of PostgreSQL or MariaDB.
|
||||||
|
|
||||||
Downgrades are possible. However, some updates also contain database
|
!!! warning
|
||||||
migrations (these change the layout of the database and may move data).
|
|
||||||
In order to move back from a version that applied database migrations,
|
|
||||||
you'll have to revert the database migration _before_ downgrading, and
|
|
||||||
then downgrade paperless.
|
|
||||||
|
|
||||||
This table lists the compatible versions for each database migration
|
You should not change any settings, especially paths, when doing this or there is a
|
||||||
number.
|
risk of data loss
|
||||||
|
|
||||||
| Migration number | Version range |
|
|
||||||
| ---------------- | --------------- |
|
|
||||||
| 1011 | 1.0.0 |
|
|
||||||
| 1012 | 1.1.0 - 1.2.1 |
|
|
||||||
| 1014 | 1.3.0 - 1.3.1 |
|
|
||||||
| 1016 | 1.3.2 - current |
|
|
||||||
|
|
||||||
Execute the following management command to migrate your database:
|
|
||||||
|
|
||||||
```shell-session
|
|
||||||
$ python3 manage.py migrate documents <migration number>
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
Some migrations cannot be undone. The command will issue errors if that
|
|
||||||
happens.
|
|
||||||
|
|
||||||
## Management utilities {#management-commands}
|
## Management utilities {#management-commands}
|
||||||
|
|
||||||
@ -269,6 +247,8 @@ optional arguments:
|
|||||||
-sm, --split-manifest
|
-sm, --split-manifest
|
||||||
-z, --zip
|
-z, --zip
|
||||||
-zn, --zip-name
|
-zn, --zip-name
|
||||||
|
--data-only
|
||||||
|
--passphrase
|
||||||
```
|
```
|
||||||
|
|
||||||
`target` is a folder to which the data gets written. This includes
|
`target` is a folder to which the data gets written. This includes
|
||||||
@ -327,6 +307,12 @@ If `-z` or `--zip` is provided, the export will be a zip file
|
|||||||
in the target directory, named according to the current local date or the
|
in the target directory, named according to the current local date or the
|
||||||
value set in `-zn` or `--zip-name`.
|
value set in `-zn` or `--zip-name`.
|
||||||
|
|
||||||
|
If `--data-only` is provided, only the database will be exported. This option is intended
|
||||||
|
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
|
||||||
|
|
||||||
|
If `--passphrase` is provided, it will be used to encrypt certain fields in the export. This value
|
||||||
|
must be provided to import. If this value is lost, the export cannot be imported.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
If exporting with the file name format, there may be errors due to
|
If exporting with the file name format, there may be errors due to
|
||||||
@ -341,19 +327,33 @@ exporter](#exporter) and imports it into paperless.
|
|||||||
The importer works just like the exporter. You point it at a directory,
|
The importer works just like the exporter. You point it at a directory,
|
||||||
and the script does the rest of the work:
|
and the script does the rest of the work:
|
||||||
|
|
||||||
```
|
```shell
|
||||||
document_importer source
|
document_importer source
|
||||||
```
|
```
|
||||||
|
|
||||||
|
| Option | Required | Default | Description |
|
||||||
|
| -------------- | -------- | ------- | ------------------------------------------------------------------------- |
|
||||||
|
| source | Yes | N/A | The directory containing an export |
|
||||||
|
| `--data-only` | No | False | If provided, only import data, do not import document files or thumbnails |
|
||||||
|
| `--passphrase` | No | N/A | If your export was encrypted with a passphrase, must be provided |
|
||||||
|
|
||||||
When you use the provided docker compose script, put the export inside
|
When you use the provided docker compose script, put the export inside
|
||||||
the `export` folder in your paperless source directory. Specify
|
the `export` folder in your paperless source directory. Specify
|
||||||
`../export` as the `source`.
|
`../export` as the `source`.
|
||||||
|
|
||||||
|
Note that .zip files (as can be generated from the exporter) are not supported. You must unzip them into
|
||||||
|
the target directory first.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
Importing from a previous version of Paperless may work, but for best
|
Importing from a previous version of Paperless may work, but for best
|
||||||
results it is suggested to match the versions.
|
results it is suggested to match the versions.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
The importer should be run against a completely empty installation (database and directories) of Paperless-ngx.
|
||||||
|
If using a data only import, only the database must be empty.
|
||||||
|
|
||||||
### Document retagger {#retagger}
|
### Document retagger {#retagger}
|
||||||
|
|
||||||
Say you've imported a few hundred documents and now want to introduce a
|
Say you've imported a few hundred documents and now want to introduce a
|
||||||
@ -580,7 +580,7 @@ Enabling encryption is no longer supported.
|
|||||||
|
|
||||||
Basic usage to disable encryption of your document store:
|
Basic usage to disable encryption of your document store:
|
||||||
|
|
||||||
(Note: If [`PAPERLESS_PASSPHRASE`](configuration.md#PAPERLESS_PASSPHRASE) isn't set already, you need to specify
|
(Note: If `PAPERLESS_PASSPHRASE` isn't set already, you need to specify
|
||||||
it here)
|
it here)
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -607,3 +607,10 @@ document_fuzzy_match [--ratio] [--processes N]
|
|||||||
| ----------- | -------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------- | -------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. |
|
| --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. |
|
||||||
| --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes |
|
| --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes |
|
||||||
|
| --delete | No | False | If provided, one document of a matched pair above the ratio will be deleted. |
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
If providing the `--delete` option, it is highly recommended to have a backup.
|
||||||
|
While every effort has been taken to ensure proper operation, there is always the
|
||||||
|
chance of deletion of a file you want to keep.
|
||||||
|
@ -136,6 +136,11 @@ script can access the following relevant environment variables set:
|
|||||||
be triggered, leading to failures as two tasks work on the
|
be triggered, leading to failures as two tasks work on the
|
||||||
same document path
|
same document path
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
If your script modifies `DOCUMENT_WORKING_PATH` in a non-deterministic
|
||||||
|
way, this may allow duplicate documents to be stored
|
||||||
|
|
||||||
A simple but common example for this would be creating a simple script
|
A simple but common example for this would be creating a simple script
|
||||||
like this:
|
like this:
|
||||||
|
|
||||||
@ -251,7 +256,8 @@ document. You will end up getting files like `0000123.pdf` in your media
|
|||||||
directory. This isn't necessarily a bad thing, because you normally
|
directory. This isn't necessarily a bad thing, because you normally
|
||||||
don't have to access these files manually. However, if you wish to name
|
don't have to access these files manually. However, if you wish to name
|
||||||
your files differently, you can do that by adjusting the
|
your files differently, you can do that by adjusting the
|
||||||
[`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) configuration option. Paperless adds the
|
[`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) configuration option
|
||||||
|
or using [storage paths (see below)](#storage-paths). Paperless adds the
|
||||||
correct file extension e.g. `.pdf`, `.jpg` automatically.
|
correct file extension e.g. `.pdf`, `.jpg` automatically.
|
||||||
|
|
||||||
This variable allows you to configure the filename (folders are allowed)
|
This variable allows you to configure the filename (folders are allowed)
|
||||||
@ -284,6 +290,15 @@ will create a directory structure as follows:
|
|||||||
paperless will report your files as missing and won't be able to find
|
paperless will report your files as missing and won't be able to find
|
||||||
them.
|
them.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
|
||||||
|
Paperless checks the filename of a document whenever it is saved. Changing (or deleting)
|
||||||
|
a [storage path](#storage-paths) will automatically be reflected in the file system. However,
|
||||||
|
when changing `PAPERLESS_FILENAME_FORMAT` you will need to manually run the
|
||||||
|
[`document renamer`](administration.md#renamer) to move any existing documents.
|
||||||
|
|
||||||
|
#### Placeholders
|
||||||
|
|
||||||
Paperless provides the following placeholders within filenames:
|
Paperless provides the following placeholders within filenames:
|
||||||
|
|
||||||
- `{asn}`: The archive serial number of the document, or "none".
|
- `{asn}`: The archive serial number of the document, or "none".
|
||||||
@ -316,6 +331,12 @@ Paperless provides the following placeholders within filenames:
|
|||||||
- `{original_name}`: Document original filename, minus the extension, if any, or "none"
|
- `{original_name}`: Document original filename, minus the extension, if any, or "none"
|
||||||
- `{doc_pk}`: The paperless identifier (primary key) for the document.
|
- `{doc_pk}`: The paperless identifier (primary key) for the document.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
When using file name placeholders, in particular when using `{tag_list}`,
|
||||||
|
you may run into the limits of your operating system's maximum path lengths.
|
||||||
|
In that case, files will retain the previous path instead and the issue logged.
|
||||||
|
|
||||||
Paperless will try to conserve the information from your database as
|
Paperless will try to conserve the information from your database as
|
||||||
much as possible. However, some characters that you can use in document
|
much as possible. However, some characters that you can use in document
|
||||||
titles and correspondent names (such as `: \ /` and a couple more) are
|
titles and correspondent names (such as `: \ /` and a couple more) are
|
||||||
@ -326,34 +347,12 @@ paperless will automatically append `_01`, `_02`, etc to the filename.
|
|||||||
This happens if all the placeholders in a filename evaluate to the same
|
This happens if all the placeholders in a filename evaluate to the same
|
||||||
value.
|
value.
|
||||||
|
|
||||||
!!! tip
|
If there are any errors in the placeholders included in `PAPERLESS_FILENAME_FORMAT`,
|
||||||
|
paperless will fall back to using the default naming scheme instead.
|
||||||
You can affect how empty placeholders are treated by changing the
|
|
||||||
following setting to `true`.
|
|
||||||
|
|
||||||
```
|
|
||||||
PAPERLESS_FILENAME_FORMAT_REMOVE_NONE=True
|
|
||||||
```
|
|
||||||
|
|
||||||
Doing this results in all empty placeholders resolving to "" instead
|
|
||||||
of "none" as stated above. Spaces before empty placeholders are
|
|
||||||
removed as well, empty directories are omitted.
|
|
||||||
|
|
||||||
!!! tip
|
|
||||||
|
|
||||||
Paperless checks the filename of a document whenever it is saved.
|
|
||||||
Therefore, you need to update the filenames of your documents and move
|
|
||||||
them after altering this setting by invoking the
|
|
||||||
[`document renamer`](administration.md#renamer).
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
Make absolutely sure you get the spelling of the placeholders right, or
|
|
||||||
else paperless will use the default naming scheme instead.
|
|
||||||
|
|
||||||
!!! caution
|
!!! caution
|
||||||
|
|
||||||
As of now, you could totally tell paperless to store your files anywhere
|
As of now, you could potentially tell paperless to store your files anywhere
|
||||||
outside the media directory by setting
|
outside the media directory by setting
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -361,28 +360,25 @@ value.
|
|||||||
```
|
```
|
||||||
|
|
||||||
However, keep in mind that inside docker, if files get stored outside of
|
However, keep in mind that inside docker, if files get stored outside of
|
||||||
the predefined volumes, they will be lost after a restart of paperless.
|
the predefined volumes, they will be lost after a restart.
|
||||||
|
|
||||||
!!! warning
|
##### Empty placeholders
|
||||||
|
|
||||||
When file naming handling, in particular when using `{tag_list}`,
|
You can affect how empty placeholders are treated by changing the
|
||||||
you may run into the limits of your operating system's maximum
|
[`PAPERLESS_FILENAME_FORMAT_REMOVE_NONE`](configuration.md#PAPERLESS_FILENAME_FORMAT_REMOVE_NONE) setting.
|
||||||
path lengths. Files will retain the previous path instead and
|
|
||||||
the issue logged.
|
|
||||||
|
|
||||||
## Storage paths
|
Enabling this results in all empty placeholders resolving to "" instead of "none" as stated above. Spaces
|
||||||
|
before empty placeholders are removed as well, empty directories are omitted.
|
||||||
|
|
||||||
One of the best things in Paperless is that you can not only access the
|
### Storage paths
|
||||||
documents via the web interface, but also via the file system.
|
|
||||||
|
|
||||||
When a single storage layout is not sufficient for your use case,
|
When a single storage layout is not sufficient for your use case, storage paths allow for more complex
|
||||||
storage paths come to the rescue. Storage paths allow you to configure
|
structure to set precisely where each document is stored in the file system.
|
||||||
more precisely where each document is stored in the file system.
|
|
||||||
|
|
||||||
- Each storage path is a [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) and
|
- Each storage path is a [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) and
|
||||||
follows the rules described above
|
follows the rules described above
|
||||||
- Each document is assigned a storage path using the matching
|
- Each document is assigned a storage path using the matching algorithms described above, but can be
|
||||||
algorithms described above, but can be overwritten at any time
|
overwritten at any time
|
||||||
|
|
||||||
For example, you could define the following two storage paths:
|
For example, you could define the following two storage paths:
|
||||||
|
|
||||||
@ -429,8 +425,10 @@ to view more detailed information about the health of the celery workers
|
|||||||
used for asynchronous tasks. This includes details on currently running,
|
used for asynchronous tasks. This includes details on currently running,
|
||||||
queued and completed tasks, timing and more. Flower can also be used
|
queued and completed tasks, timing and more. Flower can also be used
|
||||||
with Prometheus, as it exports metrics. For details on its capabilities,
|
with Prometheus, as it exports metrics. For details on its capabilities,
|
||||||
refer to the Flower documentation.
|
refer to the [Flower](https://flower.readthedocs.io/en/latest/index.html)
|
||||||
|
documentation.
|
||||||
|
|
||||||
|
Flower can be enabled with the setting [PAPERLESS_ENABLE_FLOWER](configuration.md#PAPERLESS_ENABLE_FLOWER).
|
||||||
To configure Flower further, create a `flowerconfig.py` and
|
To configure Flower further, create a `flowerconfig.py` and
|
||||||
place it into the `src/paperless` directory. For a Docker
|
place it into the `src/paperless` directory. For a Docker
|
||||||
installation, you can use volumes to accomplish this:
|
installation, you can use volumes to accomplish this:
|
||||||
@ -439,6 +437,8 @@ installation, you can use volumes to accomplish this:
|
|||||||
services:
|
services:
|
||||||
# ...
|
# ...
|
||||||
webserver:
|
webserver:
|
||||||
|
environment:
|
||||||
|
- PAPERLESS_ENABLE_FLOWER
|
||||||
ports:
|
ports:
|
||||||
- 5555:5555 # (2)!
|
- 5555:5555 # (2)!
|
||||||
# ...
|
# ...
|
||||||
@ -447,7 +447,7 @@ services:
|
|||||||
```
|
```
|
||||||
|
|
||||||
1. Note the `:ro` tag means the file will be mounted as read only.
|
1. Note the `:ro` tag means the file will be mounted as read only.
|
||||||
2. `flower` runs by default on port 5555, but this can be configured
|
2. By default, Flower runs on port 5555, but this can be configured.
|
||||||
|
|
||||||
## Custom Container Initialization
|
## Custom Container Initialization
|
||||||
|
|
||||||
@ -508,6 +508,18 @@ existing tables) with:
|
|||||||
an older system may fix issues that can arise while setting up Paperless-ngx but
|
an older system may fix issues that can arise while setting up Paperless-ngx but
|
||||||
`utf8mb3` can cause issues with consumption (where `utf8mb4` does not).
|
`utf8mb3` can cause issues with consumption (where `utf8mb4` does not).
|
||||||
|
|
||||||
|
### Missing timezones
|
||||||
|
|
||||||
|
MySQL as well as MariaDB do not have any timezone information by default (though some
|
||||||
|
docker images such as the official MariaDB image take care of this for you) which will
|
||||||
|
cause unexpected behavior with date-based queries.
|
||||||
|
|
||||||
|
To fix this, execute one of the following commands:
|
||||||
|
|
||||||
|
MySQL: `mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql -p`
|
||||||
|
|
||||||
|
MariaDB: `mariadb-tzinfo-to-sql /usr/share/zoneinfo | mariadb -u root mysql -p`
|
||||||
|
|
||||||
## Barcodes {#barcodes}
|
## Barcodes {#barcodes}
|
||||||
|
|
||||||
Paperless is able to utilize barcodes for automatically performing some tasks.
|
Paperless is able to utilize barcodes for automatically performing some tasks.
|
||||||
@ -548,6 +560,14 @@ barcode is located. However, differing from the splitting, the page with the
|
|||||||
barcode _will_ be retained. This allows application of a barcode to any page, including
|
barcode _will_ be retained. This allows application of a barcode to any page, including
|
||||||
one which holds data to keep in the document.
|
one which holds data to keep in the document.
|
||||||
|
|
||||||
|
### Tag Assignment
|
||||||
|
|
||||||
|
When enabled, Paperless will parse barcodes and attempt to interpret and assign tags.
|
||||||
|
|
||||||
|
See the relevant settings [`PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE`](configuration.md#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE)
|
||||||
|
and [`PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING`](configuration.md#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING)
|
||||||
|
for more information.
|
||||||
|
|
||||||
## Automatic collation of double-sided documents {#collate}
|
## Automatic collation of double-sided documents {#collate}
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@ -608,7 +628,7 @@ scan a completely new "odd numbered pages" one. The old staging file will get di
|
|||||||
|
|
||||||
The collation feature can be used together with the [subdirs as tags](configuration.md#consume_config)
|
The collation feature can be used together with the [subdirs as tags](configuration.md#consume_config)
|
||||||
feature (but this is not a requirement). Just create a correctly named double-sided subdir
|
feature (but this is not a requirement). Just create a correctly named double-sided subdir
|
||||||
in the hierachy and upload your scans there. For example, both `double-sided/foo/bar` as
|
in the hierarchy and upload your scans there. For example, both `double-sided/foo/bar` as
|
||||||
well as `foo/bar/double-sided` will cause the collated document to be treated as if it
|
well as `foo/bar/double-sided` will cause the collated document to be treated as if it
|
||||||
were uploaded into `foo/bar` and receive both `foo` and `bar` tags, but not `double-sided`.
|
were uploaded into `foo/bar` and receive both `foo` and `bar` tags, but not `double-sided`.
|
||||||
|
|
||||||
@ -619,3 +639,52 @@ single-sided split marker page, the split document(s) will have an empty page at
|
|||||||
whatever else was on the backside of the split marker page.) You can work around that by having
|
whatever else was on the backside of the split marker page.) You can work around that by having
|
||||||
a split marker page that has the split barcode on _both_ sides. This way, the extra page will
|
a split marker page that has the split barcode on _both_ sides. This way, the extra page will
|
||||||
get automatically removed.
|
get automatically removed.
|
||||||
|
|
||||||
|
## SSO and third party authentication with Paperless-ngx
|
||||||
|
|
||||||
|
Paperless-ngx has a built-in authentication system from Django but you can easily integrate an
|
||||||
|
external authentication solution using one of the following methods:
|
||||||
|
|
||||||
|
### Remote User authentication
|
||||||
|
|
||||||
|
This is a simple option that uses remote user authentication made available by certain SSO
|
||||||
|
applications. See the relevant configuration options for more information:
|
||||||
|
[PAPERLESS_ENABLE_HTTP_REMOTE_USER](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER),
|
||||||
|
[PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME](configuration.md#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME)
|
||||||
|
and [PAPERLESS_LOGOUT_REDIRECT_URL](configuration.md#PAPERLESS_LOGOUT_REDIRECT_URL)
|
||||||
|
|
||||||
|
### OpenID Connect and social authentication
|
||||||
|
|
||||||
|
Version 2.5.0 of Paperless-ngx added support for integrating other authentication systems via
|
||||||
|
the [django-allauth](https://github.com/pennersr/django-allauth) package. Once set up, users
|
||||||
|
can either log in or (optionally) sign up using any third party systems you integrate. See the
|
||||||
|
relevant [configuration settings](configuration.md#PAPERLESS_SOCIALACCOUNT_PROVIDERS) and
|
||||||
|
[django-allauth docs](https://docs.allauth.org/en/latest/socialaccount/configuration.html)
|
||||||
|
for more information.
|
||||||
|
|
||||||
|
To associate an existing Paperless-ngx account with a social account, first login with your
|
||||||
|
regular credentials and then choose "My Profile" from the user dropdown in the app and you
|
||||||
|
will see options to connect social account(s). If enabled, signup options will be available
|
||||||
|
on the login page.
|
||||||
|
|
||||||
|
As an example, to set up login via Github, the following environment variables would need to be
|
||||||
|
set:
|
||||||
|
|
||||||
|
```conf
|
||||||
|
PAPERLESS_APPS="allauth.socialaccount.providers.github"
|
||||||
|
PAPERLESS_SOCIALACCOUNT_PROVIDERS='{"github": {"APPS": [{"provider_id": "github","name": "Github","client_id": "<CLIENT_ID>","secret": "<CLIENT_SECRET>"}]}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, to use OpenID Connect ("OIDC"), via Keycloak in this example:
|
||||||
|
|
||||||
|
```conf
|
||||||
|
PAPERLESS_APPS="allauth.socialaccount.providers.openid_connect"
|
||||||
|
PAPERLESS_SOCIALACCOUNT_PROVIDERS='
|
||||||
|
{"openid_connect": {"APPS": [{"provider_id": "keycloak","name": "Keycloak","client_id": "paperless","secret": "<CLIENT_SECRET>","settings": { "server_url": "https://<KEYCLOAK_SERVER>/realms/<REALM>/.well-known/openid-configuration"}}]}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
More details about configuration option for various providers can be found in the [allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html#provider-specifics).
|
||||||
|
|
||||||
|
### Disabling Regular Login
|
||||||
|
|
||||||
|
Once external auth is set up, 'regular' login can be disabled with the [PAPERLESS_DISABLE_REGULAR_LOGIN](configuration.md#PAPERLESS_DISABLE_REGULAR_LOGIN) setting.
|
||||||
|
166
docs/api.md
@ -8,19 +8,23 @@ most of the available filters and ordering fields.
|
|||||||
|
|
||||||
The API provides the following main endpoints:
|
The API provides the following main endpoints:
|
||||||
|
|
||||||
- `/api/documents/`: Full CRUD support, except POSTing new documents.
|
|
||||||
See below.
|
|
||||||
- `/api/correspondents/`: Full CRUD support.
|
- `/api/correspondents/`: Full CRUD support.
|
||||||
|
- `/api/custom_fields/`: Full CRUD support.
|
||||||
|
- `/api/documents/`: Full CRUD support, except POSTing new documents.
|
||||||
|
See [below](#file-uploads).
|
||||||
- `/api/document_types/`: Full CRUD support.
|
- `/api/document_types/`: Full CRUD support.
|
||||||
|
- `/api/groups/`: Full CRUD support.
|
||||||
- `/api/logs/`: Read-Only.
|
- `/api/logs/`: Read-Only.
|
||||||
- `/api/tags/`: Full CRUD support.
|
|
||||||
- `/api/tasks/`: Read-only.
|
|
||||||
- `/api/mail_accounts/`: Full CRUD support.
|
- `/api/mail_accounts/`: Full CRUD support.
|
||||||
- `/api/mail_rules/`: Full CRUD support.
|
- `/api/mail_rules/`: Full CRUD support.
|
||||||
- `/api/users/`: Full CRUD support.
|
- `/api/profile/`: GET, PATCH
|
||||||
- `/api/groups/`: Full CRUD support.
|
|
||||||
- `/api/share_links/`: Full CRUD support.
|
- `/api/share_links/`: Full CRUD support.
|
||||||
- `/api/custom_fields/`: Full CRUD support.
|
- `/api/storage_paths/`: Full CRUD support.
|
||||||
|
- `/api/tags/`: Full CRUD support.
|
||||||
|
- `/api/tasks/`: Read-only.
|
||||||
|
- `/api/users/`: Full CRUD support.
|
||||||
|
- `/api/workflows/`: Full CRUD support.
|
||||||
|
- `/api/search/` GET, see [below](#global-search).
|
||||||
|
|
||||||
All of these endpoints except for the logging endpoint allow you to
|
All of these endpoints except for the logging endpoint allow you to
|
||||||
fetch (and edit and delete where appropriate) individual objects by
|
fetch (and edit and delete where appropriate) individual objects by
|
||||||
@ -53,7 +57,11 @@ fields:
|
|||||||
- `set_permissions`: Allows setting document permissions. Optional,
|
- `set_permissions`: Allows setting document permissions. Optional,
|
||||||
write-only. See [below](#permissions).
|
write-only. See [below](#permissions).
|
||||||
- `custom_fields`: Array of custom fields & values, specified as
|
- `custom_fields`: Array of custom fields & values, specified as
|
||||||
{ field: CUSTOM_FIELD_ID, value: VALUE }
|
`{ field: CUSTOM_FIELD_ID, value: VALUE }`
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
Note that all endpoint URLs must end with a `/`slash.
|
||||||
|
|
||||||
## Downloading documents
|
## Downloading documents
|
||||||
|
|
||||||
@ -133,10 +141,11 @@ document. Paperless only reports PDF metadata at this point.
|
|||||||
|
|
||||||
- `/api/documents/<id>/notes/`: Retrieve notes for a document.
|
- `/api/documents/<id>/notes/`: Retrieve notes for a document.
|
||||||
- `/api/documents/<id>/share_links/`: Retrieve share links for a document.
|
- `/api/documents/<id>/share_links/`: Retrieve share links for a document.
|
||||||
|
- `/api/documents/<id>/history/`: Retrieve history of changes for a document.
|
||||||
|
|
||||||
## Authorization
|
## Authorization
|
||||||
|
|
||||||
The REST api provides three different forms of authentication.
|
The REST api provides four different forms of authentication.
|
||||||
|
|
||||||
1. Basic authentication
|
1. Basic authentication
|
||||||
|
|
||||||
@ -157,6 +166,10 @@ The REST api provides three different forms of authentication.
|
|||||||
|
|
||||||
3. Token authentication
|
3. Token authentication
|
||||||
|
|
||||||
|
You can create (or re-create) an API token by opening the "My Profile"
|
||||||
|
link in the user dropdown found in the web UI and clicking the circular
|
||||||
|
arrow button.
|
||||||
|
|
||||||
Paperless also offers an endpoint to acquire authentication tokens.
|
Paperless also offers an endpoint to acquire authentication tokens.
|
||||||
|
|
||||||
POST a username and password as a form or json string to
|
POST a username and password as a form or json string to
|
||||||
@ -168,7 +181,45 @@ The REST api provides three different forms of authentication.
|
|||||||
Authorization: Token <token>
|
Authorization: Token <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Tokens can be managed and revoked in the paperless admin.
|
Tokens can also be managed in the Django admin.
|
||||||
|
|
||||||
|
4. Remote User authentication
|
||||||
|
|
||||||
|
If enabled (see
|
||||||
|
[configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
|
||||||
|
you can authenticate against the API using Remote User auth.
|
||||||
|
|
||||||
|
## Global search
|
||||||
|
|
||||||
|
A global search endpoint is available at `/api/search/` and requires a search term
|
||||||
|
of > 2 characters e.g. `?query=foo`. This endpoint returns a maximum of 3 results
|
||||||
|
across nearly all objects, e.g. documents, tags, saved views, mail rules, etc.
|
||||||
|
Results are only included if the requesting user has the appropriate permissions.
|
||||||
|
|
||||||
|
Results are returned in the following format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
total: number
|
||||||
|
documents: []
|
||||||
|
saved_views: []
|
||||||
|
correspondents: []
|
||||||
|
document_types: []
|
||||||
|
storage_paths: []
|
||||||
|
tags: []
|
||||||
|
users: []
|
||||||
|
groups: []
|
||||||
|
mail_accounts: []
|
||||||
|
mail_rules: []
|
||||||
|
custom_fields: []
|
||||||
|
workflows: []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Global search first searches objects by name (or title for documents) matching the query.
|
||||||
|
If the optional `db_only` parameter is set, only document titles will be searched. Otherwise,
|
||||||
|
if the amount of documents returned by a simple title string search is < 3, results from the
|
||||||
|
search index will also be included.
|
||||||
|
|
||||||
## Searching for documents
|
## Searching for documents
|
||||||
|
|
||||||
@ -178,7 +229,7 @@ results:
|
|||||||
|
|
||||||
- `/api/documents/?query=your%20search%20query`: Search for a document
|
- `/api/documents/?query=your%20search%20query`: Search for a document
|
||||||
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
|
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
|
||||||
- `/api/documents/?more_like=1234`: Search for documents similar to
|
- `/api/documents/?more_like_id=1234`: Search for documents similar to
|
||||||
the document with id 1234.
|
the document with id 1234.
|
||||||
|
|
||||||
Pagination works exactly the same as it does for normal requests on this
|
Pagination works exactly the same as it does for normal requests on this
|
||||||
@ -267,9 +318,12 @@ The endpoint supports the following optional form fields:
|
|||||||
- `correspondent`: Specify the ID of a correspondent that the consumer
|
- `correspondent`: Specify the ID of a correspondent that the consumer
|
||||||
should use for the document.
|
should use for the document.
|
||||||
- `document_type`: Similar to correspondent.
|
- `document_type`: Similar to correspondent.
|
||||||
|
- `storage_path`: Similar to correspondent.
|
||||||
- `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`: An array of custom field ids to assign (with an empty
|
||||||
|
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
|
||||||
@ -316,6 +370,86 @@ granted). You can pass the parameter `full_perms=true` to API calls to view the
|
|||||||
full permissions of objects in a format that mirrors the `set_permissions`
|
full permissions of objects in a format that mirrors the `set_permissions`
|
||||||
parameter above.
|
parameter above.
|
||||||
|
|
||||||
|
## Bulk Editing
|
||||||
|
|
||||||
|
The API supports various bulk-editing operations which are executed asynchronously.
|
||||||
|
|
||||||
|
### Documents
|
||||||
|
|
||||||
|
For bulk operations on documents, use the endpoint `/api/documents/bulk_edit/` which accepts
|
||||||
|
a json payload of the format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"documents": [LIST_OF_DOCUMENT_IDS],
|
||||||
|
"method": METHOD, // see below
|
||||||
|
"parameters": args // see below
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The following methods are supported:
|
||||||
|
|
||||||
|
- `set_correspondent`
|
||||||
|
- Requires `parameters`: `{ "correspondent": CORRESPONDENT_ID }`
|
||||||
|
- `set_document_type`
|
||||||
|
- Requires `parameters`: `{ "document_type": DOCUMENT_TYPE_ID }`
|
||||||
|
- `set_storage_path`
|
||||||
|
- Requires `parameters`: `{ "storage_path": STORAGE_PATH_ID }`
|
||||||
|
- `add_tag`
|
||||||
|
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||||
|
- `remove_tag`
|
||||||
|
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||||
|
- `modify_tags`
|
||||||
|
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and / or `{ "remove_tags": [LIST_OF_TAG_IDS] }`
|
||||||
|
- `delete`
|
||||||
|
- No `parameters` required
|
||||||
|
- `reprocess`
|
||||||
|
- No `parameters` required
|
||||||
|
- `set_permissions`
|
||||||
|
- Requires `parameters`:
|
||||||
|
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
||||||
|
- `"owner": OWNER_ID or null`
|
||||||
|
- `"merge": true or false` (defaults to false)
|
||||||
|
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
||||||
|
removing them) or be merged with existing permissions.
|
||||||
|
- `merge`
|
||||||
|
- No additional `parameters` required.
|
||||||
|
- The ordering of the merged document is determined by the list of IDs.
|
||||||
|
- Optional `parameters`:
|
||||||
|
- `"metadata_document_id": DOC_ID` apply metadata (tags, correspondent, etc.) from this document to the merged document.
|
||||||
|
- `"delete_originals": true` to delete the original documents. This requires the calling user being the owner of
|
||||||
|
all documents that are merged.
|
||||||
|
- `split`
|
||||||
|
- Requires `parameters`:
|
||||||
|
- `"pages": [..]` The list should be a list of pages and/or a ranges, separated by commas e.g. `"[1,2-3,4,5-7]"`
|
||||||
|
- Optional `parameters`:
|
||||||
|
- `"delete_originals": true` to delete the original document after consumption. This requires the calling user being the owner of
|
||||||
|
the document.
|
||||||
|
- The split operation only accepts a single document.
|
||||||
|
- `rotate`
|
||||||
|
- Requires `parameters`:
|
||||||
|
- `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270
|
||||||
|
- `delete_pages`
|
||||||
|
- Requires `parameters`:
|
||||||
|
- `"pages": [..]` The list should be a list of integers e.g. `"[2,3,4]"`
|
||||||
|
- The delete_pages operation only accepts a single document.
|
||||||
|
|
||||||
|
### Objects
|
||||||
|
|
||||||
|
Bulk editing for objects (tags, document types etc.) currently supports set permissions or delete
|
||||||
|
operations, using the endpoint: `/api/bulk_edit_objects/`, which requires a json payload of the format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objects": [LIST_OF_OBJECT_IDS],
|
||||||
|
"object_type": "tags", "correspondents", "document_types" or "storage_paths",
|
||||||
|
"operation": "set_permissions" or "delete",
|
||||||
|
"owner": OWNER_ID, // optional
|
||||||
|
"permissions": { "view": { "users": [] ... }, "change": { ... } }, // (see 'set_permissions' format above)
|
||||||
|
"merge": true / false // defaults to false, see above
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## API Versioning
|
## API Versioning
|
||||||
|
|
||||||
The REST API is versioned since Paperless-ngx 1.3.0.
|
The REST API is versioned since Paperless-ngx 1.3.0.
|
||||||
@ -372,3 +506,13 @@ Initial API version.
|
|||||||
color to use for a specific tag, which is either black or white
|
color to use for a specific tag, which is either black or white
|
||||||
depending on the brightness of `Tag.color`.
|
depending on the brightness of `Tag.color`.
|
||||||
- Removed field `Tag.colour`.
|
- Removed field `Tag.colour`.
|
||||||
|
|
||||||
|
#### Version 3
|
||||||
|
|
||||||
|
- Permissions endpoints have been added.
|
||||||
|
- The format of the `/api/ui_settings/` has changed.
|
||||||
|
|
||||||
|
#### Version 4
|
||||||
|
|
||||||
|
- Consumption templates were refactored to workflows and API endpoints
|
||||||
|
changed as such.
|
||||||
|
Before Width: | Height: | Size: 160 KiB |
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.3 MiB |
Before Width: | Height: | Size: 550 KiB After Width: | Height: | Size: 559 KiB |
BIN
docs/assets/screenshots/workflow.png
Normal file
After Width: | Height: | Size: 137 KiB |
1240
docs/changelog.md
@ -3,6 +3,11 @@
|
|||||||
Paperless provides a wide range of customizations. Depending on how you
|
Paperless provides a wide range of customizations. Depending on how you
|
||||||
run paperless, these settings have to be defined in different places.
|
run paperless, these settings have to be defined in different places.
|
||||||
|
|
||||||
|
Certain configuration options may be set via the UI. This currently includes
|
||||||
|
common [OCR](#ocr) related settings and some frontend settings. If set, these will take
|
||||||
|
preference over the settings via environment variables. If not set, the environment setting
|
||||||
|
or applicable default will be utilized instead.
|
||||||
|
|
||||||
- If you run paperless on docker, `paperless.conf` is not used.
|
- If you run paperless on docker, `paperless.conf` is not used.
|
||||||
Rather, configure paperless by copying necessary options to
|
Rather, configure paperless by copying necessary options to
|
||||||
`docker-compose.env`.
|
`docker-compose.env`.
|
||||||
@ -29,6 +34,8 @@ matcher.
|
|||||||
`redis://<username>:<password>@<host>:<port>`
|
`redis://<username>:<password>@<host>:<port>`
|
||||||
- With the requirepass option PAPERLESS_REDIS =
|
- With the requirepass option PAPERLESS_REDIS =
|
||||||
`redis://:<password>@<host>:<port>`
|
`redis://:<password>@<host>:<port>`
|
||||||
|
- To include the redis database index PAPERLESS_REDIS =
|
||||||
|
`redis://<username>:<password>@<host>:<port>/<DBIndex>`
|
||||||
|
|
||||||
[More information on securing your Redis
|
[More information on securing your Redis
|
||||||
Instance](https://redis.io/docs/getting-started/#securing-redis).
|
Instance](https://redis.io/docs/getting-started/#securing-redis).
|
||||||
@ -170,13 +177,13 @@ configure their endpoints, and enable the feature.
|
|||||||
|
|
||||||
#### [`PAPERLESS_TIKA_ENDPOINT=<url>`](#PAPERLESS_TIKA_ENDPOINT) {#PAPERLESS_TIKA_ENDPOINT}
|
#### [`PAPERLESS_TIKA_ENDPOINT=<url>`](#PAPERLESS_TIKA_ENDPOINT) {#PAPERLESS_TIKA_ENDPOINT}
|
||||||
|
|
||||||
: Set the endpoint URL were Paperless can reach your Tika server.
|
: Set the endpoint URL where Paperless can reach your Tika server.
|
||||||
|
|
||||||
Defaults to "<http://localhost:9998>".
|
Defaults to "<http://localhost:9998>".
|
||||||
|
|
||||||
#### [`PAPERLESS_TIKA_GOTENBERG_ENDPOINT=<url>`](#PAPERLESS_TIKA_GOTENBERG_ENDPOINT) {#PAPERLESS_TIKA_GOTENBERG_ENDPOINT}
|
#### [`PAPERLESS_TIKA_GOTENBERG_ENDPOINT=<url>`](#PAPERLESS_TIKA_GOTENBERG_ENDPOINT) {#PAPERLESS_TIKA_GOTENBERG_ENDPOINT}
|
||||||
|
|
||||||
: Set the endpoint URL were Paperless can reach your Gotenberg server.
|
: Set the endpoint URL where Paperless can reach your Gotenberg server.
|
||||||
|
|
||||||
Defaults to "<http://localhost:3000>".
|
Defaults to "<http://localhost:3000>".
|
||||||
|
|
||||||
@ -195,7 +202,7 @@ and watch out for indentation if editing the YAML file.
|
|||||||
|
|
||||||
#### [`PAPERLESS_CONSUMPTION_DIR=<path>`](#PAPERLESS_CONSUMPTION_DIR) {#PAPERLESS_CONSUMPTION_DIR}
|
#### [`PAPERLESS_CONSUMPTION_DIR=<path>`](#PAPERLESS_CONSUMPTION_DIR) {#PAPERLESS_CONSUMPTION_DIR}
|
||||||
|
|
||||||
: This where your documents should go to be consumed. Make sure that
|
: This is where your documents should go to be consumed. Make sure that
|
||||||
it exists and that the user running the paperless service can
|
it exists and that the user running the paperless service can
|
||||||
read/write its contents before you start Paperless.
|
read/write its contents before you start Paperless.
|
||||||
|
|
||||||
@ -257,7 +264,7 @@ directory. See [File name handling](advanced_usage.md#file-name-handling) for de
|
|||||||
: Tells paperless to replace placeholders in
|
: Tells paperless to replace placeholders in
|
||||||
`PAPERLESS_FILENAME_FORMAT` that would resolve to
|
`PAPERLESS_FILENAME_FORMAT` that would resolve to
|
||||||
'none' to be omitted from the resulting filename. This also holds
|
'none' to be omitted from the resulting filename. This also holds
|
||||||
true for directory names. See [File name handling](advanced_usage.md#file-name-handling) for
|
true for directory names. See [File name handling](advanced_usage.md#empty-placeholders) for
|
||||||
details.
|
details.
|
||||||
|
|
||||||
Defaults to `false` which disables this feature.
|
Defaults to `false` which disables this feature.
|
||||||
@ -281,6 +288,12 @@ this folder is no longer needed and can be removed manually.
|
|||||||
|
|
||||||
Defaults to `/usr/share/nltk_data`
|
Defaults to `/usr/share/nltk_data`
|
||||||
|
|
||||||
|
#### [`PAPERLESS_MODEL_FILE=<path>`](#PAPERLESS_MODEL_FILE) {#PAPERLESS_MODEL_FILE}
|
||||||
|
|
||||||
|
: This is where paperless will store the classification model.
|
||||||
|
|
||||||
|
Defaults to `PAPERLESS_DATA_DIR/classification_model.pickle`.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
#### [`PAPERLESS_LOGROTATE_MAX_SIZE=<num>`](#PAPERLESS_LOGROTATE_MAX_SIZE) {#PAPERLESS_LOGROTATE_MAX_SIZE}
|
#### [`PAPERLESS_LOGROTATE_MAX_SIZE=<num>`](#PAPERLESS_LOGROTATE_MAX_SIZE) {#PAPERLESS_LOGROTATE_MAX_SIZE}
|
||||||
@ -447,19 +460,32 @@ applications.
|
|||||||
|
|
||||||
This will allow authentication by simply adding a
|
This will allow authentication by simply adding a
|
||||||
`Remote-User: <username>` header to a request. Use with care! You
|
`Remote-User: <username>` header to a request. Use with care! You
|
||||||
especially *must: ensure that any such header is not passed from
|
especially *must* ensure that any such header is not passed from
|
||||||
your proxy server to paperless.
|
external requests to your reverse-proxy to paperless (that would
|
||||||
|
effectively bypass all authentication).
|
||||||
|
|
||||||
If you're exposing paperless to the internet directly, do not use
|
If you're exposing paperless to the internet directly (i.e.
|
||||||
this.
|
without a reverse proxy), do not use this.
|
||||||
|
|
||||||
Also see the warning [in the official documentation](https://docs.djangoproject.com/en/4.1/howto/auth-remote-user/#configuration).
|
Also see the warning [in the official documentation](https://docs.djangoproject.com/en/4.1/howto/auth-remote-user/#configuration).
|
||||||
|
|
||||||
Defaults to "false" which disables this feature.
|
Defaults to "false" which disables this feature.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ENABLE_HTTP_REMOTE_USER_API=<bool>`](#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API) {#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API}
|
||||||
|
|
||||||
|
: Allows authentication via HTTP_REMOTE_USER directly against the API
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
See the warning above about securing your installation when using remote user header authentication. This setting is separate from
|
||||||
|
`PAPERLESS_ENABLE_HTTP_REMOTE_USER` to avoid introducing a security vulnerability to existing reverse proxy setups. As above,
|
||||||
|
ensure that your reverse proxy does not simply pass the `Remote-User` header from the internet to paperless.
|
||||||
|
|
||||||
|
Defaults to "false" which disables this feature.
|
||||||
|
|
||||||
#### [`PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=<str>`](#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME) {#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME}
|
#### [`PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=<str>`](#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME) {#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME}
|
||||||
|
|
||||||
: If "PAPERLESS_ENABLE_HTTP_REMOTE_USER" is enabled, this
|
: If "PAPERLESS_ENABLE_HTTP_REMOTE_USER" or `PAPERLESS_ENABLE_HTTP_REMOTE_USER_API` are enabled, this
|
||||||
property allows to customize the name of the HTTP header from which
|
property allows to customize the name of the HTTP header from which
|
||||||
the authenticated username is extracted. Values are in terms of
|
the authenticated username is extracted. Values are in terms of
|
||||||
[HttpRequest.META](https://docs.djangoproject.com/en/4.1/ref/request-response/#django.http.HttpRequest.META).
|
[HttpRequest.META](https://docs.djangoproject.com/en/4.1/ref/request-response/#django.http.HttpRequest.META).
|
||||||
@ -471,8 +497,9 @@ followed by the normalized actual header name.
|
|||||||
#### [`PAPERLESS_LOGOUT_REDIRECT_URL=<str>`](#PAPERLESS_LOGOUT_REDIRECT_URL) {#PAPERLESS_LOGOUT_REDIRECT_URL}
|
#### [`PAPERLESS_LOGOUT_REDIRECT_URL=<str>`](#PAPERLESS_LOGOUT_REDIRECT_URL) {#PAPERLESS_LOGOUT_REDIRECT_URL}
|
||||||
|
|
||||||
: URL to redirect the user to after a logout. This can be used
|
: URL to redirect the user to after a logout. This can be used
|
||||||
together with PAPERLESS_ENABLE_HTTP_REMOTE_USER to
|
together with PAPERLESS_ENABLE_HTTP_REMOTE_USER and SSO to
|
||||||
redirect the user back to the SSO application's logout page.
|
redirect the user back to the SSO application's logout page to
|
||||||
|
complete the logout process.
|
||||||
|
|
||||||
Defaults to None, which disables this feature.
|
Defaults to None, which disables this feature.
|
||||||
|
|
||||||
@ -516,6 +543,64 @@ This is for use with self-signed certificates against local IMAP servers.
|
|||||||
Settings this value has security implications for the security of your email.
|
Settings this value has security implications for the security of your email.
|
||||||
Understand what it does and be sure you need to before setting.
|
Understand what it does and be sure you need to before setting.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SOCIALACCOUNT_PROVIDERS=<json>`](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) {#PAPERLESS_SOCIALACCOUNT_PROVIDERS}
|
||||||
|
|
||||||
|
: This variable is used to setup login and signup via social account providers which are compatible with django-allauth.
|
||||||
|
See the corresponding [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html)
|
||||||
|
for a list of provider configurations. You will also need to include the relevant Django 'application' inside the
|
||||||
|
[PAPERLESS_APPS](#PAPERLESS_APPS) setting to activate that specific authentication provider (e.g. `allauth.socialaccount.providers.openid_connect` for the [OIDC Connect provider](https://docs.allauth.org/en/latest/socialaccount/providers/openid_connect.html)).
|
||||||
|
|
||||||
|
Defaults to None, which does not enable any third party authentication systems.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SOCIAL_AUTO_SIGNUP=<bool>`](#PAPERLESS_SOCIAL_AUTO_SIGNUP) {#PAPERLESS_SOCIAL_AUTO_SIGNUP}
|
||||||
|
|
||||||
|
: Attempt to signup the user using retrieved email, username etc from the third party authentication
|
||||||
|
system. See the corresponding
|
||||||
|
[django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/configuration.html)
|
||||||
|
|
||||||
|
Defaults to False
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS}
|
||||||
|
|
||||||
|
: Allow users to signup for a new Paperless-ngx account using any setup third party authentication systems.
|
||||||
|
|
||||||
|
Defaults to True
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS}
|
||||||
|
|
||||||
|
: Allow users to signup for a new Paperless-ngx account.
|
||||||
|
|
||||||
|
Defaults to False
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL=<string>`](#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL) {#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL}
|
||||||
|
|
||||||
|
: The protocol used when generating URLs, e.g. login callback URLs. See the corresponding
|
||||||
|
[django-allauth documentation](https://docs.allauth.org/en/latest/account/configuration.html)
|
||||||
|
|
||||||
|
Defaults to 'https'
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ACCOUNT_EMAIL_VERIFICATION=<string>`](#PAPERLESS_ACCOUNT_EMAIL_VERIFICATION) {#PAPERLESS_ACCOUNT_EMAIL_VERIFICATION}
|
||||||
|
|
||||||
|
: Determines whether email addresses are verified during signup (as performed by Django allauth). See the relevant
|
||||||
|
[paperless settings](#PAPERLESS_EMAIL_HOST) and [the allauth docs](https://docs.allauth.org/en/latest/account/configuration.html)
|
||||||
|
|
||||||
|
Defaults to 'optional'
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
If you do not have a working email server set up you should set this to 'none'.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_DISABLE_REGULAR_LOGIN=<bool>`](#PAPERLESS_DISABLE_REGULAR_LOGIN) {#PAPERLESS_DISABLE_REGULAR_LOGIN}
|
||||||
|
|
||||||
|
: Disables the regular frontend username / password login, i.e. once you have setup SSO. Note that this setting does not disable the Django admin login. To prevent logins directly to Django, consider blocking `/admin/` in your [web server or reverse proxy configuration](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx).
|
||||||
|
|
||||||
|
Defaults to False
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ACCOUNT_SESSION_REMEMBER=<bool>`](#PAPERLESS_ACCOUNT_SESSION_REMEMBER) {#PAPERLESS_ACCOUNT_SESSION_REMEMBER}
|
||||||
|
|
||||||
|
: See the corresponding
|
||||||
|
[django-allauth documentation](https://docs.allauth.org/en/latest/account/configuration.html)
|
||||||
|
|
||||||
## OCR settings {#ocr}
|
## OCR settings {#ocr}
|
||||||
|
|
||||||
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)
|
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)
|
||||||
@ -537,6 +622,8 @@ parsing documents.
|
|||||||
Keep in mind that Tesseract uses much more CPU time with multiple
|
Keep in mind that Tesseract uses much more CPU time with multiple
|
||||||
languages enabled.
|
languages enabled.
|
||||||
|
|
||||||
|
If you are including languages that are not installed by default, you will need to also set [`PAPERLESS_OCR_LANGUAGES`](configuration.md#PAPERLESS_OCR_LANGUAGES) for docker deployments or install the tesseract language packages manually for bare metal installations.
|
||||||
|
|
||||||
Defaults to "eng".
|
Defaults to "eng".
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@ -660,11 +747,13 @@ completely.
|
|||||||
|
|
||||||
Specifying 1 here will only use the first page.
|
Specifying 1 here will only use the first page.
|
||||||
|
|
||||||
|
The value must be greater than or equal to 1 to be used.
|
||||||
|
|
||||||
When combined with `PAPERLESS_OCR_MODE=redo` or
|
When combined with `PAPERLESS_OCR_MODE=redo` or
|
||||||
`PAPERLESS_OCR_MODE=force`, paperless will not modify any text it
|
`PAPERLESS_OCR_MODE=force`, paperless will not modify any text it
|
||||||
finds on excluded pages and copy it verbatim.
|
finds on excluded pages and copy it verbatim.
|
||||||
|
|
||||||
Defaults to 0, which disables this feature and always uses all
|
Defaults to unset, which disables this feature and always uses all
|
||||||
pages.
|
pages.
|
||||||
|
|
||||||
#### [`PAPERLESS_OCR_IMAGE_DPI=<num>`](#PAPERLESS_OCR_IMAGE_DPI) {#PAPERLESS_OCR_IMAGE_DPI}
|
#### [`PAPERLESS_OCR_IMAGE_DPI=<num>`](#PAPERLESS_OCR_IMAGE_DPI) {#PAPERLESS_OCR_IMAGE_DPI}
|
||||||
@ -678,7 +767,7 @@ fails, it uses this value as a fallback.
|
|||||||
|
|
||||||
Set this to the DPI your scanner produces images at.
|
Set this to the DPI your scanner produces images at.
|
||||||
|
|
||||||
Default is none, which will automatically calculate image DPI so
|
Defaults to unset, which will automatically calculate image DPI so
|
||||||
that the produced PDF documents are A4 sized.
|
that the produced PDF documents are A4 sized.
|
||||||
|
|
||||||
#### [`PAPERLESS_OCR_MAX_IMAGE_PIXELS=<num>`](#PAPERLESS_OCR_MAX_IMAGE_PIXELS) {#PAPERLESS_OCR_MAX_IMAGE_PIXELS}
|
#### [`PAPERLESS_OCR_MAX_IMAGE_PIXELS=<num>`](#PAPERLESS_OCR_MAX_IMAGE_PIXELS) {#PAPERLESS_OCR_MAX_IMAGE_PIXELS}
|
||||||
@ -691,6 +780,8 @@ but could result in missing text content.
|
|||||||
If unset, will default to the value determined by
|
If unset, will default to the value determined by
|
||||||
[Pillow](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.MAX_IMAGE_PIXELS).
|
[Pillow](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.MAX_IMAGE_PIXELS).
|
||||||
|
|
||||||
|
Setting this value to 0 will entirely disable the limit. See the below warning.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
Increasing this limit could cause Paperless to consume additional
|
Increasing this limit could cause Paperless to consume additional
|
||||||
@ -700,7 +791,7 @@ but could result in missing text content.
|
|||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
The limit is intended to prevent malicious files from consuming
|
The limit is intended to prevent malicious files from consuming
|
||||||
system resources and causing crashes and other errors. Only increase
|
system resources and causing crashes and other errors. Only change
|
||||||
this value if you are certain your documents are not malicious and
|
this value if you are certain your documents are not malicious and
|
||||||
you need the text which was not OCRed
|
you need the text which was not OCRed
|
||||||
|
|
||||||
@ -733,7 +824,7 @@ they use underscores instead of dashes.
|
|||||||
Paperless has been tested to work with the OCR options provided
|
Paperless has been tested to work with the OCR options provided
|
||||||
above. There are many options that are incompatible with each other,
|
above. There are many options that are incompatible with each other,
|
||||||
so specifying invalid options may prevent paperless from consuming
|
so specifying invalid options may prevent paperless from consuming
|
||||||
any documents.
|
any documents. Use with caution!
|
||||||
|
|
||||||
Specify arguments as a JSON dictionary. Keep note of lower case
|
Specify arguments as a JSON dictionary. Keep note of lower case
|
||||||
booleans and double quoted parameter names and strings. Examples:
|
booleans and double quoted parameter names and strings. Examples:
|
||||||
@ -884,6 +975,28 @@ documents.
|
|||||||
|
|
||||||
Default is none, which disables the temporary directory.
|
Default is none, which disables the temporary directory.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS}
|
||||||
|
|
||||||
|
: A comma-separated list of Django apps to be included in Django's
|
||||||
|
[`INSTALLED_APPS`](https://docs.djangoproject.com/en/5.0/ref/applications/). This setting should
|
||||||
|
be used with caution!
|
||||||
|
|
||||||
|
Defaults to None, which does not add any additional apps.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_MAX_IMAGE_PIXELS=<number>`](#PAPERLESS_MAX_IMAGE_PIXELS) {#PAPERLESS_MAX_IMAGE_PIXELS}
|
||||||
|
|
||||||
|
: Configures the maximum size of an image PIL will allow to load without warning or error.
|
||||||
|
|
||||||
|
: If unset, will default to the value determined by
|
||||||
|
[Pillow](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.MAX_IMAGE_PIXELS).
|
||||||
|
|
||||||
|
Defaults to None, which does change the limit
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
This limit is designed to prevent denial of service from malicious files.
|
||||||
|
It should only be raised or disabled in certain circumstances and with great care.
|
||||||
|
|
||||||
## Document Consumption {#consume_config}
|
## Document Consumption {#consume_config}
|
||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}
|
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}
|
||||||
@ -931,7 +1044,7 @@ or hidden folders some tools use to store data.
|
|||||||
`._foo.pdf` and `._bar/foo.pdf`
|
`._foo.pdf` and `._bar/foo.pdf`
|
||||||
|
|
||||||
Defaults to
|
Defaults to
|
||||||
`[".DS_STORE/*", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini", "@eaDir/*"]`.
|
`[".DS_Store", ".DS_STORE", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini", "@eaDir/*", "Thumbs.db"]`.
|
||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_BARCODE_SCANNER=<string>`](#PAPERLESS_CONSUMER_BARCODE_SCANNER) {#PAPERLESS_CONSUMER_BARCODE_SCANNER}
|
#### [`PAPERLESS_CONSUMER_BARCODE_SCANNER=<string>`](#PAPERLESS_CONSUMER_BARCODE_SCANNER) {#PAPERLESS_CONSUMER_BARCODE_SCANNER}
|
||||||
|
|
||||||
@ -981,7 +1094,7 @@ document text will be checked as normal.
|
|||||||
|
|
||||||
: Paperless searches an entire document for dates. The first date
|
: Paperless searches an entire document for dates. The first date
|
||||||
found will be used as the initial value for the created date. When
|
found will be used as the initial value for the created date. When
|
||||||
this variable is greater than 0 (or left to it's default value),
|
this variable is greater than 0 (or left to its default value),
|
||||||
paperless will also suggest other dates found in the document, up to
|
paperless will also suggest other dates found in the document, up to
|
||||||
a maximum of this setting. Note that duplicates will be removed,
|
a maximum of this setting. Note that duplicates will be removed,
|
||||||
which can result in fewer dates displayed in the frontend than this
|
which can result in fewer dates displayed in the frontend than this
|
||||||
@ -1006,11 +1119,11 @@ This font can be changed here.
|
|||||||
|
|
||||||
#### [`PAPERLESS_IGNORE_DATES=<string>`](#PAPERLESS_IGNORE_DATES) {#PAPERLESS_IGNORE_DATES}
|
#### [`PAPERLESS_IGNORE_DATES=<string>`](#PAPERLESS_IGNORE_DATES) {#PAPERLESS_IGNORE_DATES}
|
||||||
|
|
||||||
: Paperless parses a documents creation date from filename and file
|
: Paperless parses a document's creation date from filename and file
|
||||||
content. You may specify a comma separated list of dates that should
|
content. You may specify a comma separated list of dates that should
|
||||||
be ignored during this process. This is useful for special dates
|
be ignored during this process. This is useful for special dates
|
||||||
(like date of birth) that appear in documents regularly but are very
|
(like date of birth) that appear in documents regularly but are very
|
||||||
unlikely to be the documents creation date.
|
unlikely to be the document's creation date.
|
||||||
|
|
||||||
The date is parsed using the order specified in PAPERLESS_DATE_ORDER
|
The date is parsed using the order specified in PAPERLESS_DATE_ORDER
|
||||||
|
|
||||||
@ -1042,8 +1155,10 @@ system changes with `inotify`.
|
|||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_POLLING_RETRY_COUNT=<num>`](#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT) {#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT}
|
#### [`PAPERLESS_CONSUMER_POLLING_RETRY_COUNT=<num>`](#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT) {#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT}
|
||||||
|
|
||||||
: If consumer polling is enabled, sets the number of times paperless
|
: If consumer polling is enabled, sets the maximum number of times
|
||||||
will check for a file to remain unmodified.
|
paperless will check for a file to remain unmodified. If a file's
|
||||||
|
modification time and size are identical for two consecutive checks, it
|
||||||
|
will be consumed.
|
||||||
|
|
||||||
Defaults to 5.
|
Defaults to 5.
|
||||||
|
|
||||||
@ -1135,7 +1250,7 @@ barcode.
|
|||||||
|
|
||||||
: Defines the upscale factor used in barcode detection.
|
: Defines the upscale factor used in barcode detection.
|
||||||
Improves the detection of small barcodes, i.e. with a value of 1.5 by
|
Improves the detection of small barcodes, i.e. with a value of 1.5 by
|
||||||
upscaling the document beforce the detection process. Upscaling will
|
upscaling the document before the detection process. Upscaling will
|
||||||
only take place if value is bigger than 1.0. Otherwise upscaling will
|
only take place if value is bigger than 1.0. Otherwise upscaling will
|
||||||
not be performed to save resources. Try using in combination with
|
not be performed to save resources. Try using in combination with
|
||||||
PAPERLESS_CONSUMER_BARCODE_DPI set to a value higher than default.
|
PAPERLESS_CONSUMER_BARCODE_DPI set to a value higher than default.
|
||||||
@ -1152,14 +1267,62 @@ combination with PAPERLESS_CONSUMER_BARCODE_UPSCALE bigger than 1.0.
|
|||||||
|
|
||||||
Defaults to "300"
|
Defaults to "300"
|
||||||
|
|
||||||
|
#### [`PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE=<bool>`](#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE) {#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE}
|
||||||
|
|
||||||
|
: Enables the detection of barcodes in the scanned document and
|
||||||
|
assigns or creates tags if a properly formatted barcode is detected.
|
||||||
|
|
||||||
|
The barcode must match one of the (configurable) regular expressions.
|
||||||
|
If the barcode text contains ',' (comma), it is split into multiple
|
||||||
|
barcodes which are individually processed for tagging.
|
||||||
|
|
||||||
|
Matching is case insensitive.
|
||||||
|
|
||||||
|
Defaults to false.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING=<json dict>`](#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING) {#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING}
|
||||||
|
|
||||||
|
: Defines a dictionary of filter regex and substitute expressions.
|
||||||
|
|
||||||
|
Syntax: `{"<regex>": "<substitute>" [,...]]}`
|
||||||
|
|
||||||
|
A barcode is considered for tagging if the barcode text matches
|
||||||
|
at least one of the provided <regex> pattern.
|
||||||
|
|
||||||
|
If a match is found, the <substitute> rule is applied. This allows very
|
||||||
|
versatile reformatting and mapping of barcode pattern to tag values.
|
||||||
|
|
||||||
|
If a tag is not found it will be created.
|
||||||
|
|
||||||
|
Defaults to:
|
||||||
|
|
||||||
|
`{"TAG:(.*)": "\\g<1>"}` which defines
|
||||||
|
- a regex TAG:(.*) which includes barcodes beginning with TAG:
|
||||||
|
followed by any text that gets stored into match group #1 and
|
||||||
|
- a substitute `\\g<1>` that replaces the original barcode text
|
||||||
|
by the content in match group #1.
|
||||||
|
Consequently, the tag is the barcode text without its TAG: prefix.
|
||||||
|
|
||||||
|
More examples:
|
||||||
|
|
||||||
|
`{"ASN12.*": "JOHN", "ASN13.*": "SMITH"}` for example maps
|
||||||
|
- ASN12nnnn barcodes to the tag JOHN and
|
||||||
|
- ASN13nnnn barcodes to the tag SMITH.
|
||||||
|
|
||||||
|
`{"T-J": "JOHN", "T-S": "SMITH", "T-D": "DOE"}` directly maps
|
||||||
|
- T-J barcodes to the tag JOHN,
|
||||||
|
- T-S barcodes to the tag SMITH and
|
||||||
|
- T-D barcodes to the tag DOE.
|
||||||
|
|
||||||
|
Please refer to the Python regex documentation for more information.
|
||||||
|
|
||||||
## Audit Trail
|
## Audit Trail
|
||||||
|
|
||||||
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
|
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
|
||||||
|
|
||||||
: Enables an audit trail for documents, document types, correspondents, and tags. Log entries can be viewed in the Django backend only.
|
: Enables the audit trail for documents, document types, correspondents, and tags.
|
||||||
|
|
||||||
!!! warning
|
Defaults to true.
|
||||||
Once enabled cannot be disabled
|
|
||||||
|
|
||||||
## Collate Double-Sided Documents {#collate}
|
## Collate Double-Sided Documents {#collate}
|
||||||
|
|
||||||
@ -1298,7 +1461,7 @@ specified as "chi-tra".
|
|||||||
PAPERLESS_OCR_LANGUAGES=tur ces chi-tra
|
PAPERLESS_OCR_LANGUAGES=tur ces chi-tra
|
||||||
```
|
```
|
||||||
|
|
||||||
Make sure it's a space separated list when using several values.
|
Make sure it's a space-separated list when using several values.
|
||||||
|
|
||||||
To actually use these languages, also set the default OCR language
|
To actually use these languages, also set the default OCR language
|
||||||
of paperless:
|
of paperless:
|
||||||
@ -1309,6 +1472,10 @@ specified as "chi-tra".
|
|||||||
|
|
||||||
Defaults to none, which does not install any additional languages.
|
Defaults to none, which does not install any additional languages.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
This option must not be used in rootless containers.
|
||||||
|
|
||||||
#### [`PAPERLESS_ENABLE_FLOWER=<defined>`](#PAPERLESS_ENABLE_FLOWER) {#PAPERLESS_ENABLE_FLOWER}
|
#### [`PAPERLESS_ENABLE_FLOWER=<defined>`](#PAPERLESS_ENABLE_FLOWER) {#PAPERLESS_ENABLE_FLOWER}
|
||||||
|
|
||||||
: If this environment variable is defined, the Celery monitoring tool
|
: If this environment variable is defined, the Celery monitoring tool
|
||||||
@ -1317,7 +1484,21 @@ started by the container.
|
|||||||
|
|
||||||
You can read more about this in the [advanced documentation](advanced_usage.md#celery-monitoring).
|
You can read more about this in the [advanced documentation](advanced_usage.md#celery-monitoring).
|
||||||
|
|
||||||
## Update Checking {#update-checking}
|
#### [`PAPERLESS_SUPERVISORD_WORKING_DIR=<defined>`](#PAPERLESS_SUPERVISORD_WORKING_DIR) {#PAPERLESS_SUPERVISORD_WORKING_DIR}
|
||||||
|
|
||||||
|
: If this environment variable is defined, the `supervisord.log` and `supervisord.pid` file will be created under the specified path in `PAPERLESS_SUPERVISORD_WORKING_DIR`. Setting `PAPERLESS_SUPERVISORD_WORKING_DIR=/tmp` and `PYTHONPYCACHEPREFIX=/tmp/pycache` would allow paperless to work on a read-only filesystem.
|
||||||
|
|
||||||
|
Please take note that the `PAPERLESS_DATA_DIR` and `PAPERLESS_MEDIA_ROOT` paths still have to be writable, just like the `PAPERLESS_SUPERVISORD_WORKING_DIR`. The can be archived by using bind or volume mounts. Only works in the container is run as user *paperless*
|
||||||
|
|
||||||
|
## Frontend Settings
|
||||||
|
|
||||||
|
#### [`PAPERLESS_APP_TITLE=<str>`](#PAPERLESS_APP_TITLE) {#PAPERLESS_APP_TITLE}
|
||||||
|
|
||||||
|
: If set, overrides the default name "Paperless-ngx"
|
||||||
|
|
||||||
|
#### [`PAPERLESS_APP_LOGO=<path>`](#PAPERLESS_APP_LOGO) {#PAPERLESS_APP_LOGO}
|
||||||
|
|
||||||
|
: Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg`
|
||||||
|
|
||||||
#### [`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}
|
||||||
|
|
||||||
@ -1345,6 +1526,10 @@ password. All of these options come from their similarly-named [Django settings]
|
|||||||
|
|
||||||
: Defaults to ''.
|
: Defaults to ''.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_EMAIL_FROM=<str>`](#PAPERLESS_EMAIL_FROM) {#PAPERLESS_EMAIL_FROM}
|
||||||
|
|
||||||
|
: Defaults to PAPERLESS_EMAIL_HOST_USER if not set.
|
||||||
|
|
||||||
#### [`PAPERLESS_EMAIL_HOST_PASSWORD=<str>`](#PAPERLESS_EMAIL_HOST_PASSWORD) {#PAPERLESS_EMAIL_HOST_PASSWORD}
|
#### [`PAPERLESS_EMAIL_HOST_PASSWORD=<str>`](#PAPERLESS_EMAIL_HOST_PASSWORD) {#PAPERLESS_EMAIL_HOST_PASSWORD}
|
||||||
|
|
||||||
: Defaults to ''.
|
: Defaults to ''.
|
||||||
|
@ -47,7 +47,7 @@ early on.
|
|||||||
Once installed, hooks will run when you commit. If the formatting isn't
|
Once installed, hooks will run when you commit. If the formatting isn't
|
||||||
quite right or a linter catches something, the commit will be rejected.
|
quite right or a linter catches something, the commit will be rejected.
|
||||||
You'll need to look at the output and fix the issue. Some hooks, such
|
You'll need to look at the output and fix the issue. Some hooks, such
|
||||||
as the Python formatting tool `black`, will format failing
|
as the Python linting and formatting tool `ruff`, will format failing
|
||||||
files, so all you need to do is `git add` those files again
|
files, so all you need to do is `git add` those files again
|
||||||
and retry your commit.
|
and retry your commit.
|
||||||
|
|
||||||
@ -81,10 +81,6 @@ first-time setup.
|
|||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
Using a virtual environment is highly recommended. You can spawn one via `pipenv shell`.
|
Using a virtual environment is highly recommended. You can spawn one via `pipenv shell`.
|
||||||
Make sure you're using Python 3.10.x or lower. Otherwise you might
|
|
||||||
get issues with building dependencies. You can use
|
|
||||||
[pyenv](https://github.com/pyenv/pyenv) to install a specific
|
|
||||||
Python version.
|
|
||||||
|
|
||||||
5. Install pre-commit hooks:
|
5. Install pre-commit hooks:
|
||||||
|
|
||||||
@ -277,27 +273,17 @@ Adding new languages requires adding the translated files in the
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Add the language to the available options in
|
2. Add the language to the `LANGUAGE_OPTIONS` array in
|
||||||
`src-ui/src/app/services/settings.service.ts`:
|
`src-ui/src/app/services/settings.service.ts`:
|
||||||
|
|
||||||
```typescript
|
|
||||||
getLanguageOptions(): LanguageOption[] {
|
|
||||||
return [
|
|
||||||
{code: "en-us", name: $localize`English (US)`, englishName: "English (US)", dateInputFormat: "mm/dd/yyyy"},
|
|
||||||
{code: "en-gb", name: $localize`English (GB)`, englishName: "English (GB)", dateInputFormat: "dd/mm/yyyy"},
|
|
||||||
{code: "de", name: $localize`German`, englishName: "German", dateInputFormat: "dd.mm.yyyy"},
|
|
||||||
{code: "nl", name: $localize`Dutch`, englishName: "Dutch", dateInputFormat: "dd-mm-yyyy"},
|
|
||||||
{code: "fr", name: $localize`French`, englishName: "French", dateInputFormat: "dd/mm/yyyy"},
|
|
||||||
{code: "pt-br", name: $localize`Portuguese (Brazil)`, englishName: "Portuguese (Brazil)", dateInputFormat: "dd/mm/yyyy"}
|
|
||||||
// Add your new language here
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`dateInputFormat` is a special string that defines the behavior of
|
`dateInputFormat` is a special string that defines the behavior of
|
||||||
the date input fields and absolutely needs to contain "dd", "mm"
|
the date input fields and absolutely needs to contain "dd", "mm"
|
||||||
and "yyyy".
|
and "yyyy".
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
3. Import and register the Angular data for this locale in
|
3. Import and register the Angular data for this locale in
|
||||||
`src-ui/src/app/app.module.ts`:
|
`src-ui/src/app/app.module.ts`:
|
||||||
|
|
||||||
|
@ -8,6 +8,13 @@ physical documents into a searchable online archive so you can keep, well, _less
|
|||||||
[Get started](setup.md){ .md-button .md-button--primary .index-callout }
|
[Get started](setup.md){ .md-button .md-button--primary .index-callout }
|
||||||
[Demo](https://demo.paperless-ngx.com){ .md-button .md-button--secondary target=\_blank }
|
[Demo](https://demo.paperless-ngx.com){ .md-button .md-button--secondary target=\_blank }
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: end; margin-top: -1.5rem;">
|
||||||
|
<a href="https://m.do.co/c/8d70b916d462" target="_blank">
|
||||||
|
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_white.svg#only-dark" class="no-lightbox" width="150px">
|
||||||
|
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_black.svg#only-light" class="no-lightbox" width="150px">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid-right" markdown>
|
<div class="grid-right" markdown>
|
||||||
{.index-screenshot}
|
{.index-screenshot}
|
||||||
@ -18,6 +25,7 @@ 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.
|
||||||
- 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.
|
||||||
- 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.
|
||||||
@ -41,7 +49,7 @@ physical documents into a searchable online archive so you can keep, well, _less
|
|||||||
- Configure multiple accounts and rules for each account.
|
- Configure multiple accounts and rules for each account.
|
||||||
- After processing, paperless can perform actions on the messages such as marking as read, deleting and more.
|
- After processing, paperless can perform actions on the messages such as marking as read, deleting and more.
|
||||||
- A built-in robust **multi-user permissions** system that supports 'global' permissions as well as per document or object.
|
- A built-in robust **multi-user permissions** system that supports 'global' permissions as well as per document or object.
|
||||||
- A powerful templating system that gives you more control over the consumption pipeline.
|
- A powerful workflow system that gives you even more control.
|
||||||
- **Optimized** for multi core systems: Paperless-ngx consumes multiple documents in parallel.
|
- **Optimized** for multi core systems: Paperless-ngx consumes multiple documents in parallel.
|
||||||
- The integrated sanity checker makes sure that your document archive is in good health.
|
- The integrated sanity checker makes sure that your document archive is in good health.
|
||||||
|
|
||||||
@ -156,9 +164,9 @@ Tag, correspondent, document type and storage path editing.
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid-half-right" markdown>
|
<div class="grid-half-right" markdown>
|
||||||
Consumption templates provide finer control over the document pipeline.
|
Workflows provide finer control over the document pipeline and trigger actions.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="clear"></div>
|
<div class="clear"></div>
|
||||||
|
@ -1,2 +0,0 @@
|
|||||||
-i https://pypi.python.org/simple
|
|
||||||
mkdocs-glightbox==0.3.4; python_version >= '3.8'
|
|
159
docs/setup.md
@ -6,6 +6,7 @@ You can go multiple routes to setup and run Paperless:
|
|||||||
- [Pull the image from Docker Hub](#docker_hub)
|
- [Pull the image from Docker Hub](#docker_hub)
|
||||||
- [Build the Docker image yourself](#docker_build)
|
- [Build the Docker image yourself](#docker_build)
|
||||||
- [Install Paperless directly on your system manually (bare metal)](#bare_metal)
|
- [Install Paperless directly on your system manually (bare metal)](#bare_metal)
|
||||||
|
- A user-maintained list of commercial hosting providers can be found [in the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects)
|
||||||
|
|
||||||
The Docker routes are quick & easy. These are the recommended routes.
|
The Docker routes are quick & easy. These are the recommended routes.
|
||||||
This configures all the stuff from the above automatically so that it
|
This configures all the stuff from the above automatically so that it
|
||||||
@ -28,6 +29,7 @@ steps described in [Docker setup](#docker_hub) automatically.
|
|||||||
1. Make sure that Docker and Docker Compose are installed.
|
1. Make sure that Docker and Docker Compose are installed.
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
|
|
||||||
See the Docker installation instructions at https://docs.docker.com/engine/install/
|
See the Docker installation instructions at https://docs.docker.com/engine/install/
|
||||||
|
|
||||||
2. Download and run the installation script:
|
2. Download and run the installation script:
|
||||||
@ -72,7 +74,7 @@ steps described in [Docker setup](#docker_hub) automatically.
|
|||||||
|
|
||||||
If you want to use the included `docker-compose.*.yml` file, you
|
If you want to use the included `docker-compose.*.yml` file, you
|
||||||
need to have at least Docker version **17.09.0** and Docker Compose
|
need to have at least Docker version **17.09.0** and Docker Compose
|
||||||
version **v2**. To check do: `docker compose -v` or `docker -v`
|
version **v2**. To check do: `docker compose version` or `docker -v`
|
||||||
|
|
||||||
See the [Docker installation guide](https://docs.docker.com/engine/install/) on how to install the current
|
See the [Docker installation guide](https://docs.docker.com/engine/install/) on how to install the current
|
||||||
version of Docker for your operating system or Linux distribution of
|
version of Docker for your operating system or Linux distribution of
|
||||||
@ -95,7 +97,7 @@ steps described in [Docker setup](#docker_hub) automatically.
|
|||||||
- /home/jonaswinkler/paperless-inbox:/usr/src/paperless/consume
|
- /home/jonaswinkler/paperless-inbox:/usr/src/paperless/consume
|
||||||
```
|
```
|
||||||
|
|
||||||
Don't change the part after the colon or paperless wont find your
|
Don't change the part after the colon or paperless won't find your
|
||||||
documents.
|
documents.
|
||||||
|
|
||||||
You may also need to change the default port that the webserver will
|
You may also need to change the default port that the webserver will
|
||||||
@ -120,6 +122,10 @@ steps described in [Docker setup](#docker_hub) automatically.
|
|||||||
|
|
||||||
**Rootless**
|
**Rootless**
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
It is currently not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
|
||||||
|
|
||||||
If you want to run Paperless as a rootless container, you will need
|
If you want to run Paperless as a rootless container, you will need
|
||||||
to do the following in your `docker-compose.yml`:
|
to do the following in your `docker-compose.yml`:
|
||||||
|
|
||||||
@ -244,9 +250,14 @@ a minimal installation of Debian/Buster, which is the current stable
|
|||||||
release at the time of writing. Windows is not and will never be
|
release at the time of writing. Windows is not and will never be
|
||||||
supported.
|
supported.
|
||||||
|
|
||||||
|
Paperless requires Python 3. At this time, 3.9 - 3.11 are tested versions.
|
||||||
|
Newer versions may work, but some dependencies may not fully support newer versions.
|
||||||
|
Support for older Python versions may be dropped as they reach end of life or as newer versions
|
||||||
|
are released, dependency support is confirmed, etc.
|
||||||
|
|
||||||
1. Install dependencies. Paperless requires the following packages.
|
1. Install dependencies. Paperless requires the following packages.
|
||||||
|
|
||||||
- `python3` - 3.9 - 3.11 are supported
|
- `python3`
|
||||||
- `python3-pip`
|
- `python3-pip`
|
||||||
- `python3-dev`
|
- `python3-dev`
|
||||||
- `default-libmysqlclient-dev` for MariaDB
|
- `default-libmysqlclient-dev` for MariaDB
|
||||||
@ -294,8 +305,17 @@ supported.
|
|||||||
- `libatlas-base-dev`
|
- `libatlas-base-dev`
|
||||||
- `libxslt1-dev`
|
- `libxslt1-dev`
|
||||||
|
|
||||||
You will also need `build-essential`, `python3-setuptools` and
|
You will also need these for installing some of the python dependencies:
|
||||||
`python3-wheel` for installing some of the python dependencies.
|
|
||||||
|
- `build-essential`
|
||||||
|
- `python3-setuptools`
|
||||||
|
- `python3-wheel`
|
||||||
|
|
||||||
|
Use this list for your preferred package management:
|
||||||
|
|
||||||
|
```
|
||||||
|
build-essential python3-setuptools python3-wheel
|
||||||
|
```
|
||||||
|
|
||||||
2. Install `redis` >= 6.0 and configure it to start automatically.
|
2. Install `redis` >= 6.0 and configure it to start automatically.
|
||||||
|
|
||||||
@ -395,8 +415,7 @@ supported.
|
|||||||
sudo chown paperless:paperless /opt/paperless/consume
|
sudo chown paperless:paperless /opt/paperless/consume
|
||||||
```
|
```
|
||||||
|
|
||||||
8. Install python requirements from the `requirements.txt` file. It is
|
8. Install python requirements from the `requirements.txt` file.
|
||||||
up to you if you wish to use a virtual environment or not. First you should update your pip, so it gets the actual packages.
|
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
sudo -Hu paperless pip3 install -r requirements.txt
|
sudo -Hu paperless pip3 install -r requirements.txt
|
||||||
@ -405,6 +424,12 @@ supported.
|
|||||||
This will install all python dependencies in the home directory of
|
This will install all python dependencies in the home directory of
|
||||||
the new paperless user.
|
the new paperless user.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
|
||||||
|
It is up to you if you wish to use a virtual environment or not for the Python
|
||||||
|
dependencies. This is an alternative to the above and may require adjusting
|
||||||
|
the example scripts to utilize the virtual environment paths
|
||||||
|
|
||||||
9. Go to `/opt/paperless/src`, and execute the following commands:
|
9. Go to `/opt/paperless/src`, and execute the following commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -661,24 +686,37 @@ commands as well.
|
|||||||
1. Stop and remove the paperless container
|
1. Stop and remove the paperless container
|
||||||
2. If using an external database, stop the container
|
2. If using an external database, stop the container
|
||||||
3. Update Redis configuration
|
3. Update Redis configuration
|
||||||
a) If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
|
|
||||||
|
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
|
||||||
and continue to step 4.
|
and continue to step 4.
|
||||||
b) Otherwise, in the `docker-compose.yml` add a new service for
|
|
||||||
|
1. Otherwise, in the `docker-compose.yml` add a new service for
|
||||||
Redis, following [the example compose
|
Redis, following [the example compose
|
||||||
files](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
|
files](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
|
||||||
c) Set the environment variable [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) so it points to
|
|
||||||
|
1. Set the environment variable [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) so it points to
|
||||||
the new Redis container
|
the new Redis container
|
||||||
|
|
||||||
4. Update user mapping
|
4. Update user mapping
|
||||||
a) If set, change the environment variable `PUID` to `USERMAP_UID`
|
|
||||||
b) If set, change the environment variable `PGID` to `USERMAP_GID`
|
1. If set, change the environment variable `PUID` to `USERMAP_UID`
|
||||||
|
|
||||||
|
1. If set, change the environment variable `PGID` to `USERMAP_GID`
|
||||||
|
|
||||||
5. Update configuration paths
|
5. Update configuration paths
|
||||||
a) Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`
|
|
||||||
|
1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`
|
||||||
|
|
||||||
6. Update media paths
|
6. Update media paths
|
||||||
a) Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
|
|
||||||
|
1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
|
||||||
`/data/media`
|
`/data/media`
|
||||||
|
|
||||||
7. Update timezone
|
7. Update timezone
|
||||||
a) Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
|
|
||||||
|
1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
|
||||||
value as `TZ`
|
value as `TZ`
|
||||||
|
|
||||||
8. Modify the `image:` to point to
|
8. Modify the `image:` to point to
|
||||||
`ghcr.io/paperless-ngx/paperless-ngx:latest` or a specific version
|
`ghcr.io/paperless-ngx/paperless-ngx:latest` or a specific version
|
||||||
if preferred.
|
if preferred.
|
||||||
@ -686,95 +724,8 @@ commands as well.
|
|||||||
|
|
||||||
## Moving data from SQLite to PostgreSQL or MySQL/MariaDB {#sqlite_to_psql}
|
## Moving data from SQLite to PostgreSQL or MySQL/MariaDB {#sqlite_to_psql}
|
||||||
|
|
||||||
Moving your data from SQLite to PostgreSQL or MySQL/MariaDB is done via
|
The best way to migrate between database types is to perform an [export](administration.md#exporter) and then
|
||||||
executing a series of django management commands as below. The commands
|
[import](administration.md#importer) into a clean installation of Paperless-ngx.
|
||||||
below use PostgreSQL, but are applicable to MySQL/MariaDB with the
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
Make sure that your SQLite database is migrated to the latest version.
|
|
||||||
Starting paperless will make sure that this is the case. If your try to
|
|
||||||
load data from an old database schema in SQLite into a newer database
|
|
||||||
schema in PostgreSQL, you will run into trouble.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
On some database fields, PostgreSQL enforces predefined limits on
|
|
||||||
maximum length, whereas SQLite does not. The fields in question are the
|
|
||||||
title of documents (128 characters), names of document types, tags and
|
|
||||||
correspondents (128 characters), and filenames (1024 characters). If you
|
|
||||||
have data in these fields that surpasses these limits, migration to
|
|
||||||
PostgreSQL is not possible and will fail with an error.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
MySQL is case insensitive by default, treating values like "Name" and
|
|
||||||
"NAME" as identical. See [MySQL caveats](advanced_usage.md#mysql-caveats) for details.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
MySQL also enforces limits on maximum lengths, but does so differently than
|
|
||||||
PostgreSQL. It may not be possible to migrate to MySQL due to this.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
Using mariadb version 10.4+ is recommended. Using the `utf8mb3` character set on
|
|
||||||
an older system may fix issues that can arise while setting up Paperless-ngx but
|
|
||||||
`utf8mb3` can cause issues with consumption (where `utf8mb4` does not).
|
|
||||||
|
|
||||||
1. Stop paperless, if it is running.
|
|
||||||
|
|
||||||
2. Tell paperless to use PostgreSQL:
|
|
||||||
|
|
||||||
a) With docker, copy the provided `docker-compose.postgres.yml`
|
|
||||||
file to `docker-compose.yml`. Remember to adjust the consumption
|
|
||||||
directory, if necessary.
|
|
||||||
b) Without docker, configure the database in your `paperless.conf`
|
|
||||||
file. See [configuration](configuration.md) for
|
|
||||||
details.
|
|
||||||
|
|
||||||
3. Open a shell and initialize the database:
|
|
||||||
|
|
||||||
a) With docker, run the following command to open a shell within
|
|
||||||
the paperless container:
|
|
||||||
|
|
||||||
``` shell-session
|
|
||||||
$ cd /path/to/paperless
|
|
||||||
$ docker compose run --rm webserver /bin/bash
|
|
||||||
```
|
|
||||||
|
|
||||||
This will launch the container and initialize the PostgreSQL
|
|
||||||
database.
|
|
||||||
|
|
||||||
b) Without docker, remember to activate any virtual environment,
|
|
||||||
switch to the `src` directory and create the database schema:
|
|
||||||
|
|
||||||
``` shell-session
|
|
||||||
$ cd /path/to/paperless/src
|
|
||||||
$ python3 manage.py migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
This will not copy any data yet.
|
|
||||||
|
|
||||||
4. Dump your data from SQLite:
|
|
||||||
|
|
||||||
```shell-session
|
|
||||||
$ python3 manage.py dumpdata --database=sqlite --exclude=contenttypes --exclude=auth.Permission > data.json
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Load your data into PostgreSQL:
|
|
||||||
|
|
||||||
```shell-session
|
|
||||||
$ python3 manage.py loaddata data.json
|
|
||||||
```
|
|
||||||
|
|
||||||
6. If operating inside Docker, you may exit the shell now.
|
|
||||||
|
|
||||||
```shell-session
|
|
||||||
$ exit
|
|
||||||
```
|
|
||||||
|
|
||||||
7. Start paperless.
|
|
||||||
|
|
||||||
## Moving back to Paperless
|
## Moving back to Paperless
|
||||||
|
|
||||||
|
@ -138,7 +138,7 @@ command:
|
|||||||
You might encounter errors such as:
|
You might encounter errors such as:
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
The following error occured while consuming document.pdf: [Errno 13] Permission denied: '/usr/src/paperless/src/../consume/document.pdf'
|
The following error occurred while consuming document.pdf: [Errno 13] Permission denied: '/usr/src/paperless/src/../consume/document.pdf'
|
||||||
```
|
```
|
||||||
|
|
||||||
This happens when paperless does not have permission to delete files
|
This happens when paperless does not have permission to delete files
|
||||||
|
262
docs/usage.md
@ -109,7 +109,7 @@ process.
|
|||||||
|
|
||||||
### Mobile upload {#usage-mobile_upload}
|
### Mobile upload {#usage-mobile_upload}
|
||||||
|
|
||||||
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Affiliated-Projects) for a user-maintained list of affiliated projects and
|
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects) for a user-maintained list of related projects and
|
||||||
software (e.g. for mobile devices) that is compatible with Paperless-ngx.
|
software (e.g. for mobile devices) that is compatible with Paperless-ngx.
|
||||||
|
|
||||||
### IMAP (Email) {#usage-email}
|
### IMAP (Email) {#usage-email}
|
||||||
@ -149,7 +149,7 @@ different means. These are as follows:
|
|||||||
- **Flag:** Sets the 'important' flag on mails with consumed
|
- **Flag:** Sets the 'important' flag on mails with consumed
|
||||||
documents. Paperless will not consume flagged mails.
|
documents. Paperless will not consume flagged mails.
|
||||||
- **Move to folder:** Moves consumed mails out of the way so that
|
- **Move to folder:** Moves consumed mails out of the way so that
|
||||||
paperless wont consume them again.
|
paperless won't consume them again.
|
||||||
- **Add custom Tag:** Adds a custom tag to mails with consumed
|
- **Add custom Tag:** Adds a custom tag to mails with consumed
|
||||||
documents (the IMAP standard calls these "keywords"). Paperless
|
documents (the IMAP standard calls these "keywords"). Paperless
|
||||||
will not consume mails already tagged. Not all mail servers support
|
will not consume mails already tagged. Not all mail servers support
|
||||||
@ -206,12 +206,12 @@ for details.
|
|||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
As of version 1.14.0 Paperless-ngx added core support for user / group permissions. Permissions is
|
Permissions in Paperless-ngx are based around ['global' permissions](#global-permissions) as well as
|
||||||
based around 'global' permissions as well as 'object-level' permissions. Global permissions designate
|
['object-level' permissions](#object-permissions). Global permissions determine which parts of the
|
||||||
which parts of the application a user can access (e.g. Documents, Tags, Settings) and object-level
|
application a user can access (e.g. Documents, Tags, Settings) and object-level determine which
|
||||||
determine which objects are visible or editable. All objects have an 'owner' and 'view' and 'edit'
|
objects are visible or editable. All objects have an 'owner' and 'view' and 'edit' permissions which
|
||||||
permissions which can be granted to other users or groups. The paperless-ngx permissions system uses
|
can be granted to other users or groups. The paperless-ngx permissions system uses the built-in user
|
||||||
the built-in user model of the backend framework, Django.
|
model of the backend framework, Django.
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
|
|
||||||
@ -219,86 +219,166 @@ the built-in user model of the backend framework, Django.
|
|||||||
for a Tag will _not_ affect the permissions of documents that have the Tag.
|
for a Tag will _not_ affect the permissions of documents that have the Tag.
|
||||||
|
|
||||||
Permissions can be set using the new "Permissions" tab when editing documents, or bulk-applied
|
Permissions can be set using the new "Permissions" tab when editing documents, or bulk-applied
|
||||||
in the UI by selecting documents and choosing the "Permissions" button. Owner can also optionally
|
in the UI by selecting documents and choosing the "Permissions" button.
|
||||||
be set for documents uploaded via the API. Documents consumed via the consumption dir currently
|
|
||||||
do not have an owner set.
|
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
After migration to version 1.14.0 all existing documents, tags etc. will have no explicit owner
|
|
||||||
set which means they will be visible / editable by all users. Once an object has an owner set,
|
|
||||||
only the owner can explicitly grant / revoke permissions.
|
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
When first migrating to permissions it is recommended to use a 'superuser' account (which
|
|
||||||
would usually have been setup during installation) to ensure you have full permissions.
|
|
||||||
|
|
||||||
Note that superusers have access to all objects.
|
|
||||||
|
|
||||||
### Default permissions
|
### Default permissions
|
||||||
|
|
||||||
Default permissions for documents can be set using consumption templates.
|
[Workflows](#workflows) provide advanced ways to control permissions.
|
||||||
|
|
||||||
For objects created via the web UI (tags, doc types, etc.) the default is to set the current user
|
For objects created via the web UI (tags, doc types, etc.) the default is to set the current user
|
||||||
as owner and no extra permissions, but you explicitly set these under Settings > Permissions.
|
as owner and no extra permissions, but you can explicitly set these under Settings > Permissions.
|
||||||
|
|
||||||
|
Documents consumed via the consumption directory do not have an owner or additional permissions set by default, but again, can be controlled with [Workflows](#workflows).
|
||||||
|
|
||||||
### Users and Groups
|
### Users and Groups
|
||||||
|
|
||||||
Paperless-ngx versions after 1.14.0 allow creating and editing users and groups via the 'frontend' UI.
|
Paperless-ngx supports editing users and groups via the 'frontend' UI, which can be found under
|
||||||
These can be found under Settings > Users & Groups, assuming the user has access. If a user is designated
|
Settings > Users & Groups, assuming the user has access. If a user is designated
|
||||||
as a member of a group those permissions will be inherited and this is reflected in the UI. Explicit
|
as a member of a group those permissions will be inherited and this is reflected in the UI. Explicit
|
||||||
permissions can be granted to limit access to certain parts of the UI (and corresponding API endpoints).
|
permissions can be granted to limit access to certain parts of the UI (and corresponding API endpoints).
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
Superusers can access all parts of the front and backend application as well as any and all objects.
|
||||||
|
|
||||||
|
#### Admin Status
|
||||||
|
|
||||||
|
Admin status (Django 'staff status') grants access to viewing the paperless logs and the system status dialog
|
||||||
|
as well as accessing the Django backend.
|
||||||
|
|
||||||
|
#### Detailed Explanation of Global Permissions {#global-permissions}
|
||||||
|
|
||||||
|
Global permissions define what areas of the app and API endpoints the user can access. For example, they
|
||||||
|
determine if a user can create, edit, delete or view _any_ documents, but individual documents themselves
|
||||||
|
still have "object-level" permissions.
|
||||||
|
|
||||||
|
| Type | Details |
|
||||||
|
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
|
||||||
|
| Correspondent | Grants global permissions to add, edit, delete or view Correspondents. |
|
||||||
|
| CustomField | Grants global permissions to add, edit, delete or view Custom Fields. |
|
||||||
|
| Document | Grants global permissions to add, edit, delete or view Documents. |
|
||||||
|
| DocumentType | Grants global permissions to add, edit, delete or view Document Types. |
|
||||||
|
| Group | Grants global permissions to add, edit, delete or view Groups. |
|
||||||
|
| MailAccount | Grants global permissions to add, edit, delete or view Mail Accounts. |
|
||||||
|
| MailRule | Grants global permissions to add, edit, delete or view Mail Rules. |
|
||||||
|
| Note | Grants global permissions to add, edit, delete or view Notes. |
|
||||||
|
| PaperlessTask | Grants global permissions to view or dismiss (_Change_) File Tasks. |
|
||||||
|
| SavedView | Grants global permissions to add, edit, delete or view Saved Views. |
|
||||||
|
| ShareLink | Grants global permissions to add, delete or view Share Links. |
|
||||||
|
| StoragePath | Grants global permissions to add, edit, delete or view Storage Paths. |
|
||||||
|
| Tag | Grants global permissions to add, edit, delete or view Tags. |
|
||||||
|
| UISettings | Grants global permissions to add, edit, delete or view the UI settings that are used by the web app.<br/>Users expected to access the web UI should usually be granted at least _View_ permissions. |
|
||||||
|
| User | Grants global permissions to add, edit, delete or view Users. |
|
||||||
|
| Workflow | Grants global permissions to add, edit, delete or view Workflows.<br/>Note that Workflows are global, in other words all users who can access workflows have access to the same set of them. |
|
||||||
|
|
||||||
|
#### Detailed Explanation of Object Permissions {#object-permissions}
|
||||||
|
|
||||||
|
| Type | Details |
|
||||||
|
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Owner | By default objects are only visible and editable by their owner.<br/>Only the object owner can grant permissions to other users or groups.<br/>Additionally, only document owners can create share links and add / remove custom fields.<br/>For backwards compatibility objects can have no owner which makes them visible to any user. |
|
||||||
|
| View | Confers the ability to view (not edit) a document, tag, etc.<br/>Users without 'view' (or higher) permissions will be shown _'Private'_ in place of the object name for example when viewing a document with a tag for which the user doesn't have permissions. |
|
||||||
|
| Edit | Confers the ability to edit (and view) a document, tag, etc. |
|
||||||
|
|
||||||
### Password reset
|
### Password reset
|
||||||
|
|
||||||
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
||||||
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST)
|
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
|
||||||
|
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
|
||||||
|
|
||||||
## Consumption templates
|
## Workflows
|
||||||
|
|
||||||
Consumption templates were introduced in v2.0 and allow for finer control over what metadata (tags, doc
|
!!! note
|
||||||
types) and permissions (owner, privileges) are assigned to documents during consumption. In general,
|
|
||||||
templates are applied sequentially (by sort order) but subsequent templates will never override an
|
|
||||||
assignment from a preceding template. The same is true for mail rules, e.g. if you set the correspondent
|
|
||||||
in a mail rule any subsequent consumption templates that are applied _will not_ overwrite this. The
|
|
||||||
exception to this is assignments that can be multiple e.g. tags and permissions, which will be merged.
|
|
||||||
|
|
||||||
Consumption templates allow you to filter by:
|
v2.3 added "Workflows" and existing "Consumption Templates" were converted automatically to the new more powerful format.
|
||||||
|
|
||||||
|
Workflows allow hooking into the Paperless-ngx document pipeline, for example to alter what metadata (tags, doc types) and
|
||||||
|
permissions (owner, privileges) are assigned to documents. Workflows can have multiple 'triggers' and 'actions'. Triggers
|
||||||
|
are events (with optional filtering rules) that will cause the workflow to be run and actions are the set of sequential
|
||||||
|
actions to apply.
|
||||||
|
|
||||||
|
In general, workflows and any actions they contain are applied sequentially by sort order. For "assignment" actions, subsequent
|
||||||
|
workflow actions will override previous assignments, except for assignments that accept multiple items e.g. tags, custom
|
||||||
|
fields and permissions, which will be merged.
|
||||||
|
|
||||||
|
### Workflow Triggers
|
||||||
|
|
||||||
|
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
|
||||||
|
folder or API), file path, file name, mail rule
|
||||||
|
2. **Document Added**: _after_ a document is added. At this time, file path and source information is no longer available,
|
||||||
|
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.
|
||||||
|
3. **Document Updated**: when a document is updated. Similar to 'added' events, triggers can include filtering by content matching,
|
||||||
|
tags, doc type, or correspondent.
|
||||||
|
|
||||||
|
The following flow diagram illustrates the three trigger types:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
consumption{"Matching
|
||||||
|
'Consumption'
|
||||||
|
trigger(s)"}
|
||||||
|
|
||||||
|
added{"Matching
|
||||||
|
'Added'
|
||||||
|
trigger(s)"}
|
||||||
|
|
||||||
|
updated{"Matching
|
||||||
|
'Updated'
|
||||||
|
trigger(s)"}
|
||||||
|
|
||||||
|
A[New Document] --> consumption
|
||||||
|
consumption --> |Yes| C[Workflow Actions Run]
|
||||||
|
consumption --> |No| D
|
||||||
|
C --> D[Document Added]
|
||||||
|
D -- Paperless-ngx 'matching' of tags, etc. --> added
|
||||||
|
added --> |Yes| F[Workflow Actions Run]
|
||||||
|
added --> |No| G
|
||||||
|
F --> G[Document Finalized]
|
||||||
|
H[Existing Document Changed] --> updated
|
||||||
|
updated --> |Yes| J[Workflow Actions Run]
|
||||||
|
updated --> |No| K
|
||||||
|
J --> K[Document Saved]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Filters {#workflow-trigger-filters}
|
||||||
|
|
||||||
|
Workflows allow you to filter by:
|
||||||
|
|
||||||
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
|
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
|
||||||
- File name, including wildcards e.g. \*.pdf will apply to all pdfs
|
- File name, including wildcards e.g. \*.pdf will apply to all pdfs
|
||||||
- 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 template source.
|
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
||||||
|
- Content matching (`Added` and `Updated` triggers only). Filter document content using the matching settings.
|
||||||
|
- Tags (`Added` and `Updated` triggers only). Filter for documents with any of the specified tags
|
||||||
|
- Document type (`Added` and `Updated` triggers only). Filter documents with this doc type
|
||||||
|
- Correspondent (`Added` and `Updated` triggers only). Filter documents with this correspondent
|
||||||
|
|
||||||
!!! note
|
### Workflow Actions
|
||||||
|
|
||||||
You must include a file name filter, a path filter or a mail rule filter. Use * for either to apply
|
There are currently two types of workflow actions, "Assignment", which can assign:
|
||||||
to all files.
|
|
||||||
|
|
||||||
Consumption templates can assign:
|
- Title, see [title placeholders](usage.md#title-placeholders) below
|
||||||
|
- Tags, correspondent, document type and storage path
|
||||||
- Title, see [title placeholders](usage.md#title_placeholders) below
|
|
||||||
- Tags, correspondent, document types
|
|
||||||
- Document owner
|
- Document owner
|
||||||
- View and / or edit permissions to users or groups
|
- View and / or edit permissions to users or groups
|
||||||
|
- Custom fields. Note that no value for the field will be set
|
||||||
|
|
||||||
### Consumption template permissions
|
and "Removal" actions, which can remove either all of or specific sets of the following:
|
||||||
|
|
||||||
All users who have application permissions for editing consumption templates can see the same set
|
- Tags, correspondents, document types or storage paths
|
||||||
of templates. In other words, templates themselves intentionally do not have an owner or permissions.
|
- Document owner
|
||||||
|
- View and / or edit permissions
|
||||||
|
- Custom fields
|
||||||
|
|
||||||
Given their potentially far-reaching capabilities, you may want to restrict access to templates.
|
#### Title placeholders
|
||||||
|
|
||||||
Upon migration, existing installs will grant access to consumption templates to users who can add
|
Workflow titles can include placeholders but the available options differ depending on the type of
|
||||||
documents (and superusers who can always access all parts of the app).
|
workflow trigger. This is because at the time of consumption (when the title is to be set), no automatic tags etc. have been
|
||||||
|
applied. You can use the following placeholders with any trigger type:
|
||||||
### Title placeholders
|
|
||||||
|
|
||||||
Consumption template titles can include placeholders, _only for items that are assigned within the template_.
|
|
||||||
This is because at the time of consumption (when the title is to be set), no automatic tags etc. have been
|
|
||||||
applied. You can use the following placeholders:
|
|
||||||
|
|
||||||
- `{correspondent}`: assigned correspondent name
|
- `{correspondent}`: assigned correspondent name
|
||||||
- `{document_type}`: assigned document type name
|
- `{document_type}`: assigned document type name
|
||||||
@ -310,6 +390,29 @@ applied. You can use the following placeholders:
|
|||||||
- `{added_month_name}`: added month name
|
- `{added_month_name}`: added month name
|
||||||
- `{added_month_name_short}`: added month short name
|
- `{added_month_name_short}`: added month short name
|
||||||
- `{added_day}`: added day
|
- `{added_day}`: added day
|
||||||
|
- `{added_time}`: added time in HH:MM format
|
||||||
|
- `{original_filename}`: original file name without extension
|
||||||
|
|
||||||
|
The following placeholders are only available for "added" or "updated" triggers
|
||||||
|
|
||||||
|
- `{created}`: created datetime
|
||||||
|
- `{created_year}`: created year
|
||||||
|
- `{created_year_short}`: created year
|
||||||
|
- `{created_month}`: created month
|
||||||
|
- `{created_month_name}`: created month name
|
||||||
|
- `{created_month_name_short}`: created month short name
|
||||||
|
- `{created_day}`: created day
|
||||||
|
- `{created_time}`: created time in HH:MM format
|
||||||
|
|
||||||
|
### Workflow permissions
|
||||||
|
|
||||||
|
All users who have application permissions for editing workflows can see the same set
|
||||||
|
of workflows. In other words, workflows themselves intentionally do not have an owner or permissions.
|
||||||
|
|
||||||
|
Given their potentially far-reaching capabilities, you may want to restrict access to workflows.
|
||||||
|
|
||||||
|
Upon migration, existing installs will grant access to workflows to users who can add
|
||||||
|
documents (and superusers who can always access all parts of the app).
|
||||||
|
|
||||||
## Custom Fields {#custom-fields}
|
## Custom Fields {#custom-fields}
|
||||||
|
|
||||||
@ -318,13 +421,12 @@ to optionally attach data to documents which does not fit in the existing set of
|
|||||||
Paperless-ngx provides.
|
Paperless-ngx provides.
|
||||||
|
|
||||||
1. First, create a custom field (under "Manage"), with a given name and data type. This could be something like "Invoice Number" or "Date Paid", with a data type of "Number", "Date", "String", etc.
|
1. First, create a custom field (under "Manage"), with a given name and data type. This could be something like "Invoice Number" or "Date Paid", with a data type of "Number", "Date", "String", etc.
|
||||||
2. Once created, a field can be used with documents and data stored. To do so, use the "Custom Fields" menu on the document detail page, choose your existing field and click "Add". Once the field is visible in the form you can enter the appropriate
|
2. Once created, a field can be used with documents and data stored. To do so, use the "Custom Fields" menu on the document detail page, choose your existing field from the dropdown. Once the field is visible in the form you can enter the appropriate data which will be validated according to the custom field "data type".
|
||||||
data which will be validated according to the custom field "data type".
|
|
||||||
3. Fields can be removed by hovering over the field name revealing a "Remove" button.
|
3. Fields can be removed by hovering over the field name revealing a "Remove" button.
|
||||||
|
|
||||||
!!! important
|
!!! important
|
||||||
|
|
||||||
Added / removed fields, as well as any data is not saved to the document until you
|
Added / removed fields, as well as any data, is not saved to the document until you
|
||||||
actually hit the "Save" button, similar to other changes on the document details page.
|
actually hit the "Save" button, similar to other changes on the document details page.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@ -341,11 +443,12 @@ The following custom field types are supported:
|
|||||||
- `URL`: a valid url
|
- `URL`: a valid url
|
||||||
- `Integer`: integer number e.g. 12
|
- `Integer`: integer number e.g. 12
|
||||||
- `Number`: float number e.g. 12.3456
|
- `Number`: float number e.g. 12.3456
|
||||||
- `Monetary`: float number with exactly two decimals, e.g. 12.30
|
- `Monetary`: [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes) and a number with exactly two decimals, e.g. USD12.30
|
||||||
|
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
||||||
|
|
||||||
## Share Links
|
## Share Links
|
||||||
|
|
||||||
Paperless-ngx added the abiltiy to create shareable links to files in version 2.0. You can find the button for this on the document detail screen.
|
Paperless-ngx added the ability to create shareable links to files in version 2.0. You can find the button for this on the document detail screen.
|
||||||
|
|
||||||
- Share links do not require a user to login and thus link directly to a file.
|
- Share links do not require a user to login and thus link directly to a file.
|
||||||
- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
|
- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
|
||||||
@ -356,6 +459,25 @@ Paperless-ngx added the abiltiy to create shareable links to files in version 2.
|
|||||||
|
|
||||||
If your paperless-ngx instance is behind a reverse-proxy you may want to create an exception to bypass any authentication layers that are part of your setup in order to make links truly publicly-accessible. Of course, do so with caution.
|
If your paperless-ngx instance is behind a reverse-proxy you may want to create an exception to bypass any authentication layers that are part of your setup in order to make links truly publicly-accessible. Of course, do so with caution.
|
||||||
|
|
||||||
|
## PDF Actions
|
||||||
|
|
||||||
|
Paperless-ngx supports four basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files):
|
||||||
|
|
||||||
|
- Merging documents: available when selecting multiple documents for 'bulk editing'.
|
||||||
|
- Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page.
|
||||||
|
- Splitting documents: available from an individual document's details page.
|
||||||
|
- Deleting pages: available from an individual document's details page.
|
||||||
|
|
||||||
|
!!! important
|
||||||
|
|
||||||
|
Note that rotation and deleting pages alter the Paperless-ngx _original_ file, which would, for example, invalidate a digital signature.
|
||||||
|
|
||||||
|
## Document History
|
||||||
|
|
||||||
|
As of version 2.7, Paperless-ngx automatically records all changes to a document and records this in an audit log. The feature requires [`PAPERLESS_AUDIT_LOG_ENABLED`](configuration.md#PAPERLESS_AUDIT_LOG_ENABLED) be enabled, which it is by default as of version 2.7.
|
||||||
|
Changes to documents are visible under the "History" tab. Note that certain changes such as those made by workflows, record the 'actor'
|
||||||
|
as "System".
|
||||||
|
|
||||||
## Best practices {#basic-searching}
|
## Best practices {#basic-searching}
|
||||||
|
|
||||||
Paperless offers a couple tools that help you organize your document
|
Paperless offers a couple tools that help you organize your document
|
||||||
@ -428,6 +550,16 @@ collection.
|
|||||||
|
|
||||||
## Searching {#basic-usage_searching}
|
## Searching {#basic-usage_searching}
|
||||||
|
|
||||||
|
### Global search
|
||||||
|
|
||||||
|
The top search bar in the web UI performs a "global" search of the various
|
||||||
|
objects Paperless-ngx uses, including documents, tags, workflows, etc. Only
|
||||||
|
objects for which the user has appropriate permissions are returned. For
|
||||||
|
documents, if there are < 3 results, "advanced" search results (which use
|
||||||
|
the document index) will also be included. This can be disabled under settings.
|
||||||
|
|
||||||
|
### Document searches
|
||||||
|
|
||||||
Paperless offers an extensive searching mechanism that is designed to
|
Paperless offers an extensive searching mechanism that is designed to
|
||||||
allow you to quickly find a document you're looking for (for example,
|
allow you to quickly find a document you're looking for (for example,
|
||||||
that thing that just broke and you bought a couple months ago, that
|
that thing that just broke and you bought a couple months ago, that
|
||||||
@ -483,6 +615,12 @@ language](https://whoosh.readthedocs.io/en/latest/querylang.html). For
|
|||||||
details on what date parsing utilities are available, see [Date
|
details on what date parsing utilities are available, see [Date
|
||||||
parsing](https://whoosh.readthedocs.io/en/latest/dates.html#parsing-date-queries).
|
parsing](https://whoosh.readthedocs.io/en/latest/dates.html#parsing-date-queries).
|
||||||
|
|
||||||
|
## Keyboard shortcuts / hotkeys
|
||||||
|
|
||||||
|
A list of available hotkeys can be shown on any page using <kbd>Shift</kbd> +
|
||||||
|
<kbd>?</kbd>. The help dialog shows only the keys that are currently available
|
||||||
|
based on which area of Paperless-ngx you are using.
|
||||||
|
|
||||||
## The recommended workflow {#usage-recommended-workflow}
|
## The recommended workflow {#usage-recommended-workflow}
|
||||||
|
|
||||||
Once you have familiarized yourself with paperless and are ready to use
|
Once you have familiarized yourself with paperless and are ready to use
|
||||||
|
@ -37,11 +37,11 @@ def worker_int(worker):
|
|||||||
id2name = {th.ident: th.name for th in threading.enumerate()}
|
id2name = {th.ident: th.name for th in threading.enumerate()}
|
||||||
code = []
|
code = []
|
||||||
for threadId, stack in sys._current_frames().items():
|
for threadId, stack in sys._current_frames().items():
|
||||||
code.append("\n# Thread: %s(%d)" % (id2name.get(threadId, ""), threadId))
|
code.append(f"\n# Thread: {id2name.get(threadId, '')}({threadId})")
|
||||||
for filename, lineno, name, line in traceback.extract_stack(stack):
|
for filename, lineno, name, line in traceback.extract_stack(stack):
|
||||||
code.append('File: "%s", line %d, in %s' % (filename, lineno, name))
|
code.append(f'File: "{filename}", line {lineno}, in {name}')
|
||||||
if line:
|
if line:
|
||||||
code.append(" %s" % (line.strip()))
|
code.append(f" {line.strip()}")
|
||||||
worker.log.debug("\n".join(code))
|
worker.log.debug("\n".join(code))
|
||||||
|
|
||||||
|
|
||||||
|
@ -56,8 +56,8 @@ if ! command -v docker &> /dev/null ; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v docker compose &> /dev/null ; then
|
if ! docker compose &> /dev/null ; then
|
||||||
echo "docker compose executable not found. Is docker compose installed?"
|
echo "docker compose plugin not found. Is docker compose installed?"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -71,7 +71,17 @@ if ! docker stats --no-stream &> /dev/null ; then
|
|||||||
sleep 3
|
sleep 3
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Added handling for timezone for busybox based linux, not having timedatectl available (i.e. QNAP QTS)
|
||||||
|
# if neither timedatectl nor /etc/TZ is succeeding, defaulting to GMT.
|
||||||
|
if command -v timedatectl &> /dev/null ; then
|
||||||
default_time_zone=$(timedatectl show -p Timezone --value)
|
default_time_zone=$(timedatectl show -p Timezone --value)
|
||||||
|
elif [ -f /etc/TZ ] && [ -f /etc/tzlist ] ; then
|
||||||
|
TZ=$(cat /etc/TZ)
|
||||||
|
default_time_zone=$(grep -B 1 -m 1 "$TZ" /etc/tzlist | head -1 | cut -f 2 -d =)
|
||||||
|
else
|
||||||
|
echo "WARN: unable to detect timezone, defaulting to Etc/UTC"
|
||||||
|
default_time_zone="Etc/UTC"
|
||||||
|
fi
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@ -315,7 +325,7 @@ fi
|
|||||||
wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/docker-compose.$DOCKER_COMPOSE_VERSION.yml" -O docker-compose.yml
|
wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/docker-compose.$DOCKER_COMPOSE_VERSION.yml" -O docker-compose.yml
|
||||||
wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/.env" -O .env
|
wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/.env" -O .env
|
||||||
|
|
||||||
SECRET_KEY=$(LC_ALL=C tr -dc 'a-zA-Z0-9!"#$%&'\''()*+,-./:;<=>?@[\]^_`{|}~' < /dev/urandom | head --bytes 64)
|
SECRET_KEY=$(LC_ALL=C tr -dc 'a-zA-Z0-9!#$%&()*+,-./:;<=>?@[\]^_`{|}~' < /dev/urandom | dd bs=1 count=64 2>/dev/null)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_LANGUAGES=("deu eng fra ita spa")
|
DEFAULT_LANGUAGES=("deu eng fra ita spa")
|
||||||
@ -335,7 +345,7 @@ read -r -a OCR_LANGUAGES_ARRAY <<< "${_split_langs}"
|
|||||||
fi
|
fi
|
||||||
echo "PAPERLESS_TIME_ZONE=$TIME_ZONE"
|
echo "PAPERLESS_TIME_ZONE=$TIME_ZONE"
|
||||||
echo "PAPERLESS_OCR_LANGUAGE=$OCR_LANGUAGE"
|
echo "PAPERLESS_OCR_LANGUAGE=$OCR_LANGUAGE"
|
||||||
echo "PAPERLESS_SECRET_KEY=$SECRET_KEY"
|
echo "PAPERLESS_SECRET_KEY='$SECRET_KEY'"
|
||||||
if [[ ! ${DEFAULT_LANGUAGES[*]} =~ ${OCR_LANGUAGES_ARRAY[*]} ]] ; then
|
if [[ ! ${DEFAULT_LANGUAGES[*]} =~ ${OCR_LANGUAGES_ARRAY[*]} ]] ; then
|
||||||
echo "PAPERLESS_OCR_LANGUAGES=${OCR_LANGUAGES_ARRAY[*]}"
|
echo "PAPERLESS_OCR_LANGUAGES=${OCR_LANGUAGES_ARRAY[*]}"
|
||||||
fi
|
fi
|
||||||
@ -380,7 +390,7 @@ fi
|
|||||||
docker compose pull
|
docker compose pull
|
||||||
|
|
||||||
if [ "$DATABASE_BACKEND" == "postgres" ] || [ "$DATABASE_BACKEND" == "mariadb" ] ; then
|
if [ "$DATABASE_BACKEND" == "postgres" ] || [ "$DATABASE_BACKEND" == "mariadb" ] ; then
|
||||||
echo "Starting DB first for initilzation"
|
echo "Starting DB first for initialization"
|
||||||
docker compose up --detach db
|
docker compose up --detach db
|
||||||
# hopefully enough time for even the slower systems
|
# hopefully enough time for even the slower systems
|
||||||
sleep 15
|
sleep 15
|
||||||
|
@ -44,6 +44,11 @@ markdown_extensions:
|
|||||||
- pymdownx.inlinehilite
|
- pymdownx.inlinehilite
|
||||||
- pymdownx.snippets
|
- pymdownx.snippets
|
||||||
- footnotes
|
- footnotes
|
||||||
|
- pymdownx.superfences:
|
||||||
|
custom_fences:
|
||||||
|
- name: mermaid
|
||||||
|
class: mermaid
|
||||||
|
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||||
strict: true
|
strict: true
|
||||||
nav:
|
nav:
|
||||||
- index.md
|
- index.md
|
||||||
@ -68,4 +73,6 @@ extra:
|
|||||||
link: https://matrix.to/#/#paperless:matrix.org
|
link: https://matrix.to/#/#paperless:matrix.org
|
||||||
plugins:
|
plugins:
|
||||||
- search
|
- search
|
||||||
- glightbox
|
- glightbox:
|
||||||
|
skip_classes:
|
||||||
|
- no-lightbox
|
||||||
|
41
paperless-ngx.code-workspace
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./src",
|
||||||
|
"name": "Backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./src-ui",
|
||||||
|
"name": "Frontend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.github",
|
||||||
|
"name": "CI/CD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./docs",
|
||||||
|
"name": "Documentation"
|
||||||
|
}
|
||||||
|
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"files.exclude": {
|
||||||
|
"**/__pycache__": true,
|
||||||
|
"**/.mypy_cache": true,
|
||||||
|
"**/.ruff_cache": true,
|
||||||
|
"**/.pytest_cache": true,
|
||||||
|
"**/.idea": true,
|
||||||
|
"**/.venv": true,
|
||||||
|
"**/.coverage": true,
|
||||||
|
"**/coverage.json": true
|
||||||
|
},
|
||||||
|
"python.defaultInterpreterPath": ".venv/bin/python3",
|
||||||
|
},
|
||||||
|
"extensions": {
|
||||||
|
"recommendations": ["ms-python.python", "charliermarsh.ruff", "editorconfig.editorconfig"],
|
||||||
|
"unwantedRecommendations": ["ms-python.black-formatter"]
|
||||||
|
}
|
||||||
|
}
|
@ -68,6 +68,8 @@
|
|||||||
#PAPERLESS_CONSUMER_BARCODE_STRING=PATCHT
|
#PAPERLESS_CONSUMER_BARCODE_STRING=PATCHT
|
||||||
#PAPERLESS_CONSUMER_BARCODE_UPSCALE=0.0
|
#PAPERLESS_CONSUMER_BARCODE_UPSCALE=0.0
|
||||||
#PAPERLESS_CONSUMER_BARCODE_DPI=300
|
#PAPERLESS_CONSUMER_BARCODE_DPI=300
|
||||||
|
#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE=false
|
||||||
|
#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING={"TAG:(.*)": "\\g<1>"}
|
||||||
#PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=false
|
#PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=false
|
||||||
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME=double-sided
|
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME=double-sided
|
||||||
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT=false
|
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT=false
|
||||||
|
@ -3,4 +3,4 @@
|
|||||||
docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:15
|
docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:15
|
||||||
docker run -d -p 6379:6379 redis:latest
|
docker run -d -p 6379:6379 redis:latest
|
||||||
docker run -p 3000:3000 -d gotenberg/gotenberg:7.8 gotenberg --chromium-disable-javascript=true --chromium-allow-list="file:///tmp/.*"
|
docker run -p 3000:3000 -d gotenberg/gotenberg:7.8 gotenberg --chromium-disable-javascript=true --chromium-allow-list="file:///tmp/.*"
|
||||||
docker run -p 9998:9998 -d ghcr.io/paperless-ngx/tika:latest
|
docker run -p 9998:9998 -d docker.io/apache/tika:latest
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"root": true,
|
"root": true,
|
||||||
"ignorePatterns": [
|
"ignorePatterns": [
|
||||||
"projects/**/*"
|
"projects/**/*",
|
||||||
|
"/src/app/components/common/pdf-viewer/**"
|
||||||
],
|
],
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
|
@ -31,6 +31,7 @@
|
|||||||
"fr-FR": "src/locale/messages.fr_FR.xlf",
|
"fr-FR": "src/locale/messages.fr_FR.xlf",
|
||||||
"hu-HU": "src/locale/messages.hu_HU.xlf",
|
"hu-HU": "src/locale/messages.hu_HU.xlf",
|
||||||
"it-IT": "src/locale/messages.it_IT.xlf",
|
"it-IT": "src/locale/messages.it_IT.xlf",
|
||||||
|
"ja-JP": "src/locale/messages.ja_JP.xlf",
|
||||||
"lb-LU": "src/locale/messages.lb_LU.xlf",
|
"lb-LU": "src/locale/messages.lb_LU.xlf",
|
||||||
"nl-NL": "src/locale/messages.nl_NL.xlf",
|
"nl-NL": "src/locale/messages.nl_NL.xlf",
|
||||||
"no-NO": "src/locale/messages.no_NO.xlf",
|
"no-NO": "src/locale/messages.no_NO.xlf",
|
||||||
@ -65,7 +66,7 @@
|
|||||||
"src/assets",
|
"src/assets",
|
||||||
"src/manifest.webmanifest",
|
"src/manifest.webmanifest",
|
||||||
{
|
{
|
||||||
"glob": "pdf.worker.min.js",
|
"glob": "{pdf.worker.min.js,pdf.min.js}",
|
||||||
"input": "node_modules/pdfjs-dist/build/",
|
"input": "node_modules/pdfjs-dist/build/",
|
||||||
"output": "/assets/js/"
|
"output": "/assets/js/"
|
||||||
}
|
}
|
||||||
@ -75,7 +76,9 @@
|
|||||||
],
|
],
|
||||||
"scripts": [],
|
"scripts": [],
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
"ng2-pdf-viewer"
|
"ng2-pdf-viewer",
|
||||||
|
"filesize",
|
||||||
|
"file-saver"
|
||||||
],
|
],
|
||||||
"vendorChunk": true,
|
"vendorChunk": true,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
@ -109,7 +112,7 @@
|
|||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "6kb",
|
"maximumWarning": "6kb",
|
||||||
"maximumError": "10kb"
|
"maximumError": "30kb"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -124,18 +127,18 @@
|
|||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
"browserTarget": "paperless-ui:build:en-US"
|
"buildTarget": "paperless-ui:build:en-US"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"browserTarget": "paperless-ui:build:production"
|
"buildTarget": "paperless-ui:build:production"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||||
"options": {
|
"options": {
|
||||||
"browserTarget": "paperless-ui:build"
|
"buildTarget": "paperless-ui:build"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
|
@ -124,7 +124,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"size": -1,
|
"size": -1,
|
||||||
"mimeType": "application/json",
|
"mimeType": "application/json",
|
||||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}"
|
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}"
|
||||||
},
|
},
|
||||||
"headersSize": -1,
|
"headersSize": -1,
|
||||||
"bodySize": -1,
|
"bodySize": -1,
|
||||||
|
@ -124,7 +124,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"size": -1,
|
"size": -1,
|
||||||
"mimeType": "application/json",
|
"mimeType": "application/json",
|
||||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}"
|
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}"
|
||||||
},
|
},
|
||||||
"headersSize": -1,
|
"headersSize": -1,
|
||||||
"bodySize": -1,
|
"bodySize": -1,
|
||||||
|
@ -124,7 +124,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"size": -1,
|
"size": -1,
|
||||||
"mimeType": "application/json",
|
"mimeType": "application/json",
|
||||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}"
|
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}"
|
||||||
},
|
},
|
||||||
"headersSize": -1,
|
"headersSize": -1,
|
||||||
"bodySize": -1,
|
"bodySize": -1,
|
||||||
|
@ -124,7 +124,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"size": -1,
|
"size": -1,
|
||||||
"mimeType": "application/json",
|
"mimeType": "application/json",
|
||||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true}]}"
|
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}"
|
||||||
},
|
},
|
||||||
"headersSize": -1,
|
"headersSize": -1,
|
||||||
"bodySize": -1,
|
"bodySize": -1,
|
||||||
|
@ -12,13 +12,9 @@ test('should activate / deactivate save button when changes are saved', async ({
|
|||||||
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
|
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
|
||||||
/\w+/
|
/\w+/
|
||||||
)
|
)
|
||||||
await expect(
|
await expect(page.getByRole('button', { name: 'Save' }).nth(1)).toBeDisabled()
|
||||||
page.getByRole('button', { name: 'Save', exact: true })
|
|
||||||
).toBeDisabled()
|
|
||||||
await page.getByTitle('Storage path').getByTitle('Clear all').click()
|
await page.getByTitle('Storage path').getByTitle('Clear all').click()
|
||||||
await expect(
|
await expect(page.getByRole('button', { name: 'Save' }).nth(1)).toBeEnabled()
|
||||||
page.getByRole('button', { name: 'Save', exact: true })
|
|
||||||
).toBeEnabled()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should warn on unsaved changes', async ({ page }) => {
|
test('should warn on unsaved changes', async ({ page }) => {
|
||||||
@ -27,16 +23,12 @@ test('should warn on unsaved changes', async ({ page }) => {
|
|||||||
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
|
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
|
||||||
/\w+/
|
/\w+/
|
||||||
)
|
)
|
||||||
await expect(
|
await expect(page.getByRole('button', { name: 'Save' }).nth(1)).toBeDisabled()
|
||||||
page.getByRole('button', { name: 'Save', exact: true })
|
|
||||||
).toBeDisabled()
|
|
||||||
await page
|
await page
|
||||||
.getByTitle('Storage path', { exact: true })
|
.getByTitle('Storage path', { exact: true })
|
||||||
.getByTitle('Clear all')
|
.getByTitle('Clear all')
|
||||||
.click()
|
.click()
|
||||||
await expect(
|
await expect(page.getByRole('button', { name: 'Save' }).nth(1)).toBeEnabled()
|
||||||
page.getByRole('button', { name: 'Save', exact: true })
|
|
||||||
).toBeEnabled()
|
|
||||||
await page.getByRole('button', { name: 'Close', exact: true }).click()
|
await page.getByRole('button', { name: 'Close', exact: true }).click()
|
||||||
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
|
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
|
||||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
|
@ -45,8 +45,8 @@ test('basic filtering', async ({ page }) => {
|
|||||||
test('text filtering', async ({ page }) => {
|
test('text filtering', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.getByRole('textbox').click()
|
await page.getByRole('main').getByRole('combobox').click()
|
||||||
await page.getByRole('textbox').fill('test')
|
await page.getByRole('main').getByRole('combobox').fill('test')
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/32 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/32 documents/)
|
||||||
await expect(page).toHaveURL(/title_content=test/)
|
await expect(page).toHaveURL(/title_content=test/)
|
||||||
await page.getByRole('button', { name: 'Title & content' }).click()
|
await page.getByRole('button', { name: 'Title & content' }).click()
|
||||||
@ -59,12 +59,12 @@ test('text filtering', async ({ page }) => {
|
|||||||
await expect(page.locator('pngx-document-list')).toHaveText(/26 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/26 documents/)
|
||||||
await page.getByRole('button', { name: 'Advanced search' }).click()
|
await page.getByRole('button', { name: 'Advanced search' }).click()
|
||||||
await page.getByRole('button', { name: 'ASN' }).click()
|
await page.getByRole('button', { name: 'ASN' }).click()
|
||||||
await page.getByRole('textbox').fill('1123')
|
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
|
||||||
await expect(page).toHaveURL(/archive_serial_number=1123/)
|
await expect(page).toHaveURL(/archive_serial_number=1123/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
||||||
await page.locator('select').selectOption('greater')
|
await page.locator('select').selectOption('greater')
|
||||||
await page.getByRole('textbox').click()
|
await page.getByRole('main').getByRole('combobox').nth(1).click()
|
||||||
await page.getByRole('textbox').fill('1123')
|
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
|
||||||
await expect(page).toHaveURL(/archive_serial_number__gt=1123/)
|
await expect(page).toHaveURL(/archive_serial_number__gt=1123/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/5 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/5 documents/)
|
||||||
await page.locator('select').selectOption('less')
|
await page.locator('select').selectOption('less')
|
||||||
@ -81,14 +81,15 @@ test('text filtering', async ({ page }) => {
|
|||||||
test('date filtering', async ({ page }) => {
|
test('date filtering', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.getByRole('button', { name: 'Created' }).click()
|
await page.getByRole('button', { name: 'Dates' }).click()
|
||||||
await page.getByRole('menuitem', { name: 'Last 3 months' }).click()
|
await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click()
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
||||||
await page.getByRole('button', { name: 'Created Clear selected' }).click()
|
await page.getByRole('button', { name: 'Dates Clear selected' }).click()
|
||||||
await page.getByRole('button', { name: 'Created' }).click()
|
await page.getByRole('button', { name: 'Dates' }).click()
|
||||||
await page
|
await page
|
||||||
.getByRole('menuitem', { name: 'After mm/dd/yyyy' })
|
.getByRole('menuitem', { name: 'After mm/dd/yyyy' })
|
||||||
.getByRole('button')
|
.getByRole('button')
|
||||||
|
.first()
|
||||||
.click()
|
.click()
|
||||||
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
|
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
|
||||||
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
|
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
|
||||||
@ -131,18 +132,18 @@ test('sorting', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Notes' }).click()
|
await page.getByRole('button', { name: 'Notes' }).click()
|
||||||
await expect(page).toHaveURL(/sort=num_notes/)
|
await expect(page).toHaveURL(/sort=num_notes/)
|
||||||
await page.getByRole('button', { name: 'Sort' }).click()
|
await page.getByRole('button', { name: 'Sort' }).click()
|
||||||
await page.locator('.w-100 > label > .toolbaricon').first().click()
|
await page.locator('.w-100 > label > i-bs').first().click()
|
||||||
await expect(page).not.toHaveURL(/reverse=1/)
|
await expect(page).not.toHaveURL(/reverse=1/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('change views', async ({ page }) => {
|
test('change views', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' })
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.locator('pngx-page-header label').first().click()
|
await page.locator('.btn-group label').first().click()
|
||||||
await expect(page.locator('pngx-document-list table')).toBeVisible()
|
await expect(page.locator('pngx-document-list table')).toBeVisible()
|
||||||
await page.locator('pngx-page-header label').nth(1).click()
|
await page.locator('.btn-group label').nth(1).click()
|
||||||
await expect(page.locator('pngx-document-card-small').first()).toBeAttached()
|
await expect(page.locator('pngx-document-card-small').first()).toBeAttached()
|
||||||
await page.locator('pngx-page-header label').nth(2).click()
|
await page.locator('.btn-group label').nth(2).click()
|
||||||
await expect(page.locator('pngx-document-card-large').first()).toBeAttached()
|
await expect(page.locator('pngx-document-card-large').first()).toBeAttached()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
5358
src-ui/messages.xlf
7121
src-ui/package-lock.json
generated
@ -11,56 +11,58 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^16.2.11",
|
"@angular/cdk": "^17.3.10",
|
||||||
"@angular/common": "~16.2.11",
|
"@angular/common": "~17.3.9",
|
||||||
"@angular/compiler": "~16.2.11",
|
"@angular/compiler": "~17.3.9",
|
||||||
"@angular/core": "~16.2.11",
|
"@angular/core": "~17.3.9",
|
||||||
"@angular/forms": "~16.2.11",
|
"@angular/forms": "~17.3.9",
|
||||||
"@angular/localize": "~16.2.11",
|
"@angular/localize": "~17.3.9",
|
||||||
"@angular/platform-browser": "~16.2.11",
|
"@angular/platform-browser": "~17.3.9",
|
||||||
"@angular/platform-browser-dynamic": "~16.2.11",
|
"@angular/platform-browser-dynamic": "~17.3.9",
|
||||||
"@angular/router": "~16.2.11",
|
"@angular/router": "~17.3.9",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^15.1.2",
|
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
||||||
"@ng-select/ng-select": "^11.2.0",
|
"@ng-select/ng-select": "^12.0.7",
|
||||||
"@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.2",
|
"bootstrap": "^5.3.3",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ng2-pdf-viewer": "^10.0.0",
|
"ng2-pdf-viewer": "^10.2.2",
|
||||||
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^9.0.0",
|
"ngx-color": "^9.0.0",
|
||||||
"ngx-cookie-service": "^16.0.1",
|
"ngx-cookie-service": "^17.1.0",
|
||||||
"ngx-file-drop": "^16.0.0",
|
"ngx-file-drop": "^16.0.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^13.0.6",
|
"ngx-filesize": "^3.0.3",
|
||||||
|
"ngx-ui-tour-ng-bootstrap": "^14.0.3",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zone.js": "^0.13.3"
|
"zone.js": "^0.14.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/jest": "16.0.1",
|
"@angular-builders/jest": "17.0.3",
|
||||||
"@angular-devkit/build-angular": "~16.2.9",
|
"@angular-devkit/build-angular": "~17.3.7",
|
||||||
"@angular-eslint/builder": "16.2.0",
|
"@angular-eslint/builder": "17.4.1",
|
||||||
"@angular-eslint/eslint-plugin": "16.2.0",
|
"@angular-eslint/eslint-plugin": "17.4.1",
|
||||||
"@angular-eslint/eslint-plugin-template": "16.2.0",
|
"@angular-eslint/eslint-plugin-template": "17.4.1",
|
||||||
"@angular-eslint/schematics": "16.2.0",
|
"@angular-eslint/schematics": "17.4.1",
|
||||||
"@angular-eslint/template-parser": "16.2.0",
|
"@angular-eslint/template-parser": "17.4.1",
|
||||||
"@angular/cli": "~16.2.9",
|
"@angular/cli": "~17.3.7",
|
||||||
"@angular/compiler-cli": "~16.2.3",
|
"@angular/compiler-cli": "~17.3.2",
|
||||||
"@playwright/test": "^1.40.1",
|
"@playwright/test": "^1.42.1",
|
||||||
"@types/jest": "^29.5.10",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/node": "^20.10.2",
|
"@types/node": "^20.12.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.13.1",
|
"@typescript-eslint/eslint-plugin": "^7.4.0",
|
||||||
"@typescript-eslint/parser": "^6.13.1",
|
"@typescript-eslint/parser": "^7.4.0",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"eslint": "^8.55.0",
|
"eslint": "^8.57.0",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"jest-preset-angular": "^13.1.4",
|
"jest-preset-angular": "^14.1.0",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^5.1.6",
|
"typescript": "^5.3.3",
|
||||||
"wait-on": "^7.2.0"
|
"wait-on": "^7.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,6 +23,7 @@ import localeFi from '@angular/common/locales/fi'
|
|||||||
import localeFr from '@angular/common/locales/fr'
|
import localeFr from '@angular/common/locales/fr'
|
||||||
import localeHu from '@angular/common/locales/hu'
|
import localeHu from '@angular/common/locales/hu'
|
||||||
import localeIt from '@angular/common/locales/it'
|
import localeIt from '@angular/common/locales/it'
|
||||||
|
import localeJa from '@angular/common/locales/ja'
|
||||||
import localeLb from '@angular/common/locales/lb'
|
import localeLb from '@angular/common/locales/lb'
|
||||||
import localeNl from '@angular/common/locales/nl'
|
import localeNl from '@angular/common/locales/nl'
|
||||||
import localeNo from '@angular/common/locales/no'
|
import localeNo from '@angular/common/locales/no'
|
||||||
@ -53,6 +54,7 @@ registerLocaleData(localeFi)
|
|||||||
registerLocaleData(localeFr)
|
registerLocaleData(localeFr)
|
||||||
registerLocaleData(localeHu)
|
registerLocaleData(localeHu)
|
||||||
registerLocaleData(localeIt)
|
registerLocaleData(localeIt)
|
||||||
|
registerLocaleData(localeJa)
|
||||||
registerLocaleData(localeLb)
|
registerLocaleData(localeLb)
|
||||||
registerLocaleData(localeNl)
|
registerLocaleData(localeNl)
|
||||||
registerLocaleData(localeNo)
|
registerLocaleData(localeNo)
|
||||||
@ -74,12 +76,16 @@ const mock = () => {
|
|||||||
let storage: { [key: string]: string } = {}
|
let storage: { [key: string]: string } = {}
|
||||||
return {
|
return {
|
||||||
getItem: (key: string) => (key in storage ? storage[key] : null),
|
getItem: (key: string) => (key in storage ? storage[key] : null),
|
||||||
setItem: (key: string, value: string) => (storage[key] = value || ''),
|
setItem: (key: string, value: string) => {
|
||||||
|
if (value.length > 1000000) throw new Error('localStorage overflow')
|
||||||
|
storage[key] = value || ''
|
||||||
|
},
|
||||||
removeItem: (key: string) => delete storage[key],
|
removeItem: (key: string) => delete storage[key],
|
||||||
clear: () => (storage = {}),
|
clear: () => (storage = {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'open', { value: jest.fn() })
|
||||||
Object.defineProperty(window, 'localStorage', { value: mock() })
|
Object.defineProperty(window, 'localStorage', { value: mock() })
|
||||||
Object.defineProperty(window, 'sessionStorage', { value: mock() })
|
Object.defineProperty(window, 'sessionStorage', { value: mock() })
|
||||||
Object.defineProperty(window, 'getComputedStyle', {
|
Object.defineProperty(window, 'getComputedStyle', {
|
||||||
@ -92,6 +98,10 @@ Object.defineProperty(navigator, 'clipboard', {
|
|||||||
})
|
})
|
||||||
Object.defineProperty(navigator, 'canShare', { value: () => true })
|
Object.defineProperty(navigator, 'canShare', { value: () => true })
|
||||||
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
|
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
value: { reload: jest.fn() },
|
||||||
|
})
|
||||||
|
|
||||||
HTMLCanvasElement.prototype.getContext = <
|
HTMLCanvasElement.prototype.getContext = <
|
||||||
typeof HTMLCanvasElement.prototype.getContext
|
typeof HTMLCanvasElement.prototype.getContext
|
||||||
|
@ -21,10 +21,11 @@ import {
|
|||||||
PermissionAction,
|
PermissionAction,
|
||||||
PermissionType,
|
PermissionType,
|
||||||
} from './services/permissions.service'
|
} from './services/permissions.service'
|
||||||
import { ConsumptionTemplatesComponent } from './components/manage/consumption-templates/consumption-templates.component'
|
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||||
import { MailComponent } from './components/manage/mail/mail.component'
|
import { MailComponent } from './components/manage/mail/mail.component'
|
||||||
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
|
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
|
||||||
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
||||||
|
import { ConfigComponent } from './components/admin/config/config.component'
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||||
@ -140,10 +141,7 @@ export const routes: Routes = [
|
|||||||
component: LogsComponent,
|
component: LogsComponent,
|
||||||
canActivate: [PermissionsGuard],
|
canActivate: [PermissionsGuard],
|
||||||
data: {
|
data: {
|
||||||
requiredPermission: {
|
requireAdmin: true,
|
||||||
action: PermissionAction.View,
|
|
||||||
type: PermissionType.Admin,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// redirect old paths
|
// redirect old paths
|
||||||
@ -162,7 +160,7 @@ export const routes: Routes = [
|
|||||||
canActivate: [PermissionsGuard],
|
canActivate: [PermissionsGuard],
|
||||||
data: {
|
data: {
|
||||||
requiredPermission: {
|
requiredPermission: {
|
||||||
action: PermissionAction.View,
|
action: PermissionAction.Change,
|
||||||
type: PermissionType.UISettings,
|
type: PermissionType.UISettings,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -179,6 +177,17 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'config',
|
||||||
|
component: ConfigComponent,
|
||||||
|
canActivate: [PermissionsGuard],
|
||||||
|
data: {
|
||||||
|
requiredPermission: {
|
||||||
|
action: PermissionAction.Change,
|
||||||
|
type: PermissionType.AppConfig,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'tasks',
|
path: 'tasks',
|
||||||
component: TasksComponent,
|
component: TasksComponent,
|
||||||
@ -202,13 +211,13 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'templates',
|
path: 'workflows',
|
||||||
component: ConsumptionTemplatesComponent,
|
component: WorkflowsComponent,
|
||||||
canActivate: [PermissionsGuard],
|
canActivate: [PermissionsGuard],
|
||||||
data: {
|
data: {
|
||||||
requiredPermission: {
|
requiredPermission: {
|
||||||
action: PermissionAction.View,
|
action: PermissionAction.View,
|
||||||
type: PermissionType.ConsumptionTemplate,
|
type: PermissionType.Workflow,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -19,12 +19,16 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm align-self-end" role="group" aria-label="Previous / Next">
|
<div class="btn-group btn-group-sm align-self-end" role="group" aria-label="Previous / Next">
|
||||||
<button *ngIf="tourService.hasPrev(step)" class="btn btn-outline-primary" (click)="tourService.prev()">
|
@if (tourService.hasPrev(step)) {
|
||||||
|
<button class="btn btn-outline-primary" (click)="tourService.prev()">
|
||||||
« {{ step?.prevBtnTitle }}
|
« {{ step?.prevBtnTitle }}
|
||||||
</button>
|
</button>
|
||||||
<button *ngIf="tourService.hasNext(step)" class="btn btn-outline-primary" (click)="tourService.next()">
|
}
|
||||||
|
@if (tourService.hasNext(step)) {
|
||||||
|
<button class="btn btn-outline-primary" (click)="tourService.next()">
|
||||||
{{ step?.nextBtnTitle }} »
|
{{ step?.nextBtnTitle }} »
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,8 +5,7 @@ import {
|
|||||||
fakeAsync,
|
fakeAsync,
|
||||||
tick,
|
tick,
|
||||||
} from '@angular/core/testing'
|
} from '@angular/core/testing'
|
||||||
import { Router } from '@angular/router'
|
import { Router, RouterModule } from '@angular/router'
|
||||||
import { RouterTestingModule } from '@angular/router/testing'
|
|
||||||
import { TourService, TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourService, TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import { routes } from './app-routing.module'
|
import { routes } from './app-routing.module'
|
||||||
@ -21,6 +20,10 @@ import { ToastService, Toast } from './services/toast.service'
|
|||||||
import { SettingsService } from './services/settings.service'
|
import { SettingsService } from './services/settings.service'
|
||||||
import { FileDropComponent } from './components/file-drop/file-drop.component'
|
import { FileDropComponent } from './components/file-drop/file-drop.component'
|
||||||
import { NgxFileDropModule } from 'ngx-file-drop'
|
import { NgxFileDropModule } from 'ngx-file-drop'
|
||||||
|
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { HotKeyService } from './services/hot-key.service'
|
||||||
|
import { PermissionsGuard } from './guards/permissions.guard'
|
||||||
|
import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard'
|
||||||
|
|
||||||
describe('AppComponent', () => {
|
describe('AppComponent', () => {
|
||||||
let component: AppComponent
|
let component: AppComponent
|
||||||
@ -31,16 +34,18 @@ describe('AppComponent', () => {
|
|||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
let router: Router
|
let router: Router
|
||||||
let settingsService: SettingsService
|
let settingsService: SettingsService
|
||||||
|
let hotKeyService: HotKeyService
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
declarations: [AppComponent, ToastsComponent, FileDropComponent],
|
declarations: [AppComponent, ToastsComponent, FileDropComponent],
|
||||||
providers: [],
|
providers: [PermissionsGuard, DirtySavedViewGuard],
|
||||||
imports: [
|
imports: [
|
||||||
HttpClientTestingModule,
|
HttpClientTestingModule,
|
||||||
TourNgBootstrapModule,
|
TourNgBootstrapModule,
|
||||||
RouterTestingModule.withRoutes(routes),
|
RouterModule.forRoot(routes),
|
||||||
NgxFileDropModule,
|
NgxFileDropModule,
|
||||||
|
NgbModalModule,
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
@ -50,6 +55,7 @@ describe('AppComponent', () => {
|
|||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
|
hotKeyService = TestBed.inject(HotKeyService)
|
||||||
fixture = TestBed.createComponent(AppComponent)
|
fixture = TestBed.createComponent(AppComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
})
|
})
|
||||||
@ -139,4 +145,20 @@ describe('AppComponent', () => {
|
|||||||
fileStatusSubject.next(new FileStatus())
|
fileStatusSubject.next(new FileStatus())
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should support hotkeys', () => {
|
||||||
|
const addShortcutSpy = jest.spyOn(hotKeyService, 'addShortcut')
|
||||||
|
const routerSpy = jest.spyOn(router, 'navigate')
|
||||||
|
// prevent actual navigation
|
||||||
|
routerSpy.mockReturnValue(new Promise(() => {}))
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(addShortcutSpy).toHaveBeenCalled()
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'h' }))
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/dashboard'])
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'd' }))
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/documents'])
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }))
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/settings'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { SettingsService } from './services/settings.service'
|
import { SettingsService } from './services/settings.service'
|
||||||
import { SETTINGS_KEYS } from './data/paperless-uisettings'
|
import { SETTINGS_KEYS } from './data/ui-settings'
|
||||||
import { Component, OnDestroy, OnInit, Renderer2 } from '@angular/core'
|
import { Component, OnDestroy, OnInit, Renderer2 } from '@angular/core'
|
||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
import { Subscription, first } from 'rxjs'
|
import { Subscription, first } from 'rxjs'
|
||||||
@ -12,6 +12,7 @@ import {
|
|||||||
PermissionsService,
|
PermissionsService,
|
||||||
PermissionType,
|
PermissionType,
|
||||||
} from './services/permissions.service'
|
} from './services/permissions.service'
|
||||||
|
import { HotKeyService } from './services/hot-key.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-root',
|
selector: 'pngx-root',
|
||||||
@ -31,7 +32,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
private tasksService: TasksService,
|
private tasksService: TasksService,
|
||||||
public tourService: TourService,
|
public tourService: TourService,
|
||||||
private renderer: Renderer2,
|
private renderer: Renderer2,
|
||||||
private permissionsService: PermissionsService
|
private permissionsService: PermissionsService,
|
||||||
|
private hotKeyService: HotKeyService
|
||||||
) {
|
) {
|
||||||
let anyWindow = window as any
|
let anyWindow = window as any
|
||||||
anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js'
|
anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js'
|
||||||
@ -125,6 +127,36 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.hotKeyService
|
||||||
|
.addShortcut({ keys: 'h', description: $localize`Dashboard` })
|
||||||
|
.subscribe(() => {
|
||||||
|
this.router.navigate(['/dashboard'])
|
||||||
|
})
|
||||||
|
if (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.Document
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.hotKeyService
|
||||||
|
.addShortcut({ keys: 'd', description: $localize`Documents` })
|
||||||
|
.subscribe(() => {
|
||||||
|
this.router.navigate(['/documents'])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.Change,
|
||||||
|
PermissionType.UISettings
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.hotKeyService
|
||||||
|
.addShortcut({ keys: 's', description: $localize`Settings` })
|
||||||
|
.subscribe(() => {
|
||||||
|
this.router.navigate(['/settings'])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const prevBtnTitle = $localize`Prev`
|
const prevBtnTitle = $localize`Prev`
|
||||||
const nextBtnTitle = $localize`Next`
|
const nextBtnTitle = $localize`Next`
|
||||||
const endBtnTitle = $localize`End`
|
const endBtnTitle = $localize`End`
|
||||||
@ -178,9 +210,9 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
anchorId: 'tour.consumption-templates',
|
anchorId: 'tour.workflows',
|
||||||
content: $localize`Consumption templates give you finer control over the document ingestion process.`,
|
content: $localize`Workflows give you more control over the document pipeline.`,
|
||||||
route: '/templates',
|
route: '/workflows',
|
||||||
backdropConfig: {
|
backdropConfig: {
|
||||||
offset: 0,
|
offset: 0,
|
||||||
},
|
},
|
||||||
|
@ -31,7 +31,7 @@ import { ToastsComponent } from './components/common/toasts/toasts.component'
|
|||||||
import { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component'
|
import { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component'
|
||||||
import { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.component'
|
import { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.component'
|
||||||
import { ToggleableDropdownButtonComponent } from './components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
|
import { ToggleableDropdownButtonComponent } from './components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
|
||||||
import { DateDropdownComponent } from './components/common/date-dropdown/date-dropdown.component'
|
import { DatesDropdownComponent } from './components/common/dates-dropdown/dates-dropdown.component'
|
||||||
import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component'
|
import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component'
|
||||||
import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component'
|
import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component'
|
||||||
import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component'
|
import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component'
|
||||||
@ -51,7 +51,6 @@ import { SavedViewWidgetComponent } from './components/dashboard/widgets/saved-v
|
|||||||
import { StatisticsWidgetComponent } from './components/dashboard/widgets/statistics-widget/statistics-widget.component'
|
import { StatisticsWidgetComponent } from './components/dashboard/widgets/statistics-widget/statistics-widget.component'
|
||||||
import { UploadFileWidgetComponent } from './components/dashboard/widgets/upload-file-widget/upload-file-widget.component'
|
import { UploadFileWidgetComponent } from './components/dashboard/widgets/upload-file-widget/upload-file-widget.component'
|
||||||
import { WidgetFrameComponent } from './components/dashboard/widgets/widget-frame/widget-frame.component'
|
import { WidgetFrameComponent } from './components/dashboard/widgets/widget-frame/widget-frame.component'
|
||||||
import { PdfViewerModule } from 'ng2-pdf-viewer'
|
|
||||||
import { WelcomeWidgetComponent } from './components/dashboard/widgets/welcome-widget/welcome-widget.component'
|
import { WelcomeWidgetComponent } from './components/dashboard/widgets/welcome-widget/welcome-widget.component'
|
||||||
import { YesNoPipe } from './pipes/yes-no.pipe'
|
import { YesNoPipe } from './pipes/yes-no.pipe'
|
||||||
import { FileSizePipe } from './pipes/file-size.pipe'
|
import { FileSizePipe } from './pipes/file-size.pipe'
|
||||||
@ -96,8 +95,8 @@ import { UsernamePipe } from './pipes/username.pipe'
|
|||||||
import { LogoComponent } from './components/common/logo/logo.component'
|
import { LogoComponent } from './components/common/logo/logo.component'
|
||||||
import { IsNumberPipe } from './pipes/is-number.pipe'
|
import { IsNumberPipe } from './pipes/is-number.pipe'
|
||||||
import { ShareLinksDropdownComponent } from './components/common/share-links-dropdown/share-links-dropdown.component'
|
import { ShareLinksDropdownComponent } from './components/common/share-links-dropdown/share-links-dropdown.component'
|
||||||
import { ConsumptionTemplatesComponent } from './components/manage/consumption-templates/consumption-templates.component'
|
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||||
import { ConsumptionTemplateEditDialogComponent } from './components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
|
import { WorkflowEditDialogComponent } from './components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
|
||||||
import { MailComponent } from './components/manage/mail/mail.component'
|
import { MailComponent } from './components/manage/mail/mail.component'
|
||||||
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
|
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
|
||||||
import { DragDropModule } from '@angular/cdk/drag-drop'
|
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||||
@ -105,6 +104,228 @@ import { FileDropComponent } from './components/file-drop/file-drop.component'
|
|||||||
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
||||||
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
|
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||||
|
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
|
import { PdfViewerModule } from 'ng2-pdf-viewer'
|
||||||
|
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
|
||||||
|
import { PreviewPopupComponent } from './components/common/preview-popup/preview-popup.component'
|
||||||
|
import { SwitchComponent } from './components/common/input/switch/switch.component'
|
||||||
|
import { ConfigComponent } from './components/admin/config/config.component'
|
||||||
|
import { FileComponent } from './components/common/input/file/file.component'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { ConfirmButtonComponent } from './components/common/confirm-button/confirm-button.component'
|
||||||
|
import { MonetaryComponent } from './components/common/input/monetary/monetary.component'
|
||||||
|
import { SystemStatusDialogComponent } from './components/common/system-status-dialog/system-status-dialog.component'
|
||||||
|
import { NgxFilesizeModule } from 'ngx-filesize'
|
||||||
|
import { RotateConfirmDialogComponent } from './components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||||
|
import { MergeConfirmDialogComponent } from './components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||||
|
import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
||||||
|
import { DocumentHistoryComponent } from './components/document-history/document-history.component'
|
||||||
|
import { DragDropSelectComponent } from './components/common/input/drag-drop-select/drag-drop-select.component'
|
||||||
|
import { CustomFieldDisplayComponent } from './components/common/custom-field-display/custom-field-display.component'
|
||||||
|
import { GlobalSearchComponent } from './components/app-frame/global-search/global-search.component'
|
||||||
|
import { HotkeyDialogComponent } from './components/common/hotkey-dialog/hotkey-dialog.component'
|
||||||
|
import { DeletePagesConfirmDialogComponent } from './components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
|
||||||
|
import {
|
||||||
|
airplane,
|
||||||
|
archive,
|
||||||
|
arrowClockwise,
|
||||||
|
arrowCounterclockwise,
|
||||||
|
arrowDown,
|
||||||
|
arrowLeft,
|
||||||
|
arrowRepeat,
|
||||||
|
arrowRight,
|
||||||
|
arrowRightShort,
|
||||||
|
arrowUpRight,
|
||||||
|
asterisk,
|
||||||
|
bodyText,
|
||||||
|
boxArrowUp,
|
||||||
|
boxArrowUpRight,
|
||||||
|
boxes,
|
||||||
|
calendar,
|
||||||
|
calendarEvent,
|
||||||
|
calendarEventFill,
|
||||||
|
cardChecklist,
|
||||||
|
cardHeading,
|
||||||
|
caretDown,
|
||||||
|
caretUp,
|
||||||
|
chatLeftText,
|
||||||
|
check,
|
||||||
|
check2All,
|
||||||
|
checkAll,
|
||||||
|
checkCircleFill,
|
||||||
|
checkLg,
|
||||||
|
chevronDoubleLeft,
|
||||||
|
chevronDoubleRight,
|
||||||
|
clipboard,
|
||||||
|
clipboardCheck,
|
||||||
|
clipboardCheckFill,
|
||||||
|
clipboardFill,
|
||||||
|
dash,
|
||||||
|
dashCircle,
|
||||||
|
diagram3,
|
||||||
|
dice5,
|
||||||
|
doorOpen,
|
||||||
|
download,
|
||||||
|
envelope,
|
||||||
|
envelopeAt,
|
||||||
|
exclamationCircleFill,
|
||||||
|
exclamationTriangle,
|
||||||
|
exclamationTriangleFill,
|
||||||
|
eye,
|
||||||
|
fileEarmark,
|
||||||
|
fileEarmarkCheck,
|
||||||
|
fileEarmarkFill,
|
||||||
|
fileEarmarkLock,
|
||||||
|
fileEarmarkMinus,
|
||||||
|
files,
|
||||||
|
fileText,
|
||||||
|
filter,
|
||||||
|
folder,
|
||||||
|
folderFill,
|
||||||
|
funnel,
|
||||||
|
gear,
|
||||||
|
grid,
|
||||||
|
gripVertical,
|
||||||
|
hash,
|
||||||
|
hddStack,
|
||||||
|
house,
|
||||||
|
infoCircle,
|
||||||
|
journals,
|
||||||
|
link,
|
||||||
|
listTask,
|
||||||
|
listUl,
|
||||||
|
pencil,
|
||||||
|
people,
|
||||||
|
peopleFill,
|
||||||
|
person,
|
||||||
|
personCircle,
|
||||||
|
personFill,
|
||||||
|
personFillLock,
|
||||||
|
personLock,
|
||||||
|
personSquare,
|
||||||
|
plus,
|
||||||
|
plusCircle,
|
||||||
|
questionCircle,
|
||||||
|
scissors,
|
||||||
|
search,
|
||||||
|
slashCircle,
|
||||||
|
sliders2Vertical,
|
||||||
|
sortAlphaDown,
|
||||||
|
sortAlphaUpAlt,
|
||||||
|
tagFill,
|
||||||
|
tag,
|
||||||
|
tags,
|
||||||
|
textIndentLeft,
|
||||||
|
textLeft,
|
||||||
|
threeDots,
|
||||||
|
threeDotsVertical,
|
||||||
|
trash,
|
||||||
|
uiRadios,
|
||||||
|
upcScan,
|
||||||
|
x,
|
||||||
|
xLg,
|
||||||
|
} from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
airplane,
|
||||||
|
archive,
|
||||||
|
arrowClockwise,
|
||||||
|
arrowCounterclockwise,
|
||||||
|
arrowDown,
|
||||||
|
arrowLeft,
|
||||||
|
arrowRepeat,
|
||||||
|
arrowRight,
|
||||||
|
arrowRightShort,
|
||||||
|
arrowUpRight,
|
||||||
|
asterisk,
|
||||||
|
bodyText,
|
||||||
|
boxArrowUp,
|
||||||
|
boxArrowUpRight,
|
||||||
|
boxes,
|
||||||
|
calendar,
|
||||||
|
calendarEvent,
|
||||||
|
calendarEventFill,
|
||||||
|
cardChecklist,
|
||||||
|
cardHeading,
|
||||||
|
caretDown,
|
||||||
|
caretUp,
|
||||||
|
chatLeftText,
|
||||||
|
check,
|
||||||
|
check2All,
|
||||||
|
checkAll,
|
||||||
|
checkCircleFill,
|
||||||
|
checkLg,
|
||||||
|
chevronDoubleLeft,
|
||||||
|
chevronDoubleRight,
|
||||||
|
clipboard,
|
||||||
|
clipboardCheck,
|
||||||
|
clipboardCheckFill,
|
||||||
|
clipboardFill,
|
||||||
|
dash,
|
||||||
|
dashCircle,
|
||||||
|
diagram3,
|
||||||
|
dice5,
|
||||||
|
doorOpen,
|
||||||
|
download,
|
||||||
|
envelope,
|
||||||
|
envelopeAt,
|
||||||
|
exclamationCircleFill,
|
||||||
|
exclamationTriangle,
|
||||||
|
exclamationTriangleFill,
|
||||||
|
eye,
|
||||||
|
fileEarmark,
|
||||||
|
fileEarmarkCheck,
|
||||||
|
fileEarmarkFill,
|
||||||
|
fileEarmarkLock,
|
||||||
|
fileEarmarkMinus,
|
||||||
|
files,
|
||||||
|
fileText,
|
||||||
|
filter,
|
||||||
|
folder,
|
||||||
|
folderFill,
|
||||||
|
funnel,
|
||||||
|
gear,
|
||||||
|
grid,
|
||||||
|
gripVertical,
|
||||||
|
hash,
|
||||||
|
hddStack,
|
||||||
|
house,
|
||||||
|
infoCircle,
|
||||||
|
journals,
|
||||||
|
link,
|
||||||
|
listTask,
|
||||||
|
listUl,
|
||||||
|
pencil,
|
||||||
|
people,
|
||||||
|
peopleFill,
|
||||||
|
person,
|
||||||
|
personCircle,
|
||||||
|
personFill,
|
||||||
|
personFillLock,
|
||||||
|
personLock,
|
||||||
|
personSquare,
|
||||||
|
plus,
|
||||||
|
plusCircle,
|
||||||
|
questionCircle,
|
||||||
|
scissors,
|
||||||
|
search,
|
||||||
|
slashCircle,
|
||||||
|
sliders2Vertical,
|
||||||
|
sortAlphaDown,
|
||||||
|
sortAlphaUpAlt,
|
||||||
|
tagFill,
|
||||||
|
tag,
|
||||||
|
tags,
|
||||||
|
textIndentLeft,
|
||||||
|
textLeft,
|
||||||
|
threeDots,
|
||||||
|
threeDotsVertical,
|
||||||
|
trash,
|
||||||
|
uiRadios,
|
||||||
|
upcScan,
|
||||||
|
x,
|
||||||
|
xLg,
|
||||||
|
}
|
||||||
|
|
||||||
import localeAf from '@angular/common/locales/af'
|
import localeAf from '@angular/common/locales/af'
|
||||||
import localeAr from '@angular/common/locales/ar'
|
import localeAr from '@angular/common/locales/ar'
|
||||||
@ -121,6 +342,7 @@ import localeFi from '@angular/common/locales/fi'
|
|||||||
import localeFr from '@angular/common/locales/fr'
|
import localeFr from '@angular/common/locales/fr'
|
||||||
import localeHu from '@angular/common/locales/hu'
|
import localeHu from '@angular/common/locales/hu'
|
||||||
import localeIt from '@angular/common/locales/it'
|
import localeIt from '@angular/common/locales/it'
|
||||||
|
import localeJa from '@angular/common/locales/ja'
|
||||||
import localeLb from '@angular/common/locales/lb'
|
import localeLb from '@angular/common/locales/lb'
|
||||||
import localeNl from '@angular/common/locales/nl'
|
import localeNl from '@angular/common/locales/nl'
|
||||||
import localeNo from '@angular/common/locales/no'
|
import localeNo from '@angular/common/locales/no'
|
||||||
@ -151,6 +373,7 @@ registerLocaleData(localeFi)
|
|||||||
registerLocaleData(localeFr)
|
registerLocaleData(localeFr)
|
||||||
registerLocaleData(localeHu)
|
registerLocaleData(localeHu)
|
||||||
registerLocaleData(localeIt)
|
registerLocaleData(localeIt)
|
||||||
|
registerLocaleData(localeJa)
|
||||||
registerLocaleData(localeLb)
|
registerLocaleData(localeLb)
|
||||||
registerLocaleData(localeNl)
|
registerLocaleData(localeNl)
|
||||||
registerLocaleData(localeNo)
|
registerLocaleData(localeNo)
|
||||||
@ -199,7 +422,7 @@ function initializeApp(settings: SettingsService) {
|
|||||||
FilterEditorComponent,
|
FilterEditorComponent,
|
||||||
FilterableDropdownComponent,
|
FilterableDropdownComponent,
|
||||||
ToggleableDropdownButtonComponent,
|
ToggleableDropdownButtonComponent,
|
||||||
DateDropdownComponent,
|
DatesDropdownComponent,
|
||||||
DocumentCardLargeComponent,
|
DocumentCardLargeComponent,
|
||||||
DocumentCardSmallComponent,
|
DocumentCardSmallComponent,
|
||||||
BulkEditorComponent,
|
BulkEditorComponent,
|
||||||
@ -248,14 +471,32 @@ function initializeApp(settings: SettingsService) {
|
|||||||
LogoComponent,
|
LogoComponent,
|
||||||
IsNumberPipe,
|
IsNumberPipe,
|
||||||
ShareLinksDropdownComponent,
|
ShareLinksDropdownComponent,
|
||||||
ConsumptionTemplatesComponent,
|
WorkflowsComponent,
|
||||||
ConsumptionTemplateEditDialogComponent,
|
WorkflowEditDialogComponent,
|
||||||
MailComponent,
|
MailComponent,
|
||||||
UsersAndGroupsComponent,
|
UsersAndGroupsComponent,
|
||||||
FileDropComponent,
|
FileDropComponent,
|
||||||
CustomFieldsComponent,
|
CustomFieldsComponent,
|
||||||
CustomFieldEditDialogComponent,
|
CustomFieldEditDialogComponent,
|
||||||
CustomFieldsDropdownComponent,
|
CustomFieldsDropdownComponent,
|
||||||
|
ProfileEditDialogComponent,
|
||||||
|
DocumentLinkComponent,
|
||||||
|
PreviewPopupComponent,
|
||||||
|
SwitchComponent,
|
||||||
|
ConfigComponent,
|
||||||
|
FileComponent,
|
||||||
|
ConfirmButtonComponent,
|
||||||
|
MonetaryComponent,
|
||||||
|
SystemStatusDialogComponent,
|
||||||
|
RotateConfirmDialogComponent,
|
||||||
|
MergeConfirmDialogComponent,
|
||||||
|
SplitConfirmDialogComponent,
|
||||||
|
DocumentHistoryComponent,
|
||||||
|
DragDropSelectComponent,
|
||||||
|
CustomFieldDisplayComponent,
|
||||||
|
GlobalSearchComponent,
|
||||||
|
HotkeyDialogComponent,
|
||||||
|
DeletePagesConfirmDialogComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
@ -264,12 +505,14 @@ function initializeApp(settings: SettingsService) {
|
|||||||
HttpClientModule,
|
HttpClientModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgxFileDropModule,
|
|
||||||
PdfViewerModule,
|
PdfViewerModule,
|
||||||
|
NgxFileDropModule,
|
||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
ColorSliderModule,
|
ColorSliderModule,
|
||||||
TourNgBootstrapModule,
|
TourNgBootstrapModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
|
NgxBootstrapIconsModule.pick(icons),
|
||||||
|
NgxFilesizeModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
59
src-ui/src/app/components/admin/config/config.component.html
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<pngx-page-header
|
||||||
|
title="Application Configuration"
|
||||||
|
i18n-title
|
||||||
|
info="Global app configuration options which apply to <strong>every</strong> user of this install of Paperless-ngx. Options can also be set using environment variables or the configuration file but the value here will always take precedence."
|
||||||
|
i18n-info
|
||||||
|
infoLink="configuration">
|
||||||
|
</pngx-page-header>
|
||||||
|
|
||||||
|
<form [formGroup]="configForm" (ngSubmit)="saveConfig()" class="pb-4">
|
||||||
|
|
||||||
|
<ul ngbNav #nav="ngbNav" class="nav-tabs">
|
||||||
|
@for (category of optionCategories; track category) {
|
||||||
|
<li [ngbNavItem]="category">
|
||||||
|
<a ngbNavLink>{{category}}</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||||
|
@for (option of getCategoryOptions(category); track option.key) {
|
||||||
|
<div class="col">
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-title">
|
||||||
|
<h6>
|
||||||
|
{{option.title}}
|
||||||
|
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||||
|
<i-bs name="info-circle"></i-bs>
|
||||||
|
</a>
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="mb-n3">
|
||||||
|
@switch (option.type) {
|
||||||
|
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
|
||||||
|
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
|
||||||
|
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
|
||||||
|
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||||
|
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||||
|
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
|
||||||
|
<div class="btn-toolbar" role="toolbar">
|
||||||
|
<div class="btn-group me-2">
|
||||||
|
<button type="button" (click)="discardChanges()" class="btn btn-secondary" [disabled]="loading || (isDirty$ | async) === false" i18n>Discard</button>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="submit" class="btn btn-primary" [disabled]="loading || !configForm.valid || (isDirty$ | async) === false" i18n>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
145
src-ui/src/app/components/admin/config/config.component.spec.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
|
||||||
|
import { ConfigComponent } from './config.component'
|
||||||
|
import { ConfigService } from 'src/app/services/config.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { of, throwError } from 'rxjs'
|
||||||
|
import { OutputTypeConfig } from 'src/app/data/paperless-config'
|
||||||
|
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||||
|
import { BrowserModule } from '@angular/platform-browser'
|
||||||
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
|
import { TextComponent } from '../../common/input/text/text.component'
|
||||||
|
import { NumberComponent } from '../../common/input/number/number.component'
|
||||||
|
import { SwitchComponent } from '../../common/input/switch/switch.component'
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
|
import { SelectComponent } from '../../common/input/select/select.component'
|
||||||
|
import { FileComponent } from '../../common/input/file/file.component'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
|
describe('ConfigComponent', () => {
|
||||||
|
let component: ConfigComponent
|
||||||
|
let fixture: ComponentFixture<ConfigComponent>
|
||||||
|
let configService: ConfigService
|
||||||
|
let toastService: ToastService
|
||||||
|
let settingService: SettingsService
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [
|
||||||
|
ConfigComponent,
|
||||||
|
TextComponent,
|
||||||
|
SelectComponent,
|
||||||
|
NumberComponent,
|
||||||
|
SwitchComponent,
|
||||||
|
FileComponent,
|
||||||
|
PageHeaderComponent,
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
HttpClientTestingModule,
|
||||||
|
BrowserModule,
|
||||||
|
NgbModule,
|
||||||
|
NgSelectModule,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
configService = TestBed.inject(ConfigService)
|
||||||
|
toastService = TestBed.inject(ToastService)
|
||||||
|
settingService = TestBed.inject(SettingsService)
|
||||||
|
fixture = TestBed.createComponent(ConfigComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should load config on init, show error if necessary', () => {
|
||||||
|
const getSpy = jest.spyOn(configService, 'getConfig')
|
||||||
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
getSpy.mockReturnValueOnce(
|
||||||
|
throwError(() => new Error('Error getting config'))
|
||||||
|
)
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(getSpy).toHaveBeenCalled()
|
||||||
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
|
getSpy.mockReturnValueOnce(
|
||||||
|
of({ output_type: OutputTypeConfig.PDF_A } as any)
|
||||||
|
)
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.initialConfig).toEqual({
|
||||||
|
output_type: OutputTypeConfig.PDF_A,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save config, show error if necessary', () => {
|
||||||
|
const saveSpy = jest.spyOn(configService, 'saveConfig')
|
||||||
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
saveSpy.mockReturnValueOnce(
|
||||||
|
throwError(() => new Error('Error saving config'))
|
||||||
|
)
|
||||||
|
component.saveConfig()
|
||||||
|
expect(saveSpy).toHaveBeenCalled()
|
||||||
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
|
saveSpy.mockReturnValueOnce(
|
||||||
|
of({ output_type: OutputTypeConfig.PDF_A } as any)
|
||||||
|
)
|
||||||
|
component.saveConfig()
|
||||||
|
expect(component.initialConfig).toEqual({
|
||||||
|
output_type: OutputTypeConfig.PDF_A,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support discard changes', () => {
|
||||||
|
component.initialConfig = { output_type: OutputTypeConfig.PDF_A2 } as any
|
||||||
|
component.configForm.patchValue({ output_type: OutputTypeConfig.PDF_A })
|
||||||
|
component.discardChanges()
|
||||||
|
expect(component.configForm.get('output_type').value).toEqual(
|
||||||
|
OutputTypeConfig.PDF_A2
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support JSON validation for e.g. user_args', () => {
|
||||||
|
component.configForm.patchValue({ user_args: '{ foo bar }' })
|
||||||
|
expect(component.errors).toEqual({ user_args: 'Invalid JSON' })
|
||||||
|
component.configForm.patchValue({ user_args: '{ "foo": "bar" }' })
|
||||||
|
expect(component.errors).toEqual({ user_args: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should upload file, show error if necessary', () => {
|
||||||
|
const uploadSpy = jest.spyOn(configService, 'uploadFile')
|
||||||
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
uploadSpy.mockReturnValueOnce(
|
||||||
|
throwError(() => new Error('Error uploading file'))
|
||||||
|
)
|
||||||
|
component.uploadFile(new File([], 'test.png'), 'app_logo')
|
||||||
|
expect(uploadSpy).toHaveBeenCalled()
|
||||||
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
|
uploadSpy.mockReturnValueOnce(
|
||||||
|
of({ app_logo: 'https://example.com/logo/test.png' } as any)
|
||||||
|
)
|
||||||
|
component.uploadFile(new File([], 'test.png'), 'app_logo')
|
||||||
|
expect(component.initialConfig).toEqual({
|
||||||
|
app_logo: 'https://example.com/logo/test.png',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should refresh ui settings after save or upload', () => {
|
||||||
|
const saveSpy = jest.spyOn(configService, 'saveConfig')
|
||||||
|
const initSpy = jest.spyOn(settingService, 'initializeSettings')
|
||||||
|
saveSpy.mockReturnValueOnce(
|
||||||
|
of({ output_type: OutputTypeConfig.PDF_A } as any)
|
||||||
|
)
|
||||||
|
component.saveConfig()
|
||||||
|
expect(initSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const uploadSpy = jest.spyOn(configService, 'uploadFile')
|
||||||
|
uploadSpy.mockReturnValueOnce(
|
||||||
|
of({ app_logo: 'https://example.com/logo/test.png' } as any)
|
||||||
|
)
|
||||||
|
component.uploadFile(new File([], 'test.png'), 'app_logo')
|
||||||
|
expect(initSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
189
src-ui/src/app/components/admin/config/config.component.ts
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||||
|
import { AbstractControl, FormControl, FormGroup } from '@angular/forms'
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
Observable,
|
||||||
|
Subject,
|
||||||
|
Subscription,
|
||||||
|
first,
|
||||||
|
takeUntil,
|
||||||
|
} from 'rxjs'
|
||||||
|
import {
|
||||||
|
PaperlessConfigOptions,
|
||||||
|
ConfigCategory,
|
||||||
|
ConfigOption,
|
||||||
|
ConfigOptionType,
|
||||||
|
PaperlessConfig,
|
||||||
|
} from 'src/app/data/paperless-config'
|
||||||
|
import { ConfigService } from 'src/app/services/config.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
|
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-config',
|
||||||
|
templateUrl: './config.component.html',
|
||||||
|
styleUrl: './config.component.scss',
|
||||||
|
})
|
||||||
|
export class ConfigComponent
|
||||||
|
extends ComponentWithPermissions
|
||||||
|
implements OnInit, OnDestroy, DirtyComponent
|
||||||
|
{
|
||||||
|
public readonly ConfigOptionType = ConfigOptionType
|
||||||
|
|
||||||
|
// generated dynamically
|
||||||
|
public configForm = new FormGroup({})
|
||||||
|
|
||||||
|
public errors = {}
|
||||||
|
|
||||||
|
get optionCategories(): string[] {
|
||||||
|
return Object.values(ConfigCategory)
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategoryOptions(category: string): ConfigOption[] {
|
||||||
|
return PaperlessConfigOptions.filter((o) => o.category === category)
|
||||||
|
}
|
||||||
|
|
||||||
|
public loading: boolean = false
|
||||||
|
|
||||||
|
initialConfig: PaperlessConfig
|
||||||
|
store: BehaviorSubject<any>
|
||||||
|
storeSub: Subscription
|
||||||
|
isDirty$: Observable<boolean>
|
||||||
|
|
||||||
|
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private toastService: ToastService,
|
||||||
|
private settingsService: SettingsService
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
this.configForm.addControl('id', new FormControl())
|
||||||
|
PaperlessConfigOptions.forEach((option) => {
|
||||||
|
this.configForm.addControl(option.key, new FormControl())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loading = true
|
||||||
|
this.configService
|
||||||
|
.getConfig()
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe({
|
||||||
|
next: (config) => {
|
||||||
|
this.loading = false
|
||||||
|
this.initialize(config)
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.loading = false
|
||||||
|
this.toastService.showError($localize`Error retrieving config`, e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// validate JSON inputs
|
||||||
|
PaperlessConfigOptions.filter(
|
||||||
|
(o) => o.type === ConfigOptionType.JSON
|
||||||
|
).forEach((option) => {
|
||||||
|
this.configForm
|
||||||
|
.get(option.key)
|
||||||
|
.addValidators((control: AbstractControl) => {
|
||||||
|
if (!control.value || control.value.toString().length === 0)
|
||||||
|
return null
|
||||||
|
try {
|
||||||
|
JSON.parse(control.value)
|
||||||
|
} catch (e) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
user_args: e,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
this.configForm.get(option.key).statusChanges.subscribe((status) => {
|
||||||
|
this.errors[option.key] =
|
||||||
|
status === 'INVALID' ? $localize`Invalid JSON` : null
|
||||||
|
})
|
||||||
|
this.configForm.get(option.key).updateValueAndValidity()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.unsubscribeNotifier.next(true)
|
||||||
|
this.unsubscribeNotifier.complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private initialize(config: PaperlessConfig) {
|
||||||
|
if (!this.store) {
|
||||||
|
this.store = new BehaviorSubject(config)
|
||||||
|
|
||||||
|
this.store
|
||||||
|
.asObservable()
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((state) => {
|
||||||
|
this.configForm.patchValue(state, { emitEvent: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable())
|
||||||
|
}
|
||||||
|
this.configForm.patchValue(config)
|
||||||
|
|
||||||
|
this.initialConfig = config
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocsUrl(key: string) {
|
||||||
|
return `https://docs.paperless-ngx.com/configuration/#${key}`
|
||||||
|
}
|
||||||
|
|
||||||
|
public saveConfig() {
|
||||||
|
this.loading = true
|
||||||
|
this.configService
|
||||||
|
.saveConfig(this.configForm.value as PaperlessConfig)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier), first())
|
||||||
|
.subscribe({
|
||||||
|
next: (config) => {
|
||||||
|
this.loading = false
|
||||||
|
this.initialize(config)
|
||||||
|
this.store.next(config)
|
||||||
|
this.settingsService.initializeSettings().subscribe()
|
||||||
|
this.toastService.showInfo($localize`Configuration updated`)
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.loading = false
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`An error occurred updating configuration`,
|
||||||
|
e
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public discardChanges() {
|
||||||
|
this.configForm.reset(this.initialConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
public uploadFile(file: File, key: string) {
|
||||||
|
this.loading = true
|
||||||
|
this.configService
|
||||||
|
.uploadFile(file, this.configForm.value['id'], key)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier), first())
|
||||||
|
.subscribe({
|
||||||
|
next: (config) => {
|
||||||
|
this.loading = false
|
||||||
|
this.initialize(config)
|
||||||
|
this.store.next(config)
|
||||||
|
this.settingsService.initializeSettings().subscribe()
|
||||||
|
this.toastService.showInfo($localize`File successfully updated`)
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.loading = false
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`An error occurred uploading file`,
|
||||||
|
e
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -1,30 +1,44 @@
|
|||||||
<pngx-page-header title="Logs" i18n-title>
|
<pngx-page-header
|
||||||
<div class="form-check form-switch" (click)="toggleAutoRefresh()">
|
title="Logs"
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="autoRefreshSwitch" [attr.checked]="autoRefreshInterval">
|
i18n-title
|
||||||
|
info="Review the log files for the application and for email checking."
|
||||||
|
i18n-info>
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="autoRefreshSwitch" (click)="toggleAutoRefresh()" [attr.checked]="autoRefreshInterval">
|
||||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||||
</div>
|
</div>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeLog" (activeIdChange)="reloadLogs()" class="nav-tabs">
|
<ul ngbNav #nav="ngbNav" [(activeId)]="activeLog" (activeIdChange)="reloadLogs()" class="nav-tabs">
|
||||||
<li *ngFor="let logFile of logFiles" [ngbNavItem]="logFile">
|
@for (logFile of logFiles; track logFile) {
|
||||||
|
<li [ngbNavItem]="logFile">
|
||||||
<a ngbNavLink>
|
<a ngbNavLink>
|
||||||
{{logFile}}.log
|
{{logFile}}.log
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<div *ngIf="isLoading || !logFiles.length" class="ps-2 d-flex align-items-center">
|
}
|
||||||
|
@if (isLoading || !logFiles.length) {
|
||||||
|
<div class="ps-2 d-flex align-items-center">
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
<ng-container *ngIf="!logFiles.length" i18n>Loading...</ng-container>
|
@if (!logFiles.length) {
|
||||||
|
<ng-container i18n>Loading...</ng-container>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div [ngbNavOutlet]="nav" class="mt-2"></div>
|
<div [ngbNavOutlet]="nav" class="mt-2"></div>
|
||||||
|
|
||||||
<div class="bg-dark p-3 text-light font-monospace log-container" #logContainer>
|
<div class="bg-dark p-3 text-light font-monospace log-container" #logContainer>
|
||||||
<div *ngIf="isLoading && logFiles.length">
|
@if (isLoading && logFiles.length) {
|
||||||
|
<div>
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
<ng-container i18n>Loading...</ng-container>
|
<ng-container i18n>Loading...</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
@for (log of logs; track log) {
|
||||||
<p
|
<p
|
||||||
class="m-0 p-0 log-entry-{{getLogLevel(log)}}"
|
class="m-0 p-0 log-entry-{{getLogLevel(log)}}"
|
||||||
*ngFor="let log of logs">{{log}}</p>
|
>{{log}}</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@ -11,6 +11,7 @@ import { of, throwError } from 'rxjs'
|
|||||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||||
import { NgbModule, NgbNavLink } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModule, NgbNavLink } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { BrowserModule, By } from '@angular/platform-browser'
|
import { BrowserModule, By } from '@angular/platform-browser'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
const paperless_logs = [
|
const paperless_logs = [
|
||||||
'[2023-05-29 03:05:01,224] [DEBUG] [paperless.tasks] Training data unchanged.',
|
'[2023-05-29 03:05:01,224] [DEBUG] [paperless.tasks] Training data unchanged.',
|
||||||
@ -37,7 +38,12 @@ describe('LogsComponent', () => {
|
|||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
declarations: [LogsComponent, PageHeaderComponent],
|
declarations: [LogsComponent, PageHeaderComponent],
|
||||||
providers: [],
|
providers: [],
|
||||||
imports: [HttpClientTestingModule, BrowserModule, NgbModule],
|
imports: [
|
||||||
|
HttpClientTestingModule,
|
||||||
|
BrowserModule,
|
||||||
|
NgbModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
logService = TestBed.inject(LogService)
|
logService = TestBed.inject(LogService)
|
||||||
|
@ -2,9 +2,9 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
OnInit,
|
OnInit,
|
||||||
AfterViewChecked,
|
|
||||||
ViewChild,
|
ViewChild,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
|
ChangeDetectorRef,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { Subject, takeUntil } from 'rxjs'
|
import { Subject, takeUntil } from 'rxjs'
|
||||||
import { LogService } from 'src/app/services/rest/log.service'
|
import { LogService } from 'src/app/services/rest/log.service'
|
||||||
@ -14,8 +14,11 @@ import { LogService } from 'src/app/services/rest/log.service'
|
|||||||
templateUrl: './logs.component.html',
|
templateUrl: './logs.component.html',
|
||||||
styleUrls: ['./logs.component.scss'],
|
styleUrls: ['./logs.component.scss'],
|
||||||
})
|
})
|
||||||
export class LogsComponent implements OnInit, AfterViewChecked, OnDestroy {
|
export class LogsComponent implements OnInit, OnDestroy {
|
||||||
constructor(private logService: LogService) {}
|
constructor(
|
||||||
|
private logService: LogService,
|
||||||
|
private changedetectorRef: ChangeDetectorRef
|
||||||
|
) {}
|
||||||
|
|
||||||
public logs: string[] = []
|
public logs: string[] = []
|
||||||
|
|
||||||
@ -47,13 +50,10 @@ export class LogsComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewChecked() {
|
|
||||||
this.scrollToBottom()
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.unsubscribeNotifier.next(true)
|
this.unsubscribeNotifier.next(true)
|
||||||
this.unsubscribeNotifier.complete()
|
this.unsubscribeNotifier.complete()
|
||||||
|
clearInterval(this.autoRefreshInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
reloadLogs() {
|
reloadLogs() {
|
||||||
@ -65,6 +65,7 @@ export class LogsComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.logs = result
|
this.logs = result
|
||||||
this.isLoading = false
|
this.isLoading = false
|
||||||
|
this.scrollToBottom()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.logs = []
|
this.logs = []
|
||||||
@ -88,6 +89,7 @@ export class LogsComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
scrollToBottom(): void {
|
scrollToBottom(): void {
|
||||||
|
this.changedetectorRef.detectChanges()
|
||||||
this.logContainer?.nativeElement.scroll({
|
this.logContainer?.nativeElement.scroll({
|
||||||
top: this.logContainer.nativeElement.scrollHeight,
|
top: this.logContainer.nativeElement.scrollHeight,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
@ -1,11 +1,36 @@
|
|||||||
<pngx-page-header title="Settings" i18n-title>
|
<pngx-page-header
|
||||||
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()"><ng-container i18n>Start tour</ng-container></button>
|
title="Settings"
|
||||||
<a *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }" class="btn btn-sm btn-primary ms-3" href="admin/" target="_blank">
|
i18n-title
|
||||||
|
info="Options to customize appearance, notifications, saved views and more. Settings apply to the <strong>current user only</strong>."
|
||||||
|
i18n-info
|
||||||
|
>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()">
|
||||||
|
<i-bs class="me-1" name="airplane"></i-bs> <ng-container i18n>Start tour</ng-container>
|
||||||
|
</button>
|
||||||
|
@if (permissionsService.isAdmin()) {
|
||||||
|
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
|
||||||
|
[disabled]="!systemStatus">
|
||||||
|
@if (!systemStatus) {
|
||||||
|
<div class="spinner-border spinner-border-sm me-1 h-75" role="status"></div>
|
||||||
|
} @else {
|
||||||
|
<i-bs class="me-2" name="card-checklist"></i-bs>
|
||||||
|
@if (systemStatusHasErrors) {
|
||||||
|
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
|
||||||
|
<i-bs name="exclamation-circle-fill" class="text-danger" width="1.75em" height="1.75em"></i-bs>
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
|
||||||
|
<i-bs name="check-circle-fill" class="text-primary" width="1.75em" height="1.75em"></i-bs>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<ng-container i18n>System Status</ng-container>
|
||||||
|
</button>
|
||||||
|
<a class="btn btn-sm btn-primary" href="admin/" target="_blank">
|
||||||
<ng-container i18n>Open Django Admin</ng-container>
|
<ng-container i18n>Open Django Admin</ng-container>
|
||||||
<svg class="sidebaricon ms-1" fill="currentColor">
|
<i-bs name="arrow-up-right"></i-bs>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#arrow-up-right"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
</a>
|
||||||
|
}
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
||||||
@ -24,10 +49,16 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
|
|
||||||
<select class="form-select" formControlName="displayLanguage">
|
<select class="form-select" formControlName="displayLanguage">
|
||||||
<option *ngFor="let lang of displayLanguageOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code && currentLocale !== 'en-US'"> - {{lang.englishName}}</span></option>
|
@for (lang of displayLanguageOptions; track lang) {
|
||||||
|
<option [ngValue]="lang.code">{{lang.name}}@if (lang.code && currentLocale !== 'en-US') {
|
||||||
|
<span> - {{lang.englishName}}</span>
|
||||||
|
}</option>
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<small *ngIf="displayLanguageIsDirty" class="form-text text-primary" i18n>You need to reload the page after applying a new language.</small>
|
@if (displayLanguageIsDirty) {
|
||||||
|
<small class="form-text text-primary" i18n>You need to reload the page after applying a new language.</small>
|
||||||
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -39,7 +70,11 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
|
|
||||||
<select class="form-select" formControlName="dateLocale">
|
<select class="form-select" formControlName="dateLocale">
|
||||||
<option *ngFor="let lang of dateLocaleOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code"> - {{today | customDate:'shortDate':null:lang.code}}</span></option>
|
@for (lang of dateLocaleOptions; track lang) {
|
||||||
|
<option [ngValue]="lang.code">{{lang.name}}@if (lang.code) {
|
||||||
|
<span> - {{today | customDate:'shortDate':null:lang.code}}</span>
|
||||||
|
}</option>
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@ -125,9 +160,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
<button class="btn btn-link btn-sm pt-2 ps-0" [disabled]="!this.settingsForm.get('themeColor').value" (click)="clearThemeColor()">
|
<button class="btn btn-link btn-sm pt-2 ps-0" [disabled]="!this.settingsForm.get('themeColor').value" (click)="clearThemeColor()">
|
||||||
<svg fill="currentColor" class="buttonicon-sm me-1">
|
<i-bs width="1em" height="1em" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
|
||||||
</svg><ng-container i18n>Reset</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -147,6 +180,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h4 class="mt-4" i18n>Document editing</h4>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="offset-md-3 col">
|
||||||
|
<pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4 class="mt-4" i18n>Bulk editing</h4>
|
<h4 class="mt-4" i18n>Bulk editing</h4>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
@ -156,6 +197,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h4 class="mt-4" i18n>Global search</h4>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="offset-md-3 col">
|
||||||
|
<pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="offset-md-3 col">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-2 col-form-label pt-0">
|
||||||
|
<span i18n>Full search links to</span>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<select class="form-select" formControlName="searchLink">
|
||||||
|
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
|
||||||
|
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4 class="mt-4" i18n>Notes</h4>
|
<h4 class="mt-4" i18n>Notes</h4>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
@ -279,17 +344,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 i18n>Views</h4>
|
<h4 i18n>Views</h4>
|
||||||
<div formGroupName="savedViews">
|
<ul class="list-group" formGroupName="savedViews">
|
||||||
|
|
||||||
<div *ngFor="let view of savedViews" [formGroupName]="view.id" class="row">
|
@for (view of savedViews; track view) {
|
||||||
<div class="mb-3 col">
|
<li class="list-group-item py-3">
|
||||||
<label class="form-label" for="name_{{view.id}}" i18n>Name</label>
|
<div [formGroupName]="view.id" class="row">
|
||||||
<input type="text" class="form-control" formControlName="name" id="name_{{view.id}}">
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<pngx-input-text title="Name" formControlName="name"></pngx-input-text>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col">
|
||||||
<div class="mb-2 col">
|
<div class="form-check form-switch mt-3">
|
||||||
<label class="form-label" for="show_on_dashboard_{{view.id}}" i18n> <span class="visually-hidden">Appears on</span></label>
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
|
<input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
|
||||||
<label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
|
<label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
|
||||||
</div>
|
</div>
|
||||||
@ -298,21 +363,52 @@
|
|||||||
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
|
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
<div class="mb-2 col-auto">
|
|
||||||
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger form-control" (click)="deleteSavedView(view)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }" i18n>Delete</button>
|
<pngx-confirm-button
|
||||||
|
label="Delete"
|
||||||
|
i18n-label
|
||||||
|
(confirm)="deleteSavedView(view)"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }"
|
||||||
|
buttonClasses="btn-sm btn-outline-danger form-control"
|
||||||
|
iconName="trash">
|
||||||
|
</pngx-confirm-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<pngx-input-number i18n-title title="Documents page size" [showAdd]="false" formControlName="page_size"></pngx-input-number>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label" for="display_mode_{{view.id}}" i18n>Display as</label>
|
||||||
|
<select class="form-select" formControlName="display_mode">
|
||||||
|
<option [ngValue]="DisplayMode.TABLE" i18n>Table</option>
|
||||||
|
<option [ngValue]="DisplayMode.SMALL_CARDS" i18n>Small Cards</option>
|
||||||
|
<option [ngValue]="DisplayMode.LARGE_CARDS" i18n>Large Cards</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
@if (displayFields) {
|
||||||
|
<pngx-input-drag-drop-select i18n-title title="Show" i18n-emptyText emptyText="Default" [items]="displayFields" formControlName="display_fields"></pngx-input-drag-drop-select>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
<div *ngIf="savedViews && savedViews.length === 0" i18n>No saved views defined.</div>
|
@if (savedViews && savedViews.length === 0) {
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div i18n>No saved views defined.</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
<div *ngIf="!savedViews">
|
@if (!savedViews) {
|
||||||
|
<li class="list-group-item">
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||||
<div class="visually-hidden" i18n>Loading...</div>
|
<div class="visually-hidden" i18n>Loading...</div>
|
||||||
</div>
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
</div>
|
</ul>
|
||||||
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
@ -320,5 +416,6 @@
|
|||||||
|
|
||||||
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
|
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary mb-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
<button type="submit" class="btn btn-primary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
||||||
|
<button type="button" (click)="reset()" class="btn btn-secondary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
|
||||||
</form>
|
</form>
|
||||||
|
@ -9,12 +9,14 @@ import {
|
|||||||
NgbModule,
|
NgbModule,
|
||||||
NgbAlertModule,
|
NgbAlertModule,
|
||||||
NgbNavLink,
|
NgbNavLink,
|
||||||
|
NgbModal,
|
||||||
|
NgbModalModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
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'
|
||||||
@ -37,6 +39,17 @@ import { TextComponent } from '../../common/input/text/text.component'
|
|||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { SettingsComponent } from './settings.component'
|
import { SettingsComponent } from './settings.component'
|
||||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||||
|
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
|
||||||
|
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||||
|
import {
|
||||||
|
SystemStatus,
|
||||||
|
InstallType,
|
||||||
|
SystemStatusItemStatus,
|
||||||
|
} from 'src/app/data/system-status'
|
||||||
|
import { DragDropSelectComponent } from '../../common/input/drag-drop-select/drag-drop-select.component'
|
||||||
|
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||||
|
|
||||||
const savedViews = [
|
const savedViews = [
|
||||||
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
|
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
|
||||||
@ -63,6 +76,8 @@ describe('SettingsComponent', () => {
|
|||||||
let userService: UserService
|
let userService: UserService
|
||||||
let permissionsService: PermissionsService
|
let permissionsService: PermissionsService
|
||||||
let groupService: GroupService
|
let groupService: GroupService
|
||||||
|
let modalService: NgbModal
|
||||||
|
let systemStatusService: SystemStatusService
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@ -82,6 +97,8 @@ describe('SettingsComponent', () => {
|
|||||||
PermissionsUserComponent,
|
PermissionsUserComponent,
|
||||||
PermissionsGroupComponent,
|
PermissionsGroupComponent,
|
||||||
IfOwnerDirective,
|
IfOwnerDirective,
|
||||||
|
ConfirmButtonComponent,
|
||||||
|
DragDropSelectComponent,
|
||||||
],
|
],
|
||||||
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
|
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
|
||||||
imports: [
|
imports: [
|
||||||
@ -92,6 +109,9 @@ describe('SettingsComponent', () => {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgbAlertModule,
|
NgbAlertModule,
|
||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
NgbModalModule,
|
||||||
|
DragDropModule,
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
@ -103,6 +123,8 @@ describe('SettingsComponent', () => {
|
|||||||
settingsService.currentUser = users[0]
|
settingsService.currentUser = users[0]
|
||||||
userService = TestBed.inject(UserService)
|
userService = TestBed.inject(UserService)
|
||||||
permissionsService = TestBed.inject(PermissionsService)
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
|
modalService = TestBed.inject(NgbModal)
|
||||||
|
systemStatusService = TestBed.inject(SystemStatusService)
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
jest
|
jest
|
||||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||||
@ -138,7 +160,7 @@ describe('SettingsComponent', () => {
|
|||||||
of({
|
of({
|
||||||
all: savedViews.map((v) => v.id),
|
all: savedViews.map((v) => v.id),
|
||||||
count: savedViews.length,
|
count: savedViews.length,
|
||||||
results: (savedViews as PaperlessSavedView[]).concat([]),
|
results: (savedViews as SavedView[]).concat([]),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -226,9 +248,7 @@ describe('SettingsComponent', () => {
|
|||||||
savedViewPatchSpy.mockClear()
|
savedViewPatchSpy.mockClear()
|
||||||
|
|
||||||
// succeed saved views
|
// succeed saved views
|
||||||
savedViewPatchSpy.mockReturnValueOnce(
|
savedViewPatchSpy.mockReturnValueOnce(of(savedViews as SavedView[]))
|
||||||
of(savedViews as PaperlessSavedView[])
|
|
||||||
)
|
|
||||||
component.saveSettings()
|
component.saveSettings()
|
||||||
expect(toastErrorSpy).not.toHaveBeenCalled()
|
expect(toastErrorSpy).not.toHaveBeenCalled()
|
||||||
expect(savedViewPatchSpy).toHaveBeenCalled()
|
expect(savedViewPatchSpy).toHaveBeenCalled()
|
||||||
@ -289,7 +309,7 @@ describe('SettingsComponent', () => {
|
|||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(storeSpy).toHaveBeenCalled()
|
expect(storeSpy).toHaveBeenCalled()
|
||||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||||
expect(setSpy).toHaveBeenCalledTimes(24)
|
expect(setSpy).toHaveBeenCalledTimes(27)
|
||||||
|
|
||||||
// succeed
|
// succeed
|
||||||
storeSpy.mockReturnValueOnce(of(true))
|
storeSpy.mockReturnValueOnce(of(true))
|
||||||
@ -307,10 +327,15 @@ describe('SettingsComponent', () => {
|
|||||||
component.store.getValue()['displayLanguage'] = 'en-US'
|
component.store.getValue()['displayLanguage'] = 'en-US'
|
||||||
component.store.getValue()['updateCheckingEnabled'] = false
|
component.store.getValue()['updateCheckingEnabled'] = false
|
||||||
component.settingsForm.value.displayLanguage = 'en-GB'
|
component.settingsForm.value.displayLanguage = 'en-GB'
|
||||||
component.settingsForm.value.updateCheckingEnabled = true
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValueOnce(of(true))
|
|
||||||
component.saveSettings()
|
component.saveSettings()
|
||||||
expect(toast.actionName).toEqual('Reload now')
|
expect(toast.actionName).toEqual('Reload now')
|
||||||
|
|
||||||
|
component.settingsForm.value.updateCheckingEnabled = true
|
||||||
|
component.saveSettings()
|
||||||
|
|
||||||
|
expect(toast.actionName).toEqual('Reload now')
|
||||||
|
toast.action()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should allow setting theme color, visually apply change immediately but not save', () => {
|
it('should allow setting theme color, visually apply change immediately but not save', () => {
|
||||||
@ -335,7 +360,7 @@ describe('SettingsComponent', () => {
|
|||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
const deleteSpy = jest.spyOn(savedViewService, 'delete')
|
const deleteSpy = jest.spyOn(savedViewService, 'delete')
|
||||||
deleteSpy.mockReturnValue(of(true))
|
deleteSpy.mockReturnValue(of(true))
|
||||||
component.deleteSavedView(savedViews[0] as PaperlessSavedView)
|
component.deleteSavedView(savedViews[0] as SavedView)
|
||||||
expect(deleteSpy).toHaveBeenCalled()
|
expect(deleteSpy).toHaveBeenCalled()
|
||||||
expect(toastSpy).toHaveBeenCalledWith(
|
expect(toastSpy).toHaveBeenCalledWith(
|
||||||
`Saved view "${savedViews[0].name}" deleted.`
|
`Saved view "${savedViews[0].name}" deleted.`
|
||||||
@ -365,4 +390,62 @@ describe('SettingsComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(toastErrorSpy).toBeCalled()
|
expect(toastErrorSpy).toBeCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
||||||
|
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
|
||||||
|
completeSetup()
|
||||||
|
expect(component['systemStatus']).toEqual(status) // private
|
||||||
|
expect(component.systemStatusHasErrors).toBeTruthy()
|
||||||
|
// coverage
|
||||||
|
component['systemStatus'].database.status = SystemStatusItemStatus.OK
|
||||||
|
component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK
|
||||||
|
component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK
|
||||||
|
expect(component.systemStatusHasErrors).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should open system status dialog', () => {
|
||||||
|
const modalOpenSpy = jest.spyOn(modalService, 'open')
|
||||||
|
completeSetup()
|
||||||
|
component.showSystemStatus()
|
||||||
|
expect(modalOpenSpy).toHaveBeenCalledWith(SystemStatusDialogComponent, {
|
||||||
|
size: 'xl',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support reset', () => {
|
||||||
|
completeSetup()
|
||||||
|
component.settingsForm.get('themeColor').setValue('#ff0000')
|
||||||
|
component.reset()
|
||||||
|
expect(component.settingsForm.get('themeColor').value).toEqual('')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -9,7 +9,11 @@ import {
|
|||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormGroup, FormControl } from '@angular/forms'
|
import { FormGroup, FormControl } from '@angular/forms'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap'
|
import {
|
||||||
|
NgbModal,
|
||||||
|
NgbModalRef,
|
||||||
|
NgbNavChangeEvent,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
|
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
|
||||||
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import {
|
import {
|
||||||
@ -21,10 +25,10 @@ import {
|
|||||||
takeUntil,
|
takeUntil,
|
||||||
tap,
|
tap,
|
||||||
} from 'rxjs'
|
} from 'rxjs'
|
||||||
import { PaperlessGroup } from 'src/app/data/paperless-group'
|
import { Group } from 'src/app/data/group'
|
||||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
|
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { PaperlessUser } from 'src/app/data/paperless-user'
|
import { User } from 'src/app/data/user'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
import {
|
import {
|
||||||
PermissionsService,
|
PermissionsService,
|
||||||
@ -40,6 +44,13 @@ import {
|
|||||||
} from 'src/app/services/settings.service'
|
} from 'src/app/services/settings.service'
|
||||||
import { ToastService, Toast } from 'src/app/services/toast.service'
|
import { ToastService, Toast } from 'src/app/services/toast.service'
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
|
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
|
||||||
|
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||||
|
import {
|
||||||
|
SystemStatusItemStatus,
|
||||||
|
SystemStatus,
|
||||||
|
} from 'src/app/data/system-status'
|
||||||
|
import { DisplayMode } from 'src/app/data/document'
|
||||||
|
|
||||||
enum SettingsNavIDs {
|
enum SettingsNavIDs {
|
||||||
General = 1,
|
General = 1,
|
||||||
@ -48,6 +59,12 @@ enum SettingsNavIDs {
|
|||||||
SavedViews = 4,
|
SavedViews = 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const systemLanguage = { code: '', name: $localize`Use system language` }
|
||||||
|
const systemDateFormat = {
|
||||||
|
code: '',
|
||||||
|
name: $localize`Use date format of display language`,
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-settings',
|
selector: 'pngx-settings',
|
||||||
templateUrl: './settings.component.html',
|
templateUrl: './settings.component.html',
|
||||||
@ -57,8 +74,8 @@ export class SettingsComponent
|
|||||||
extends ComponentWithPermissions
|
extends ComponentWithPermissions
|
||||||
implements OnInit, AfterViewInit, OnDestroy, DirtyComponent
|
implements OnInit, AfterViewInit, OnDestroy, DirtyComponent
|
||||||
{
|
{
|
||||||
SettingsNavIDs = SettingsNavIDs
|
|
||||||
activeNavID: number
|
activeNavID: number
|
||||||
|
DisplayMode = DisplayMode
|
||||||
|
|
||||||
savedViewGroup = new FormGroup({})
|
savedViewGroup = new FormGroup({})
|
||||||
|
|
||||||
@ -82,6 +99,9 @@ export class SettingsComponent
|
|||||||
defaultPermsViewGroups: new FormControl(null),
|
defaultPermsViewGroups: new FormControl(null),
|
||||||
defaultPermsEditUsers: new FormControl(null),
|
defaultPermsEditUsers: new FormControl(null),
|
||||||
defaultPermsEditGroups: new FormControl(null),
|
defaultPermsEditGroups: new FormControl(null),
|
||||||
|
documentEditingRemoveInboxTags: new FormControl(null),
|
||||||
|
searchDbOnly: new FormControl(null),
|
||||||
|
searchLink: new FormControl(null),
|
||||||
|
|
||||||
notificationsConsumerNewDocument: new FormControl(null),
|
notificationsConsumerNewDocument: new FormControl(null),
|
||||||
notificationsConsumerSuccess: new FormControl(null),
|
notificationsConsumerSuccess: new FormControl(null),
|
||||||
@ -92,7 +112,11 @@ export class SettingsComponent
|
|||||||
savedViews: this.savedViewGroup,
|
savedViews: this.savedViewGroup,
|
||||||
})
|
})
|
||||||
|
|
||||||
savedViews: PaperlessSavedView[]
|
savedViews: SavedView[]
|
||||||
|
SettingsNavIDs = SettingsNavIDs
|
||||||
|
get displayFields() {
|
||||||
|
return this.settings.allDisplayFields
|
||||||
|
}
|
||||||
|
|
||||||
store: BehaviorSubject<any>
|
store: BehaviorSubject<any>
|
||||||
storeSub: Subscription
|
storeSub: Subscription
|
||||||
@ -101,8 +125,22 @@ export class SettingsComponent
|
|||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
savePending: boolean = false
|
savePending: boolean = false
|
||||||
|
|
||||||
users: PaperlessUser[]
|
users: User[]
|
||||||
groups: PaperlessGroup[]
|
groups: Group[]
|
||||||
|
|
||||||
|
public systemStatus: SystemStatus
|
||||||
|
|
||||||
|
public readonly GlobalSearchType = GlobalSearchType
|
||||||
|
|
||||||
|
get systemStatusHasErrors(): boolean {
|
||||||
|
return (
|
||||||
|
this.systemStatus.database.status === SystemStatusItemStatus.ERROR ||
|
||||||
|
this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR ||
|
||||||
|
this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR ||
|
||||||
|
this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR ||
|
||||||
|
this.systemStatus.tasks.classifier_status === SystemStatusItemStatus.ERROR
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
get computedDateLocale(): string {
|
get computedDateLocale(): string {
|
||||||
return (
|
return (
|
||||||
@ -124,7 +162,9 @@ export class SettingsComponent
|
|||||||
private usersService: UserService,
|
private usersService: UserService,
|
||||||
private groupsService: GroupService,
|
private groupsService: GroupService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
public permissionsService: PermissionsService
|
public permissionsService: PermissionsService,
|
||||||
|
private modalService: NgbModal,
|
||||||
|
private systemStatusService: SystemStatusService
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
this.settings.settingsSaved.subscribe(() => {
|
this.settings.settingsSaved.subscribe(() => {
|
||||||
@ -265,6 +305,11 @@ export class SettingsComponent
|
|||||||
defaultPermsEditGroups: this.settings.get(
|
defaultPermsEditGroups: this.settings.get(
|
||||||
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS
|
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS
|
||||||
),
|
),
|
||||||
|
documentEditingRemoveInboxTags: this.settings.get(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
|
||||||
|
),
|
||||||
|
searchDbOnly: this.settings.get(SETTINGS_KEYS.SEARCH_DB_ONLY),
|
||||||
|
searchLink: this.settings.get(SETTINGS_KEYS.SEARCH_FULL_TYPE),
|
||||||
savedViews: {},
|
savedViews: {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -306,6 +351,9 @@ export class SettingsComponent
|
|||||||
name: view.name,
|
name: view.name,
|
||||||
show_on_dashboard: view.show_on_dashboard,
|
show_on_dashboard: view.show_on_dashboard,
|
||||||
show_in_sidebar: view.show_in_sidebar,
|
show_in_sidebar: view.show_in_sidebar,
|
||||||
|
page_size: view.page_size,
|
||||||
|
display_mode: view.display_mode,
|
||||||
|
display_fields: view.display_fields,
|
||||||
}
|
}
|
||||||
this.savedViewGroup.addControl(
|
this.savedViewGroup.addControl(
|
||||||
view.id.toString(),
|
view.id.toString(),
|
||||||
@ -314,6 +362,9 @@ export class SettingsComponent
|
|||||||
name: new FormControl(null),
|
name: new FormControl(null),
|
||||||
show_on_dashboard: new FormControl(null),
|
show_on_dashboard: new FormControl(null),
|
||||||
show_in_sidebar: new FormControl(null),
|
show_in_sidebar: new FormControl(null),
|
||||||
|
page_size: new FormControl(null),
|
||||||
|
display_mode: new FormControl(null),
|
||||||
|
display_fields: new FormControl([]),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -350,6 +401,12 @@ export class SettingsComponent
|
|||||||
// prevents loss of unsaved changes
|
// prevents loss of unsaved changes
|
||||||
this.settingsForm.patchValue(currentFormValue)
|
this.settingsForm.patchValue(currentFormValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.permissionsService.isAdmin()) {
|
||||||
|
this.systemStatusService.get().subscribe((status) => {
|
||||||
|
this.systemStatus = status
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private emptyGroup(group: FormGroup) {
|
private emptyGroup(group: FormGroup) {
|
||||||
@ -357,12 +414,12 @@ export class SettingsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didnt save
|
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
|
||||||
this.storeSub && this.storeSub.unsubscribe()
|
this.storeSub && this.storeSub.unsubscribe()
|
||||||
this.settings.organizingSidebarSavedViews = false
|
this.settings.organizingSidebarSavedViews = false
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteSavedView(savedView: PaperlessSavedView) {
|
deleteSavedView(savedView: SavedView) {
|
||||||
this.savedViewService.delete(savedView).subscribe(() => {
|
this.savedViewService.delete(savedView).subscribe(() => {
|
||||||
this.savedViewGroup.removeControl(savedView.id.toString())
|
this.savedViewGroup.removeControl(savedView.id.toString())
|
||||||
this.savedViews.splice(this.savedViews.indexOf(savedView), 1)
|
this.savedViews.splice(this.savedViews.indexOf(savedView), 1)
|
||||||
@ -416,7 +473,7 @@ export class SettingsComponent
|
|||||||
)
|
)
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.THEME_COLOR,
|
SETTINGS_KEYS.THEME_COLOR,
|
||||||
this.settingsForm.value.themeColor.toString()
|
this.settingsForm.value.themeColor
|
||||||
)
|
)
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER,
|
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER,
|
||||||
@ -478,6 +535,18 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS,
|
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS,
|
||||||
this.settingsForm.value.defaultPermsEditGroups
|
this.settingsForm.value.defaultPermsEditGroups
|
||||||
)
|
)
|
||||||
|
this.settings.set(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS,
|
||||||
|
this.settingsForm.value.documentEditingRemoveInboxTags
|
||||||
|
)
|
||||||
|
this.settings.set(
|
||||||
|
SETTINGS_KEYS.SEARCH_DB_ONLY,
|
||||||
|
this.settingsForm.value.searchDbOnly
|
||||||
|
)
|
||||||
|
this.settings.set(
|
||||||
|
SETTINGS_KEYS.SEARCH_FULL_TYPE,
|
||||||
|
this.settingsForm.value.searchLink
|
||||||
|
)
|
||||||
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
|
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
|
||||||
this.settings
|
this.settings
|
||||||
.storeSettings()
|
.storeSettings()
|
||||||
@ -486,8 +555,8 @@ export class SettingsComponent
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.store.next(this.settingsForm.value)
|
this.store.next(this.settingsForm.value)
|
||||||
this.documentListViewService.updatePageSize()
|
|
||||||
this.settings.updateAppearanceSettings()
|
this.settings.updateAppearanceSettings()
|
||||||
|
this.settings.initializeDisplayFields()
|
||||||
let savedToast: Toast = {
|
let savedToast: Toast = {
|
||||||
content: $localize`Settings were saved successfully.`,
|
content: $localize`Settings were saved successfully.`,
|
||||||
delay: 5000,
|
delay: 5000,
|
||||||
@ -512,15 +581,11 @@ export class SettingsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get displayLanguageOptions(): LanguageOption[] {
|
get displayLanguageOptions(): LanguageOption[] {
|
||||||
return [{ code: '', name: $localize`Use system language` }].concat(
|
return [systemLanguage].concat(this.settings.getLanguageOptions())
|
||||||
this.settings.getLanguageOptions()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get dateLocaleOptions(): LanguageOption[] {
|
get dateLocaleOptions(): LanguageOption[] {
|
||||||
return [
|
return [systemDateFormat].concat(this.settings.getDateLocaleOptions())
|
||||||
{ code: '', name: $localize`Use date format of display language` },
|
|
||||||
].concat(this.settings.getDateLocaleOptions())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get today() {
|
get today() {
|
||||||
@ -529,7 +594,7 @@ export class SettingsComponent
|
|||||||
|
|
||||||
saveSettings() {
|
saveSettings() {
|
||||||
// only patch views that have actually changed
|
// only patch views that have actually changed
|
||||||
const changed: PaperlessSavedView[] = []
|
const changed: SavedView[] = []
|
||||||
Object.values(this.savedViewGroup.controls)
|
Object.values(this.savedViewGroup.controls)
|
||||||
.filter((g: FormGroup) => !g.pristine)
|
.filter((g: FormGroup) => !g.pristine)
|
||||||
.forEach((group: FormGroup) => {
|
.forEach((group: FormGroup) => {
|
||||||
@ -552,7 +617,21 @@ export class SettingsComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.settingsForm.patchValue(this.store.getValue())
|
||||||
|
}
|
||||||
|
|
||||||
clearThemeColor() {
|
clearThemeColor() {
|
||||||
this.settingsForm.get('themeColor').patchValue('')
|
this.settingsForm.get('themeColor').patchValue('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showSystemStatus() {
|
||||||
|
const modal: NgbModalRef = this.modalService.open(
|
||||||
|
SystemStatusDialogComponent,
|
||||||
|
{
|
||||||
|
size: 'xl',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
modal.componentInstance.status = this.systemStatus
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,26 +1,27 @@
|
|||||||
<pngx-page-header title="File Tasks" i18n-title>
|
<pngx-page-header
|
||||||
|
title="File Tasks"
|
||||||
|
i18n-title
|
||||||
|
info="File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process."
|
||||||
|
i18n-info
|
||||||
|
>
|
||||||
<div class="btn-toolbar col col-md-auto align-items-center">
|
<div class="btn-toolbar col col-md-auto align-items-center">
|
||||||
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
|
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<i-bs name="x"></i-bs> <ng-container i18n>Clear selection</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
|
||||||
</svg> <ng-container i18n>Clear selection</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-primary me-4" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="tasksService.total === 0">
|
<button class="btn btn-sm btn-outline-primary me-4" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="tasksService.total === 0">
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<i-bs name="check2-all"></i-bs> {{dismissButtonText}}
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#check2-all"/>
|
|
||||||
</svg> <ng-container i18n>{{dismissButtonText}}</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
<div class="form-check form-switch mb-0" (click)="toggleAutoRefresh()">
|
<div class="form-check form-switch mb-0">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="autoRefreshSwitch" [attr.checked]="autoRefreshInterval">
|
<input class="form-check-input" type="checkbox" role="switch" id="autoRefreshSwitch" (click)="toggleAutoRefresh()" [attr.checked]="autoRefreshInterval">
|
||||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<ng-container *ngIf="!tasksService.completedFileTasks && tasksService.loading">
|
@if (!tasksService.completedFileTasks && tasksService.loading) {
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||||
<div class="visually-hidden" i18n>Loading...</div>
|
<div class="visually-hidden" i18n>Loading...</div>
|
||||||
</ng-container>
|
}
|
||||||
|
|
||||||
<ng-template let-tasks="tasks" #tasksTemplate>
|
<ng-template let-tasks="tasks" #tasksTemplate>
|
||||||
<table class="table table-striped align-middle border shadow-sm">
|
<table class="table table-striped align-middle border shadow-sm">
|
||||||
@ -28,19 +29,21 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col">
|
<th scope="col">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
<input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length === 0" [(ngModel)]="togggleAll" (click)="toggleAll($event); $event.stopPropagation();">
|
||||||
<label class="form-check-label" for="all-tasks"></label>
|
<label class="form-check-label" for="all-tasks"></label>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" i18n>Name</th>
|
<th scope="col" i18n>Name</th>
|
||||||
<th scope="col" class="d-none d-lg-table-cell" i18n>Created</th>
|
<th scope="col" class="d-none d-lg-table-cell" i18n>Created</th>
|
||||||
<th scope="col" class="d-none d-lg-table-cell" *ngIf="activeTab !== 'started' && activeTab !== 'queued'" i18n>Results</th>
|
@if (activeTab !== 'started' && activeTab !== 'queued') {
|
||||||
|
<th scope="col" class="d-none d-lg-table-cell" i18n>Results</th>
|
||||||
|
}
|
||||||
<th scope="col" class="d-table-cell d-lg-none" i18n>Info</th>
|
<th scope="col" class="d-table-cell d-lg-none" i18n>Info</th>
|
||||||
<th scope="col" i18n>Actions</th>
|
<th scope="col" i18n>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<ng-container *ngFor="let task of tasks | slice: (page-1) * pageSize : page * pageSize">
|
@for (task of tasks | slice: (page-1) * pageSize : page * pageSize; track task) {
|
||||||
<tr (click)="toggleSelected(task, $event); $event.stopPropagation();">
|
<tr (click)="toggleSelected(task, $event); $event.stopPropagation();">
|
||||||
<td>
|
<td>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
@ -50,37 +53,43 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="overflow-auto name-col">{{ task.task_file_name }}</td>
|
<td class="overflow-auto name-col">{{ task.task_file_name }}</td>
|
||||||
<td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td>
|
<td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td>
|
||||||
<td class="d-none d-lg-table-cell" *ngIf="activeTab !== 'started' && activeTab !== 'queued'">
|
@if (activeTab !== 'started' && activeTab !== 'queued') {
|
||||||
<div *ngIf="task.result?.length > 50" class="result" (click)="expandTask(task); $event.stopPropagation();"
|
<td class="d-none d-lg-table-cell">
|
||||||
|
@if (task.result?.length > 50) {
|
||||||
|
<div class="result" (click)="expandTask(task); $event.stopPropagation();"
|
||||||
[ngbPopover]="resultPopover" popoverClass="shadow small mobile" triggers="mouseenter:mouseleave" container="body">
|
[ngbPopover]="resultPopover" popoverClass="shadow small mobile" triggers="mouseenter:mouseleave" container="body">
|
||||||
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result | slice:0:50 }}…</span>
|
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result | slice:0:50 }}…</span>
|
||||||
</div>
|
</div>
|
||||||
<span *ngIf="task.result?.length <= 50" class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result }}</span>
|
}
|
||||||
|
@if (task.result?.length <= 50) {
|
||||||
|
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result }}</span>
|
||||||
|
}
|
||||||
<ng-template #resultPopover>
|
<ng-template #resultPopover>
|
||||||
<pre class="small mb-0">{{ task.result | slice:0:300 }}<ng-container *ngIf="task.result.length > 300">…</ng-container></pre>
|
<pre class="small mb-0">{{ task.result | slice:0:300 }}@if (task.result.length > 300) {
|
||||||
<ng-container *ngIf="task.result?.length > 300"><br/><em>(<ng-container i18n>click for full output</ng-container>)</em></ng-container>
|
…
|
||||||
|
}</pre>
|
||||||
|
@if (task.result?.length > 300) {
|
||||||
|
<br/><em>(<ng-container i18n>click for full output</ng-container>)</em>
|
||||||
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</td>
|
</td>
|
||||||
|
}
|
||||||
<td class="d-lg-none">
|
<td class="d-lg-none">
|
||||||
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();">
|
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();">
|
||||||
<svg fill="currentColor" class="" width="1.2em" height="1.2em" style="vertical-align: text-top;" viewBox="0 0 16 16">
|
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#info-circle" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td scope="row">
|
<td scope="row">
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
|
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<i-bs name="check"></i-bs> <ng-container i18n>Dismiss</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#check"/>
|
|
||||||
</svg> <ng-container i18n>Dismiss</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
<button *ngIf="task.related_document" class="btn btn-sm btn-outline-primary" (click)="dismissAndGo(task); $event.stopPropagation();">
|
@if (task.related_document) {
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<button class="btn btn-sm btn-outline-primary" (click)="dismissAndGo(task); $event.stopPropagation();">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#file-text"/>
|
<i-bs name="file-text"></i-bs> <ng-container i18n>Open Document</ng-container>
|
||||||
</svg> <ng-container i18n>Open Document</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -90,37 +99,54 @@
|
|||||||
<pre #collapse="ngbCollapse" [ngbCollapse]="expandedTask !== task.id" class="small mb-0"><div class="small p-1 p-lg-3 ms-lg-3">{{ task.result }}</div></pre>
|
<pre #collapse="ngbCollapse" [ngbCollapse]="expandedTask !== task.id" class="small mb-0"><div class="small p-1 p-lg-3 ms-lg-3">{{ task.result }}</div></pre>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-container>
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="pb-3 d-sm-flex justify-content-between align-items-center">
|
<div class="pb-3 d-sm-flex justify-content-between align-items-center">
|
||||||
<div class="pb-2 pb-sm-0" i18n *ngIf="tasks.length > 0">{tasks.length, plural, =1 {One {{this.activeTabLocalized}} task} other {{{tasks.length || 0}} total {{this.activeTabLocalized}} tasks}}</div>
|
@if (tasks.length > 0) {
|
||||||
<ngb-pagination *ngIf="tasks.length > pageSize" [(page)]="page" [pageSize]="pageSize" [collectionSize]="tasks.length" maxSize="8" size="sm"></ngb-pagination>
|
<div class="pb-2 pb-sm-0">
|
||||||
|
<ng-container i18n>{tasks.length, plural, =1 {One {{this.activeTabLocalized}} task} other {{{tasks.length || 0}} total {{this.activeTabLocalized}} tasks}}</ng-container>
|
||||||
|
@if (selectedTasks.size > 0) {
|
||||||
|
<ng-container i18n> ({{selectedTasks.size}} selected)</ng-container>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (tasks.length > pageSize) {
|
||||||
|
<ngb-pagination [(page)]="page" [pageSize]="pageSize" [collectionSize]="tasks.length" maxSize="8" size="sm"></ngb-pagination>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-tabs" (hidden)="duringTabChange($event)">
|
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-tabs" (hidden)="duringTabChange($event)">
|
||||||
<li ngbNavItem="failed">
|
<li ngbNavItem="failed">
|
||||||
<a ngbNavLink i18n>Failed<span *ngIf="tasksService.failedFileTasks.length > 0" class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span></a>
|
<a ngbNavLink i18n>Failed@if (tasksService.failedFileTasks.length > 0) {
|
||||||
|
<span class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span>
|
||||||
|
}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.failedFileTasks}"></ng-container>
|
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.failedFileTasks}"></ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
<li ngbNavItem="completed">
|
<li ngbNavItem="completed">
|
||||||
<a ngbNavLink i18n>Complete<span *ngIf="tasksService.completedFileTasks.length > 0" class="badge bg-secondary ms-2">{{tasksService.completedFileTasks.length}}</span></a>
|
<a ngbNavLink i18n>Complete@if (tasksService.completedFileTasks.length > 0) {
|
||||||
|
<span class="badge bg-secondary ms-2">{{tasksService.completedFileTasks.length}}</span>
|
||||||
|
}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.completedFileTasks}"></ng-container>
|
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.completedFileTasks}"></ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
<li ngbNavItem="started">
|
<li ngbNavItem="started">
|
||||||
<a ngbNavLink i18n>Started<span *ngIf="tasksService.startedFileTasks.length > 0" class="badge bg-secondary ms-2">{{tasksService.startedFileTasks.length}}</span></a>
|
<a ngbNavLink i18n>Started@if (tasksService.startedFileTasks.length > 0) {
|
||||||
|
<span class="badge bg-secondary ms-2">{{tasksService.startedFileTasks.length}}</span>
|
||||||
|
}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.startedFileTasks}"></ng-container>
|
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.startedFileTasks}"></ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
<li ngbNavItem="queued">
|
<li ngbNavItem="queued">
|
||||||
<a ngbNavLink i18n>Queued<span *ngIf="tasksService.queuedFileTasks.length > 0" class="badge bg-secondary ms-2">{{tasksService.queuedFileTasks.length}}</span></a>
|
<a ngbNavLink i18n>Queued@if (tasksService.queuedFileTasks.length > 0) {
|
||||||
|
<span class="badge bg-secondary ms-2">{{tasksService.queuedFileTasks.length}}</span>
|
||||||
|
}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.queuedFileTasks}"></ng-container>
|
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.queuedFileTasks}"></ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -28,6 +28,8 @@ import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dial
|
|||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { TasksComponent } from './tasks.component'
|
import { TasksComponent } from './tasks.component'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
|
||||||
const tasks: PaperlessTask[] = [
|
const tasks: PaperlessTask[] = [
|
||||||
{
|
{
|
||||||
@ -138,6 +140,8 @@ describe('TasksComponent', () => {
|
|||||||
NgbModule,
|
NgbModule,
|
||||||
HttpClientTestingModule,
|
HttpClientTestingModule,
|
||||||
RouterTestingModule.withRoutes(routes),
|
RouterTestingModule.withRoutes(routes),
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
FormsModule,
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ export class TasksComponent
|
|||||||
{
|
{
|
||||||
public activeTab: string
|
public activeTab: string
|
||||||
public selectedTasks: Set<number> = new Set()
|
public selectedTasks: Set<number> = new Set()
|
||||||
|
public togggleAll: boolean = false
|
||||||
public expandedTask: number
|
public expandedTask: number
|
||||||
|
|
||||||
public pageSize: number = 25
|
public pageSize: number = 25
|
||||||
@ -46,6 +47,7 @@ export class TasksComponent
|
|||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.tasksService.cancelPending()
|
this.tasksService.cancelPending()
|
||||||
|
clearInterval(this.autoRefreshInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
dismissTask(task: PaperlessTask) {
|
dismissTask(task: PaperlessTask) {
|
||||||
@ -119,6 +121,7 @@ export class TasksComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
|
this.togggleAll = false
|
||||||
this.selectedTasks.clear()
|
this.selectedTasks.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,14 +1,17 @@
|
|||||||
<pngx-page-header title="Users & Groups" i18n-title>
|
<pngx-page-header
|
||||||
|
title="Users & Groups"
|
||||||
|
i18n-title
|
||||||
|
info="Create, delete and edit users and groups."
|
||||||
|
i18n-info
|
||||||
|
infoLink="usage/#users-and-groups"
|
||||||
|
>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<ng-container *ngIf="users">
|
@if (users) {
|
||||||
<h4 class="d-flex">
|
<h4 class="d-flex">
|
||||||
<ng-container i18n>Users</ng-container>
|
<ng-container i18n>Users</ng-container>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
|
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
|
||||||
<svg class="sidebaricon me-1" fill="currentColor">
|
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add User</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
|
|
||||||
</svg>
|
|
||||||
<ng-container i18n>Add User</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
@ -20,42 +23,36 @@
|
|||||||
<div class="col" i18n>Actions</div>
|
<div class="col" i18n>Actions</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
@for (user of users; track user) {
|
||||||
<li *ngFor="let user of users" class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
|
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
|
||||||
<div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div>
|
<div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div>
|
||||||
<div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div>
|
<div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editUser(user)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }">
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editUser(user)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }">
|
||||||
<svg class="buttonicon-sm" fill="currentColor">
|
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#pencil" />
|
|
||||||
</svg> <ng-container i18n>Edit</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }">
|
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }">
|
||||||
<svg class="buttonicon-sm" fill="currentColor">
|
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
|
||||||
</svg> <ng-container i18n>Delete</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</ng-container>
|
}
|
||||||
|
|
||||||
<ng-container *ngIf="groups">
|
@if (groups) {
|
||||||
<h4 class="mt-4 d-flex">
|
<h4 class="mt-4 d-flex">
|
||||||
<ng-container i18n>Groups</ng-container>
|
<ng-container i18n>Groups</ng-container>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
||||||
<svg class="sidebaricon me-1" fill="currentColor">
|
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Group</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
|
|
||||||
</svg>
|
|
||||||
<ng-container i18n>Add Group</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
</h4>
|
</h4>
|
||||||
<ul *ngIf="groups.length > 0" class="list-group">
|
<ul class="list-group">
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col" i18n>Name</div>
|
<div class="col" i18n>Name</div>
|
||||||
@ -64,34 +61,34 @@
|
|||||||
<div class="col" i18n>Actions</div>
|
<div class="col" i18n>Actions</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
@for (group of groups; track group) {
|
||||||
<li *ngFor="let group of groups" class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
||||||
<div class="col"></div>
|
<div class="col"></div>
|
||||||
<div class="col"></div>
|
<div class="col"></div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }">
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }">
|
||||||
<svg class="buttonicon-sm" fill="currentColor">
|
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#pencil" />
|
|
||||||
</svg> <ng-container i18n>Edit</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }">
|
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }">
|
||||||
<svg class="buttonicon-sm" fill="currentColor">
|
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
|
||||||
</svg> <ng-container i18n>Delete</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li *ngIf="groups.length === 0" class="list-group-item" i18n>No groups defined</li>
|
}
|
||||||
|
@if (groups.length === 0) {
|
||||||
|
<li class="list-group-item" i18n>No groups defined</li>
|
||||||
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
</ng-container>
|
@if (!users || !groups) {
|
||||||
|
<div>
|
||||||
<div *ngIf="!users || !groups">
|
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||||
<div class="visually-hidden" i18n>Loading...</div>
|
<div class="visually-hidden" i18n>Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
@ -41,8 +41,9 @@ import { TextComponent } from '../../common/input/text/text.component'
|
|||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { SettingsComponent } from '../settings/settings.component'
|
import { SettingsComponent } from '../settings/settings.component'
|
||||||
import { UsersAndGroupsComponent } from './users-groups.component'
|
import { UsersAndGroupsComponent } from './users-groups.component'
|
||||||
import { PaperlessUser } from 'src/app/data/paperless-user'
|
import { User } from 'src/app/data/user'
|
||||||
import { PaperlessGroup } from 'src/app/data/paperless-group'
|
import { Group } from 'src/app/data/group'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
const users = [
|
const users = [
|
||||||
{ id: 1, username: 'user1', is_superuser: false },
|
{ id: 1, username: 'user1', is_superuser: false },
|
||||||
@ -92,6 +93,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgbAlertModule,
|
NgbAlertModule,
|
||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
fixture = TestBed.createComponent(UsersAndGroupsComponent)
|
fixture = TestBed.createComponent(UsersAndGroupsComponent)
|
||||||
@ -119,7 +121,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
of({
|
of({
|
||||||
all: users.map((a) => a.id),
|
all: users.map((a) => a.id),
|
||||||
count: users.length,
|
count: users.length,
|
||||||
results: (users as PaperlessUser[]).concat([]),
|
results: (users as User[]).concat([]),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -128,7 +130,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
of({
|
of({
|
||||||
all: groups.map((r) => r.id),
|
all: groups.map((r) => r.id),
|
||||||
count: groups.length,
|
count: groups.length,
|
||||||
results: (groups as PaperlessGroup[]).concat([]),
|
results: (groups as Group[]).concat([]),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { Component, OnDestroy, OnInit } from '@angular/core'
|
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { Subject, first, takeUntil } from 'rxjs'
|
import { Subject, first, takeUntil } from 'rxjs'
|
||||||
import { PaperlessGroup } from 'src/app/data/paperless-group'
|
import { Group } from 'src/app/data/group'
|
||||||
import { PaperlessUser } from 'src/app/data/paperless-user'
|
import { User } from 'src/app/data/user'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
import { GroupService } from 'src/app/services/rest/group.service'
|
import { GroupService } from 'src/app/services/rest/group.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
@ -23,8 +23,8 @@ export class UsersAndGroupsComponent
|
|||||||
extends ComponentWithPermissions
|
extends ComponentWithPermissions
|
||||||
implements OnInit, OnDestroy
|
implements OnInit, OnDestroy
|
||||||
{
|
{
|
||||||
users: PaperlessUser[]
|
users: User[]
|
||||||
groups: PaperlessGroup[]
|
groups: Group[]
|
||||||
|
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ export class UsersAndGroupsComponent
|
|||||||
this.unsubscribeNotifier.next(true)
|
this.unsubscribeNotifier.next(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
editUser(user: PaperlessUser = null) {
|
editUser(user: User = null) {
|
||||||
var modal = this.modalService.open(UserEditDialogComponent, {
|
var modal = this.modalService.open(UserEditDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
size: 'xl',
|
size: 'xl',
|
||||||
@ -80,7 +80,7 @@ export class UsersAndGroupsComponent
|
|||||||
modal.componentInstance.object = user
|
modal.componentInstance.object = user
|
||||||
modal.componentInstance.succeeded
|
modal.componentInstance.succeeded
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((newUser: PaperlessUser) => {
|
.subscribe((newUser: User) => {
|
||||||
if (
|
if (
|
||||||
newUser.id === this.settings.currentUser.id &&
|
newUser.id === this.settings.currentUser.id &&
|
||||||
(modal.componentInstance as UserEditDialogComponent).passwordIsSet
|
(modal.componentInstance as UserEditDialogComponent).passwordIsSet
|
||||||
@ -89,7 +89,7 @@ export class UsersAndGroupsComponent
|
|||||||
$localize`Password has been changed, you will be logged out momentarily.`
|
$localize`Password has been changed, you will be logged out momentarily.`
|
||||||
)
|
)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/`
|
window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
|
||||||
}, 2500)
|
}, 2500)
|
||||||
} else {
|
} else {
|
||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
@ -107,7 +107,7 @@ export class UsersAndGroupsComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteUser(user: PaperlessUser) {
|
deleteUser(user: User) {
|
||||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
@ -133,7 +133,7 @@ export class UsersAndGroupsComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
editGroup(group: PaperlessGroup = null) {
|
editGroup(group: Group = null) {
|
||||||
var modal = this.modalService.open(GroupEditDialogComponent, {
|
var modal = this.modalService.open(GroupEditDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
@ -157,7 +157,7 @@ export class UsersAndGroupsComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteGroup(group: PaperlessGroup) {
|
deleteGroup(group: Group) {
|
||||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
|
@ -4,25 +4,30 @@
|
|||||||
(click)="isMenuCollapsed = !isMenuCollapsed">
|
(click)="isMenuCollapsed = !isMenuCollapsed">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<a class="navbar-brand col-auto col-md-3 col-lg-2 me-0 px-3 py-3 order-sm-0" [ngClass]="slimSidebarEnabled ? 'slim' : 'col-auto col-md-3 col-lg-2'" routerLink="/dashboard" tourAnchor="tour.intro">
|
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" class="me-2" fill="currentColor">
|
[ngClass]="{ 'slim': slimSidebarEnabled, 'col-auto col-md-3 col-lg-2 col-xxxl-1' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
|
||||||
<path d="M194.7,0C164.22,70.94,17.64,79.74,64.55,194.06c.58,1.47-10.85,17-18.47,29.9-1.76-6.45-3.81-13.48-3.52-14.07,38.11-45.14-27.26-70.65-30.78-107.58C-4.64,131.62-10.5,182.92,39,212.53c.3,0,2.64,11.14,3.81,16.71a58.55,58.55,0,0,0-2.93,6.45c-1.17,2.93,7.62,2.64,7.62,3.22.88-.29,21.7-36.93,22.28-37.23C187.67,174.72,208.48,68.6,194.7,0ZM134.61,74.75C79.5,124,70.12,160.64,71.88,178.53,53.41,134.85,107.64,86.77,134.61,74.75ZM28.2,145.11c10.55,9.67,28.14,39.28,13.19,56.57C44.91,193.77,46.08,175.89,28.2,145.11Z" transform="translate(0 0)"/>
|
routerLink="/dashboard"
|
||||||
|
tourAnchor="tour.intro">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" height="1.5em" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M194.7,0C164.22,70.94,17.64,79.74,64.55,194.06c.58,1.47-10.85,17-18.47,29.9-1.76-6.45-3.81-13.48-3.52-14.07,38.11-45.14-27.26-70.65-30.78-107.58C-4.64,131.62-10.5,182.92,39,212.53c.3,0,2.64,11.14,3.81,16.71a58.55,58.55,0,0,0-2.93,6.45c-1.17,2.93,7.62,2.64,7.62,3.22.88-.29,21.7-36.93,22.28-37.23C187.67,174.72,208.48,68.6,194.7,0ZM134.61,74.75C79.5,124,70.12,160.64,71.88,178.53,53.41,134.85,107.64,86.77,134.61,74.75ZM28.2,145.11c10.55,9.67,28.14,39.28,13.19,56.57C44.91,193.77,46.08,175.89,28.2,145.11Z"
|
||||||
|
transform="translate(0 0)" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="ms-2" [class.visually-hidden]="slimSidebarEnabled" i18n="app title">Paperless-ngx</span>
|
<div class="ms-2 ms-md-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled">
|
||||||
|
@if (customAppTitle?.length) {
|
||||||
|
<div class="d-flex flex-column align-items-start">
|
||||||
|
<span class="title">{{customAppTitle}}</span>
|
||||||
|
<span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
Paperless-ngx
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="search-form-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1">
|
||||||
<form (ngSubmit)="search()" class="form-inline flex-grow-1">
|
<div class="col-12 col-md-7">
|
||||||
<svg width="1em" height="1em" fill="currentColor">
|
<pngx-global-search></pngx-global-search>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#search"/>
|
</div>
|
||||||
</svg>
|
|
||||||
<input class="form-control form-control-sm" type="text" placeholder="Search documents" aria-label="Search"
|
|
||||||
[formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (keyup)="searchFieldKeyup($event)" (selectItem)="itemSelected($event)" i18n-placeholder>
|
|
||||||
<button type="button" *ngIf="!searchFieldEmpty" class="btn btn-link btn-sm px-0 position-absolute top-0 end-0" (click)="resetSearchField()">
|
|
||||||
<svg fill="currentColor" class="buttonicon-sm me-1">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<ul ngbNav class="order-sm-3">
|
<ul ngbNav class="order-sm-3">
|
||||||
<li ngbDropdown class="nav-item dropdown">
|
<li ngbDropdown class="nav-item dropdown">
|
||||||
@ -30,30 +35,27 @@
|
|||||||
<span class="small me-2 d-none d-sm-inline">
|
<span class="small me-2 d-none d-sm-inline">
|
||||||
{{this.settingsService.displayName}}
|
{{this.settingsService.displayName}}
|
||||||
</span>
|
</span>
|
||||||
<svg width="1.3em" height="1.3em" fill="currentColor">
|
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#person-circle"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow me-2" aria-labelledby="userDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow me-2" aria-labelledby="userDropdown">
|
||||||
<div class="d-sm-none">
|
<div class="d-sm-none">
|
||||||
<p class="small mb-0 px-3 text-muted" i18n>Logged in as {{this.settingsService.displayName}}</p>
|
<p class="small mb-0 px-3 text-muted" i18n>Logged in as {{this.settingsService.displayName}}</p>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
</div>
|
</div>
|
||||||
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }">
|
<button ngbDropdownItem class="nav-link" (click)="editProfile()">
|
||||||
<svg class="sidebaricon me-2" fill="currentColor">
|
<i-bs class="me-2" name="person"></i-bs> <ng-container i18n>My Profile</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#gear"/>
|
</button>
|
||||||
</svg><ng-container i18n>Settings</ng-container>
|
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }">
|
||||||
|
<i-bs class="me-2" name="gear"></i-bs><ng-container i18n>Settings</ng-container>
|
||||||
</a>
|
</a>
|
||||||
<a ngbDropdownItem class="nav-link" href="accounts/logout/" (click)="onLogout()">
|
<a ngbDropdownItem class="nav-link d-flex" href="accounts/logout/" (click)="onLogout()">
|
||||||
<svg class="sidebaricon me-2" fill="currentColor">
|
<i-bs class="me-2" name="door-open"></i-bs><ng-container i18n>Logout</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#door-open"/>
|
|
||||||
</svg><ng-container i18n>Logout</ng-container>
|
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<a ngbDropdownItem class="nav-link" target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com">
|
<a ngbDropdownItem class="nav-link" target="_blank" rel="noopener noreferrer"
|
||||||
<svg class="sidebaricon me-2" fill="currentColor">
|
href="https://docs.paperless-ngx.com">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#question-circle"/>
|
<i-bs class="me-2" name="question-circle"></i-bs><ng-container i18n>Documentation</ng-container>
|
||||||
</svg><ng-container i18n>Documentation</ng-container>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@ -62,189 +64,232 @@
|
|||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse" [ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating" [ngbCollapse]="isMenuCollapsed">
|
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
||||||
|
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating"
|
||||||
|
[ngbCollapse]="isMenuCollapsed">
|
||||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
|
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
|
||||||
<svg class="sidebaricon-sm" fill="currentColor">
|
@if (slimSidebarEnabled) {
|
||||||
<use *ngIf="slimSidebarEnabled" xlink:href="assets/bootstrap-icons.svg#chevron-double-right"/>
|
<i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
|
||||||
<use *ngIf="!slimSidebarEnabled" xlink:href="assets/bootstrap-icons.svg#chevron-double-left"/>
|
} @else {
|
||||||
</svg>
|
<i-bs width="0.9em" height="0.9em" name="chevron-double-left"></i-bs>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
<div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around">
|
<div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around">
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item">
|
<li class="nav-item app-link">
|
||||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#house"/>
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
</svg><span> <ng-container i18n>Dashboard</ng-container></span>
|
<i-bs class="me-1" name="house"></i-bs><span> <ng-container i18n>Dashboard</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
<a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#files"/>
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
</svg><span> <ng-container i18n>Documents</ng-container></span>
|
<i-bs class="me-1" name="files"></i-bs><span> <ng-container i18n>Documents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
|
||||||
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted" *ngIf='savedViewService.loading || savedViewService.sidebarViews?.length > 0'>
|
|
||||||
<span i18n>Saved views</span>
|
|
||||||
<div *ngIf="savedViewService.loading" class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
|
||||||
</h6>
|
|
||||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
|
||||||
<li class="nav-item w-100" *ngFor="let view of savedViewService.sidebarViews"
|
|
||||||
cdkDrag
|
|
||||||
[cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
|
|
||||||
cdkDragPreviewContainer="parent"
|
|
||||||
cdkDragPreviewClass="navItemDrag"
|
|
||||||
(cdkDragStarted)="onDragStart($event)"
|
|
||||||
(cdkDragEnded)="onDragEnd($event)">
|
|
||||||
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#funnel"/>
|
|
||||||
</svg><span> {{view.name}}</span>
|
|
||||||
</a>
|
|
||||||
<div *ngIf="settingsService.organizingSidebarSavedViews" class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
|
||||||
<svg class="sidebaricon text-muted" fill="currentColor">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#grip-vertical"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||||
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted" *ngIf='openDocuments.length > 0'>
|
@if (savedViewService.loading || savedViewService.sidebarViews?.length > 0) {
|
||||||
|
<h6 class="sidebar-heading px-3 text-muted">
|
||||||
|
<span i18n>Saved views</span>
|
||||||
|
@if (savedViewService.loading) {
|
||||||
|
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
||||||
|
}
|
||||||
|
</h6>
|
||||||
|
}
|
||||||
|
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||||
|
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||||
|
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
|
||||||
|
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||||
|
(cdkDragEnded)="onDragEnd($event)">
|
||||||
|
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}"
|
||||||
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
||||||
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
|
popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="funnel"></i-bs><span> {{view.name}}</span>
|
||||||
|
</a>
|
||||||
|
@if (settingsService.organizingSidebarSavedViews) {
|
||||||
|
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
||||||
|
<i-bs name="grip-vertical"></i-bs>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
|
@if (openDocuments.length > 0) {
|
||||||
|
<h6 class="sidebar-heading px-3 text-muted">
|
||||||
<span i18n>Open documents</span>
|
<span i18n>Open documents</span>
|
||||||
</h6>
|
</h6>
|
||||||
|
}
|
||||||
<ul class="nav flex-column mb-2">
|
<ul class="nav flex-column mb-2">
|
||||||
<li class="nav-item w-100" *ngFor='let d of openDocuments'>
|
@for (d of openDocuments; track d) {
|
||||||
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<li class="nav-item w-100 app-link">
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#file-text"/>
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
|
||||||
</svg><span> {{d.title | documentTitle}}</span>
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
|
popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="file-text"></i-bs><span> {{d.title | documentTitle}}</span>
|
||||||
<span class="close" (click)="closeDocument(d); $event.preventDefault()">
|
<span class="close" (click)="closeDocument(d); $event.preventDefault()">
|
||||||
<svg fill="currentColor" class="toolbaricon">
|
<i-bs name="x"></i-bs>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item w-100" *ngIf="openDocuments.length >= 1">
|
}
|
||||||
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()" ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
@if (openDocuments.length >= 1) {
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<li class="nav-item w-100 app-link">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()"
|
||||||
</svg><span> <ng-container i18n>Close all</ng-container></span>
|
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="x"></i-bs><span> <ng-container i18n>Close all</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</ng-container>
|
</div>
|
||||||
|
|
||||||
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted">
|
<div class="nav-group mt-3 mb-1">
|
||||||
|
<h6 class="sidebar-heading px-3 text-muted">
|
||||||
<span i18n>Manage</span>
|
<span i18n>Manage</span>
|
||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2">
|
<ul class="nav flex-column mb-2">
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
<li class="nav-item app-link"
|
||||||
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#person"/>
|
ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
</svg><span> <ng-container i18n>Correspondents</ng-container></span>
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="person"></i-bs><span> <ng-container i18n>Correspondents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }" tourAnchor="tour.tags">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"
|
||||||
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
tourAnchor="tour.tags">
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#tags"/>
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
</svg><span> <ng-container i18n>Tags</ng-container></span>
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="tags"></i-bs><span> <ng-container i18n>Tags</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
<li class="nav-item app-link"
|
||||||
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#hash"/>
|
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
</svg><span> <ng-container i18n>Document Types</ng-container></span>
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="hash"></i-bs><span> <ng-container i18n>Document Types</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||||
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#folder"/>
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
</svg><span> <ng-container i18n>Storage Paths</ng-container></span>
|
<i-bs class="me-1" name="folder"></i-bs><span> <ng-container i18n>Storage Paths</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||||
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#ui-radios"/>
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
</svg><span> <ng-container i18n>Custom Fields</ng-container></span>
|
<i-bs class="me-1" name="ui-radios"></i-bs><span> <ng-container i18n>Custom Fields</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ConsumptionTemplate }" tourAnchor="tour.consumption-templates">
|
<li class="nav-item app-link"
|
||||||
<a class="nav-link" routerLink="templates" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Consumption templates" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
tourAnchor="tour.workflows">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#file-earmark-ruled"/>
|
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||||
</svg><span> <ng-container i18n>Templates</ng-container></span>
|
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="boxes"></i-bs><span> <ng-container i18n>Workflows</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }" tourAnchor="tour.mail">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
tourAnchor="tour.mail">
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#envelope"/>
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
</svg><span> <ng-container i18n>Mail</ng-container></span>
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="envelope"></i-bs><span> <ng-container i18n>Mail</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted">
|
<div class="nav-group mt-auto mb-1">
|
||||||
|
<h6 class="sidebar-heading px-3 pt-4 text-muted">
|
||||||
<span i18n>Administration</span>
|
<span i18n>Administration</span>
|
||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2">
|
<ul class="nav flex-column mb-2">
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }" tourAnchor="tour.settings">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
||||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
tourAnchor="tour.settings">
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#gear"/>
|
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
</svg><span> <ng-container i18n>Settings</ng-container></span>
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="gear"></i-bs><span> <ng-container i18n>Settings</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
||||||
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#people"/>
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
</svg><span> <ng-container i18n>Users & Groups</ng-container></span>
|
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span> <ng-container i18n>Configuration</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }" tourAnchor="tour.file-tasks">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
||||||
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()" ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<span *ngIf="tasksService.failedFileTasks.length > 0 && slimSidebarEnabled" class="badge bg-danger position-absolute top-0 end-0">{{tasksService.failedFileTasks.length}}</span>
|
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#list-task"/>
|
<i-bs class="me-1" name="people"></i-bs><span> <ng-container i18n>Users & Groups</ng-container></span>
|
||||||
</svg><span> <ng-container i18n>File Tasks<span *ngIf="tasksService.failedFileTasks.length > 0"><span class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span></span></ng-container></span>
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
|
<li class="nav-item app-link"
|
||||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
tourAnchor="tour.file-tasks">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#text-left"/>
|
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
||||||
</svg><span> <ng-container i18n>Logs</ng-container></span>
|
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="list-task"></i-bs><span> <ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
|
||||||
|
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
|
||||||
|
}</span>
|
||||||
|
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
|
||||||
|
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
@if (permissionsService.isAdmin()) {
|
||||||
|
<li class="nav-item app-link">
|
||||||
|
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
||||||
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="text-left"></i-bs><span> <ng-container i18n>Logs</ng-container></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||||
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none" target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#question-circle"/>
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
</svg><span class="ms-1"> <ng-container i18n>Documentation</ng-container></span>
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1"> <ng-container i18n>Documentation</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||||
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
|
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
|
||||||
<div class="me-3">
|
<div class="me-3">
|
||||||
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer" href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||||
|
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
||||||
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
{{ versionString }}
|
{{ versionString }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="!settingsService.updateCheckingIsSet || appRemoteVersion" class="version-check">
|
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
|
||||||
|
<div class="version-check">
|
||||||
<ng-template #updateAvailablePopContent>
|
<ng-template #updateAvailablePopContent>
|
||||||
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is available.</ng-container><br/><ng-container i18n>Click to view.</ng-container></span>
|
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
|
||||||
|
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template #updateCheckingNotEnabledPopContent>
|
<ng-template #updateCheckingNotEnabledPopContent>
|
||||||
<p class="small mb-2">
|
<p class="small mb-2">
|
||||||
@ -260,31 +305,36 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-container *ngIf="settingsService.updateCheckingIsSet; else updateCheckNotSet">
|
@if (settingsService.updateCheckingIsSet) {
|
||||||
<a *ngIf="appRemoteVersion.update_available" class="small text-decoration-none" target="_blank" rel="noopener noreferrer" href="https://github.com/paperless-ngx/paperless-ngx/releases"
|
@if (appRemoteVersion.update_available) {
|
||||||
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave" container="body">
|
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||||
<svg fill="currentColor" class="me-1" width="1.2em" height="1.2em" style="vertical-align: text-top;" viewBox="0 0 16 16">
|
href="https://github.com/paperless-ngx/paperless-ngx/releases"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#info-circle" />
|
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
|
||||||
</svg>
|
container="body">
|
||||||
<ng-container *ngIf="appRemoteVersion?.update_available" i18n>Update available</ng-container>
|
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||||
|
@if (appRemoteVersion?.update_available) {
|
||||||
|
<ng-container i18n>Update available</ng-container>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
</ng-container>
|
}
|
||||||
<ng-template #updateCheckNotSet>
|
} @else {
|
||||||
<a class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
|
<a class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
|
||||||
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter" container="body">
|
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
|
||||||
<svg fill="currentColor" class="me-1" width="1.2em" height="1.2em" style="vertical-align: text-top;" viewBox="0 0 16 16">
|
container="body">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#info-circle" />
|
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||||
</svg>
|
|
||||||
</a>
|
</a>
|
||||||
</ng-template>
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main role="main" class="ms-sm-auto px-md-4" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
|
<main role="main" class="ms-sm-auto px-md-4"
|
||||||
|
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
@ -18,6 +18,10 @@
|
|||||||
height: 0.8em;
|
height: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-group:not(:has(.app-link)) .sidebar-heading {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
// These come from the col-* classes for non-slim sidebar, needed for animation
|
// These come from the col-* classes for non-slim sidebar, needed for animation
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
max-width: 25%;
|
max-width: 25%;
|
||||||
@ -152,9 +156,9 @@ main {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebaricon {
|
i-bs {
|
||||||
margin-right: 4px;
|
position: relative;
|
||||||
color: inherit;
|
top: -1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,11 +190,11 @@ main {
|
|||||||
width: 1.8rem;
|
width: 1.8rem;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
svg {
|
i-bs {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover svg {
|
&:hover i-bs {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -205,7 +209,7 @@ main {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
i-bs {
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -217,9 +221,16 @@ main {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
padding-top: 0.75rem;
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|
||||||
|
.flex-column {
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.byline {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
letter-spacing: 0.1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
@ -241,58 +252,11 @@ main {
|
|||||||
.navbar .dropdown-menu {
|
.navbar .dropdown-menu {
|
||||||
font-size: 0.875rem; // body size
|
font-size: 0.875rem; // body size
|
||||||
|
|
||||||
a svg {
|
a i-bs {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar .search-form-container {
|
|
||||||
max-width: 550px;
|
|
||||||
|
|
||||||
form {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
> svg {
|
|
||||||
position: absolute;
|
|
||||||
left: 0.6rem;
|
|
||||||
top: 0.5rem;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
&:focus-within {
|
|
||||||
form > svg {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control::placeholder {
|
|
||||||
color: rgba(255, 255, 255, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
background-color: rgba(0, 0, 0, 0.15);
|
|
||||||
padding-left: 1.8rem;
|
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
|
||||||
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
|
|
||||||
max-width: 600px;
|
|
||||||
min-width: 300px; // 1/2 max
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
background-color: rgba(0, 0, 0, 0.3);
|
|
||||||
color: var(--bs-light);
|
|
||||||
flex-grow: 1;
|
|
||||||
padding-left: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-check {
|
.version-check {
|
||||||
animation: pulse 2s ease-in-out 0s 1;
|
animation: pulse 2s ease-in-out 0s 1;
|
||||||
}
|
}
|
||||||
@ -312,10 +276,10 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item .position-absolute {
|
.nav-item > .position-absolute {
|
||||||
cursor: move;
|
cursor: move;
|
||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep .navItemDrag .position-absolute svg {
|
::ng-deep .navItemDrag .position-absolute i-bs {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
@ -9,29 +9,34 @@ import {
|
|||||||
fakeAsync,
|
fakeAsync,
|
||||||
tick,
|
tick,
|
||||||
} from '@angular/core/testing'
|
} from '@angular/core/testing'
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { BrowserModule } from '@angular/platform-browser'
|
import { BrowserModule } from '@angular/platform-browser'
|
||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
|
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import {
|
||||||
|
DjangoMessageLevel,
|
||||||
|
DjangoMessagesService,
|
||||||
|
} from 'src/app/services/django-messages.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { SearchService } from 'src/app/services/rest/search.service'
|
import { SearchService } from 'src/app/services/rest/search.service'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
|
||||||
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
|
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'
|
import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'
|
||||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||||
|
|
||||||
const saved_views = [
|
const saved_views = [
|
||||||
{
|
{
|
||||||
@ -81,15 +86,19 @@ describe('AppFrameComponent', () => {
|
|||||||
let permissionsService: PermissionsService
|
let permissionsService: PermissionsService
|
||||||
let remoteVersionService: RemoteVersionService
|
let remoteVersionService: RemoteVersionService
|
||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
|
let messagesService: DjangoMessagesService
|
||||||
let openDocumentsService: OpenDocumentsService
|
let openDocumentsService: OpenDocumentsService
|
||||||
let searchService: SearchService
|
|
||||||
let documentListViewService: DocumentListViewService
|
|
||||||
let router: Router
|
let router: Router
|
||||||
let savedViewSpy
|
let savedViewSpy
|
||||||
|
let modalService: NgbModal
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
declarations: [AppFrameComponent, IfPermissionsDirective],
|
declarations: [
|
||||||
|
AppFrameComponent,
|
||||||
|
IfPermissionsDirective,
|
||||||
|
GlobalSearchComponent,
|
||||||
|
],
|
||||||
imports: [
|
imports: [
|
||||||
HttpClientTestingModule,
|
HttpClientTestingModule,
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
@ -98,6 +107,8 @@ describe('AppFrameComponent', () => {
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
|
NgbModalModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
SettingsService,
|
SettingsService,
|
||||||
@ -118,8 +129,10 @@ describe('AppFrameComponent', () => {
|
|||||||
RemoteVersionService,
|
RemoteVersionService,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
ToastService,
|
ToastService,
|
||||||
|
DjangoMessagesService,
|
||||||
OpenDocumentsService,
|
OpenDocumentsService,
|
||||||
SearchService,
|
SearchService,
|
||||||
|
NgbModal,
|
||||||
{
|
{
|
||||||
provide: ActivatedRoute,
|
provide: ActivatedRoute,
|
||||||
useValue: {
|
useValue: {
|
||||||
@ -145,9 +158,9 @@ describe('AppFrameComponent', () => {
|
|||||||
permissionsService = TestBed.inject(PermissionsService)
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
remoteVersionService = TestBed.inject(RemoteVersionService)
|
remoteVersionService = TestBed.inject(RemoteVersionService)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
|
messagesService = TestBed.inject(DjangoMessagesService)
|
||||||
openDocumentsService = TestBed.inject(OpenDocumentsService)
|
openDocumentsService = TestBed.inject(OpenDocumentsService)
|
||||||
searchService = TestBed.inject(SearchService)
|
modalService = TestBed.inject(NgbModal)
|
||||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
|
|
||||||
jest
|
jest
|
||||||
@ -243,7 +256,7 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support collapsable menu', () => {
|
it('should support collapsible menu', () => {
|
||||||
const button: HTMLButtonElement = (
|
const button: HTMLButtonElement = (
|
||||||
fixture.nativeElement as HTMLDivElement
|
fixture.nativeElement as HTMLDivElement
|
||||||
).querySelector('button[data-toggle=collapse]')
|
).querySelector('button[data-toggle=collapse]')
|
||||||
@ -282,47 +295,6 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(component.canDeactivate()).toBeFalsy()
|
expect(component.canDeactivate()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call autocomplete endpoint on input', fakeAsync(() => {
|
|
||||||
const autocompleteSpy = jest.spyOn(searchService, 'autocomplete')
|
|
||||||
component.searchAutoComplete(of('hello')).subscribe()
|
|
||||||
tick(250)
|
|
||||||
expect(autocompleteSpy).toHaveBeenCalled()
|
|
||||||
|
|
||||||
component.searchAutoComplete(of('hello world 1')).subscribe()
|
|
||||||
tick(250)
|
|
||||||
expect(autocompleteSpy).toHaveBeenCalled()
|
|
||||||
}))
|
|
||||||
|
|
||||||
it('should support reset search field', () => {
|
|
||||||
const resetSpy = jest.spyOn(component, 'resetSearchField')
|
|
||||||
const input = (fixture.nativeElement as HTMLDivElement).querySelector(
|
|
||||||
'input'
|
|
||||||
) as HTMLInputElement
|
|
||||||
input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }))
|
|
||||||
expect(resetSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support choosing a search item', () => {
|
|
||||||
expect(component.searchField.value).toEqual('')
|
|
||||||
component.itemSelected({ item: 'hello', preventDefault: () => true })
|
|
||||||
expect(component.searchField.value).toEqual('hello ')
|
|
||||||
component.itemSelected({ item: 'world', preventDefault: () => true })
|
|
||||||
expect(component.searchField.value).toEqual('hello world ')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should navigate via quickFilter on search', () => {
|
|
||||||
const str = 'hello world '
|
|
||||||
component.searchField.patchValue(str)
|
|
||||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
|
||||||
component.search()
|
|
||||||
expect(qfSpy).toHaveBeenCalledWith([
|
|
||||||
{
|
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
|
||||||
value: str.trim(),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should disable global dropzone on start drag + drop, re-enable after', () => {
|
it('should disable global dropzone on start drag + drop, re-enable after', () => {
|
||||||
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
|
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
|
||||||
component.onDragStart(null)
|
component.onDragStart(null)
|
||||||
@ -336,7 +308,7 @@ describe('AppFrameComponent', () => {
|
|||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||||
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
|
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
|
||||||
PaperlessSavedView[]
|
SavedView[]
|
||||||
>)
|
>)
|
||||||
expect(settingsSpy).toHaveBeenCalledWith([
|
expect(settingsSpy).toHaveBeenCalledWith([
|
||||||
saved_views[2],
|
saved_views[2],
|
||||||
@ -359,8 +331,31 @@ describe('AppFrameComponent', () => {
|
|||||||
.spyOn(settingsService, 'storeSettings')
|
.spyOn(settingsService, 'storeSettings')
|
||||||
.mockReturnValue(throwError(() => new Error('unable to save')))
|
.mockReturnValue(throwError(() => new Error('unable to save')))
|
||||||
component.onDrop({ previousIndex: 0, currentIndex: 2 } as CdkDragDrop<
|
component.onDrop({ previousIndex: 0, currentIndex: 2 } as CdkDragDrop<
|
||||||
PaperlessSavedView[]
|
SavedView[]
|
||||||
>)
|
>)
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should support edit profile', () => {
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
component.editProfile()
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(ProfileEditDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show toasts for django messages', () => {
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
jest.spyOn(messagesService, 'get').mockReturnValue([
|
||||||
|
{ level: DjangoMessageLevel.WARNING, message: 'Test warning' },
|
||||||
|
{ level: DjangoMessageLevel.ERROR, message: 'Test error' },
|
||||||
|
{ level: DjangoMessageLevel.SUCCESS, message: 'Test success' },
|
||||||
|
{ level: DjangoMessageLevel.INFO, message: 'Test info' },
|
||||||
|
{ level: DjangoMessageLevel.DEBUG, message: 'Test debug' },
|
||||||
|
])
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
|
||||||
|
expect(toastInfoSpy).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -1,22 +1,16 @@
|
|||||||
import { Component, HostListener, OnInit } from '@angular/core'
|
import { Component, HostListener, OnInit } from '@angular/core'
|
||||||
import { FormControl } from '@angular/forms'
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { from, Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import {
|
import { first } from 'rxjs/operators'
|
||||||
debounceTime,
|
import { Document } from 'src/app/data/document'
|
||||||
distinctUntilChanged,
|
|
||||||
map,
|
|
||||||
switchMap,
|
|
||||||
first,
|
|
||||||
} from 'rxjs/operators'
|
|
||||||
import { PaperlessDocument } from 'src/app/data/paperless-document'
|
|
||||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||||
|
import {
|
||||||
|
DjangoMessageLevel,
|
||||||
|
DjangoMessagesService,
|
||||||
|
} from 'src/app/services/django-messages.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { SearchService } from 'src/app/services/rest/search.service'
|
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
|
||||||
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
|
|
||||||
import {
|
import {
|
||||||
RemoteVersionService,
|
RemoteVersionService,
|
||||||
AppRemoteVersion,
|
AppRemoteVersion,
|
||||||
@ -24,7 +18,7 @@ import {
|
|||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
import {
|
import {
|
||||||
@ -32,13 +26,16 @@ import {
|
|||||||
PermissionsService,
|
PermissionsService,
|
||||||
PermissionType,
|
PermissionType,
|
||||||
} from 'src/app/services/permissions.service'
|
} from 'src/app/services/permissions.service'
|
||||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import {
|
import {
|
||||||
CdkDragStart,
|
CdkDragStart,
|
||||||
CdkDragEnd,
|
CdkDragEnd,
|
||||||
CdkDragDrop,
|
CdkDragDrop,
|
||||||
moveItemInArray,
|
moveItemInArray,
|
||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
|
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-app-frame',
|
selector: 'pngx-app-frame',
|
||||||
@ -56,20 +53,18 @@ export class AppFrameComponent
|
|||||||
|
|
||||||
slimSidebarAnimating: boolean = false
|
slimSidebarAnimating: boolean = false
|
||||||
|
|
||||||
searchField = new FormControl('')
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public router: Router,
|
public router: Router,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
private openDocumentsService: OpenDocumentsService,
|
private openDocumentsService: OpenDocumentsService,
|
||||||
private searchService: SearchService,
|
|
||||||
public savedViewService: SavedViewService,
|
public savedViewService: SavedViewService,
|
||||||
private remoteVersionService: RemoteVersionService,
|
private remoteVersionService: RemoteVersionService,
|
||||||
private list: DocumentListViewService,
|
|
||||||
public settingsService: SettingsService,
|
public settingsService: SettingsService,
|
||||||
public tasksService: TasksService,
|
public tasksService: TasksService,
|
||||||
private readonly toastService: ToastService,
|
private readonly toastService: ToastService,
|
||||||
permissionsService: PermissionsService
|
private modalService: NgbModal,
|
||||||
|
public permissionsService: PermissionsService,
|
||||||
|
private djangoMessagesService: DjangoMessagesService
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
@ -88,6 +83,20 @@ export class AppFrameComponent
|
|||||||
this.checkForUpdates()
|
this.checkForUpdates()
|
||||||
}
|
}
|
||||||
this.tasksService.reload()
|
this.tasksService.reload()
|
||||||
|
|
||||||
|
this.djangoMessagesService.get().forEach((message) => {
|
||||||
|
switch (message.level) {
|
||||||
|
case DjangoMessageLevel.ERROR:
|
||||||
|
case DjangoMessageLevel.WARNING:
|
||||||
|
this.toastService.showError(message.message)
|
||||||
|
break
|
||||||
|
case DjangoMessageLevel.SUCCESS:
|
||||||
|
case DjangoMessageLevel.INFO:
|
||||||
|
case DjangoMessageLevel.DEBUG:
|
||||||
|
this.toastService.showInfo(message.message)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSlimSidebar(): void {
|
toggleSlimSidebar(): void {
|
||||||
@ -98,6 +107,10 @@ export class AppFrameComponent
|
|||||||
}, 200) // slightly longer than css animation for slim sidebar
|
}, 200) // slightly longer than css animation for slim sidebar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get customAppTitle(): string {
|
||||||
|
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
||||||
|
}
|
||||||
|
|
||||||
get slimSidebarEnabled(): boolean {
|
get slimSidebarEnabled(): boolean {
|
||||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||||
}
|
}
|
||||||
@ -121,7 +134,14 @@ export class AppFrameComponent
|
|||||||
this.isMenuCollapsed = true
|
this.isMenuCollapsed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
get openDocuments(): PaperlessDocument[] {
|
editProfile() {
|
||||||
|
this.modalService.open(ProfileEditDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
this.closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
get openDocuments(): Document[] {
|
||||||
return this.openDocumentsService.getOpenDocuments()
|
return this.openDocumentsService.getOpenDocuments()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,60 +150,7 @@ export class AppFrameComponent
|
|||||||
return !this.openDocumentsService.hasDirty()
|
return !this.openDocumentsService.hasDirty()
|
||||||
}
|
}
|
||||||
|
|
||||||
get searchFieldEmpty(): boolean {
|
closeDocument(d: Document) {
|
||||||
return this.searchField.value.trim().length == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
resetSearchField() {
|
|
||||||
this.searchField.reset('')
|
|
||||||
}
|
|
||||||
|
|
||||||
searchFieldKeyup(event: KeyboardEvent) {
|
|
||||||
if (event.key == 'Escape') {
|
|
||||||
this.resetSearchField()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
searchAutoComplete = (text$: Observable<string>) =>
|
|
||||||
text$.pipe(
|
|
||||||
debounceTime(200),
|
|
||||||
distinctUntilChanged(),
|
|
||||||
map((term) => {
|
|
||||||
if (term.lastIndexOf(' ') != -1) {
|
|
||||||
return term.substring(term.lastIndexOf(' ') + 1)
|
|
||||||
} else {
|
|
||||||
return term
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
switchMap((term) =>
|
|
||||||
term.length < 2 ? from([[]]) : this.searchService.autocomplete(term)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
itemSelected(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
let currentSearch: string = this.searchField.value
|
|
||||||
let lastSpaceIndex = currentSearch.lastIndexOf(' ')
|
|
||||||
if (lastSpaceIndex != -1) {
|
|
||||||
currentSearch = currentSearch.substring(0, lastSpaceIndex + 1)
|
|
||||||
currentSearch += event.item + ' '
|
|
||||||
} else {
|
|
||||||
currentSearch = event.item + ' '
|
|
||||||
}
|
|
||||||
this.searchField.patchValue(currentSearch)
|
|
||||||
}
|
|
||||||
|
|
||||||
search() {
|
|
||||||
this.closeMenu()
|
|
||||||
this.list.quickFilter([
|
|
||||||
{
|
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
|
||||||
value: (this.searchField.value as string).trim(),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
closeDocument(d: PaperlessDocument) {
|
|
||||||
this.openDocumentsService
|
this.openDocumentsService
|
||||||
.closeDocument(d)
|
.closeDocument(d)
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
@ -233,7 +200,7 @@ export class AppFrameComponent
|
|||||||
this.settingsService.globalDropzoneEnabled = true
|
this.settingsService.globalDropzoneEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
onDrop(event: CdkDragDrop<PaperlessSavedView[]>) {
|
onDrop(event: CdkDragDrop<SavedView[]>) {
|
||||||
const sidebarViews = this.savedViewService.sidebarViews.concat([])
|
const sidebarViews = this.savedViewService.sidebarViews.concat([])
|
||||||
moveItemInArray(sidebarViews, event.previousIndex, event.currentIndex)
|
moveItemInArray(sidebarViews, event.previousIndex, event.currentIndex)
|
||||||
|
|
||||||
|
@ -0,0 +1,174 @@
|
|||||||
|
|
||||||
|
<div ngbDropdown #resultsDropdown="ngbDropdown" (openChange)="onDropdownOpenChange">
|
||||||
|
<form class="form-inline position-relative">
|
||||||
|
<i-bs width="1em" height="1em" name="search"></i-bs>
|
||||||
|
<div class="input-group">
|
||||||
|
<div class="form-control form-control-sm">
|
||||||
|
<input class="bg-transparent border-0 w-100 h-100" #searchInput type="text" name="query"
|
||||||
|
placeholder="Search" aria-label="Search" i18n-placeholder
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
[(ngModel)]="query"
|
||||||
|
(ngModelChange)="this.queryDebounce.next($event)"
|
||||||
|
(keydown)="searchInputKeyDown($event)"
|
||||||
|
ngbDropdownAnchor>
|
||||||
|
<div class="position-absolute top-50 end-0 translate-middle">
|
||||||
|
@if (loading) {
|
||||||
|
<div class="spinner-border spinner-border-sm text-muted mt-1"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (query) {
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="runFullSearch()">
|
||||||
|
@if (useAdvancedForFullSearch) {
|
||||||
|
<ng-container i18n>Advanced search</ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container i18n>Search</ng-container>
|
||||||
|
}
|
||||||
|
<i-bs width="1em" height="1em" name="arrow-right-short"></i-bs>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ng-template #resultItemTemplate let-item="item" let-nameProp="nameProp" let-type="type" let-icon="icon" let-date="date">
|
||||||
|
<div #resultItem ngbDropdownItem class="py-2 d-flex align-items-center focus-ring border-0 cursor-pointer" tabindex="-1"
|
||||||
|
(click)="primaryAction(type, item, $event)"
|
||||||
|
(mouseenter)="onItemHover($event)">
|
||||||
|
<i-bs width="1.2em" height="1.2em" name="{{icon}}" class="me-2 text-muted"></i-bs>
|
||||||
|
<div class="text-truncate">
|
||||||
|
{{item[nameProp]}}
|
||||||
|
@if (date) {
|
||||||
|
<small class="small text-muted">{{date | customDate}}</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="btn-group ms-auto">
|
||||||
|
<button #primaryButton type="button" class="btn btn-sm btn-outline-primary d-flex"
|
||||||
|
(click)="primaryAction(type, item, $event); $event.stopImmediatePropagation()"
|
||||||
|
(keydown)="onButtonKeyDown($event)"
|
||||||
|
[disabled]="disablePrimaryButton(type, item)"
|
||||||
|
(mouseenter)="onButtonHover($event)">
|
||||||
|
@if (type === DataType.Document) {
|
||||||
|
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
||||||
|
<span> <ng-container i18n>Open</ng-container></span>
|
||||||
|
} @else if (type === DataType.SavedView) {
|
||||||
|
<i-bs width="1em" height="1em" name="eye"></i-bs>
|
||||||
|
<span> <ng-container i18n>Open</ng-container></span>
|
||||||
|
} @else if (type === DataType.Workflow || type === DataType.CustomField || type === DataType.Group || type === DataType.User || type === DataType.MailAccount || type === DataType.MailRule) {
|
||||||
|
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
||||||
|
<span> <ng-container i18n>Edit</ng-container></span>
|
||||||
|
} @else {
|
||||||
|
<i-bs width="1em" height="1em" name="filter"></i-bs>
|
||||||
|
<span> <ng-container i18n>Filter documents</ng-container></span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
@if (type !== DataType.SavedView && type !== DataType.Workflow && type !== DataType.CustomField && type !== DataType.Group && type !== DataType.User && type !== DataType.MailAccount && type !== DataType.MailRule) {
|
||||||
|
<button #secondaryButton type="button" class="btn btn-sm btn-outline-primary d-flex"
|
||||||
|
(click)="secondaryAction(type, item, $event); $event.stopImmediatePropagation()"
|
||||||
|
(keydown)="onButtonKeyDown($event)"
|
||||||
|
[disabled]="disableSecondaryButton(type, item)"
|
||||||
|
(mouseenter)="onButtonHover($event)">
|
||||||
|
@if (type === DataType.Document) {
|
||||||
|
<i-bs width="1em" height="1em" name="download"></i-bs>
|
||||||
|
<span> <ng-container i18n>Download</ng-container></span>
|
||||||
|
} @else {
|
||||||
|
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
||||||
|
<span> <ng-container i18n>Edit</ng-container></span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg">
|
||||||
|
<div (keydown)="dropdownKeyDown($event)">
|
||||||
|
@if (searchResults?.total === 0) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
|
||||||
|
} @else {
|
||||||
|
@if (searchResults?.documents.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
|
||||||
|
@for (document of searchResults.documents; track document.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.added}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (searchResults?.saved_views.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
|
||||||
|
@for (saved_view of searchResults.saved_views; track saved_view.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.tags.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
|
||||||
|
@for (tag of searchResults.tags; track tag.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.correspondents.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
|
||||||
|
@for (correspondent of searchResults.correspondents; track correspondent.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.document_types.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
|
||||||
|
@for (documentType of searchResults.document_types; track documentType.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.storage_paths.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
|
||||||
|
@for (storagePath of searchResults.storage_paths; track storagePath.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.users.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
|
||||||
|
@for (user of searchResults.users; track user.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.groups.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
|
||||||
|
@for (group of searchResults.groups; track group.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.custom_fields.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
|
||||||
|
@for (customField of searchResults.custom_fields; track customField.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.mail_accounts.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
|
||||||
|
@for (mailAccount of searchResults.mail_accounts; track mailAccount.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.mail_rules.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
|
||||||
|
@for (mailRule of searchResults.mail_rules; track mailRule.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.workflows.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
|
||||||
|
@for (workflow of searchResults.workflows; track workflow.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,101 @@
|
|||||||
|
form {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
> i-bs[name="search"] {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.6rem;
|
||||||
|
top: .35rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
// adjust for smaller font size on non-mobile
|
||||||
|
top: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
i-bs[name="search"],
|
||||||
|
.badge {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group .btn {
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
color: var(--pngx-primary-text-contrast);
|
||||||
|
padding-top: .15rem;
|
||||||
|
padding-bottom: .15rem;
|
||||||
|
min-height: calc(1.3em + 0.5rem + calc(var(--bs-border-width) * 2)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
background-color: rgba(0, 0, 0, 0.15);
|
||||||
|
padding-left: 1.8rem;
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
|
||||||
|
> input {
|
||||||
|
outline: none;
|
||||||
|
color: var(--pngx-primary-text-contrast);
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
color: var(--bs-light);
|
||||||
|
flex-grow: 1;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
--pngx-focus-alpha: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mh-75 {
|
||||||
|
max-height: 75vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
&:has(button:focus) {
|
||||||
|
background-color: var(--pngx-bg-darker);
|
||||||
|
}
|
||||||
|
|
||||||
|
& button {
|
||||||
|
transition: all 0.3s ease, color 0.15s ease;
|
||||||
|
max-width: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
& button span {
|
||||||
|
opacity: 0;
|
||||||
|
transition: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover button,
|
||||||
|
&:has(button:focus) button {
|
||||||
|
max-width: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover button span,
|
||||||
|
&:has(button:focus) span {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,546 @@
|
|||||||
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
|
import { GlobalSearchComponent } from './global-search.component'
|
||||||
|
import { of } from 'rxjs'
|
||||||
|
import { SearchService } from 'src/app/services/rest/search.service'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
|
import {
|
||||||
|
NgbDropdownModule,
|
||||||
|
NgbModal,
|
||||||
|
NgbModalModule,
|
||||||
|
NgbModalRef,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
|
import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
|
||||||
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
|
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import {
|
||||||
|
FILTER_FULLTEXT_QUERY,
|
||||||
|
FILTER_HAS_CORRESPONDENT_ANY,
|
||||||
|
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||||
|
FILTER_HAS_STORAGE_PATH_ANY,
|
||||||
|
FILTER_HAS_TAGS_ALL,
|
||||||
|
FILTER_TITLE_CONTENT,
|
||||||
|
} from 'src/app/data/filter-rule-type'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
|
||||||
|
import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
|
||||||
|
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
|
||||||
|
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
|
import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
|
||||||
|
import { ElementRef } from '@angular/core'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { DataType } from 'src/app/data/datatype'
|
||||||
|
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
|
||||||
|
const searchResults = {
|
||||||
|
total: 11,
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Test',
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
document_type: { id: 1, name: 'Test' },
|
||||||
|
storage_path: { id: 1, path: 'Test' },
|
||||||
|
tags: [],
|
||||||
|
correspondents: [],
|
||||||
|
custom_fields: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
saved_views: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestSavedView',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
correspondents: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestCorrespondent',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
document_types: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestDocumentType',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
storage_paths: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestStoragePath',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestTag',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
username: 'TestUser',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestGroup',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mail_accounts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestMailAccount',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mail_rules: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestMailRule',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
custom_fields: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestCustomField',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workflows: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestWorkflow',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GlobalSearchComponent', () => {
|
||||||
|
let component: GlobalSearchComponent
|
||||||
|
let fixture: ComponentFixture<GlobalSearchComponent>
|
||||||
|
let searchService: SearchService
|
||||||
|
let router: Router
|
||||||
|
let modalService: NgbModal
|
||||||
|
let documentService: DocumentService
|
||||||
|
let documentListViewService: DocumentListViewService
|
||||||
|
let toastService: ToastService
|
||||||
|
let settingsService: SettingsService
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [GlobalSearchComponent],
|
||||||
|
imports: [
|
||||||
|
HttpClientTestingModule,
|
||||||
|
NgbModalModule,
|
||||||
|
NgbDropdownModule,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
searchService = TestBed.inject(SearchService)
|
||||||
|
router = TestBed.inject(Router)
|
||||||
|
modalService = TestBed.inject(NgbModal)
|
||||||
|
documentService = TestBed.inject(DocumentService)
|
||||||
|
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||||
|
toastService = TestBed.inject(ToastService)
|
||||||
|
settingsService = TestBed.inject(SettingsService)
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(GlobalSearchComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle keyboard nav', () => {
|
||||||
|
const focusSpy = jest.spyOn(component.searchInput.nativeElement, 'focus')
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
|
||||||
|
expect(focusSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.searchResults = searchResults as any
|
||||||
|
component.resultsDropdown.open()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
component['currentItemIndex'] = 0
|
||||||
|
component['setCurrentItem']()
|
||||||
|
const firstItemFocusSpy = jest.spyOn(
|
||||||
|
component.primaryButtons.get(1).nativeElement,
|
||||||
|
'focus'
|
||||||
|
)
|
||||||
|
component.dropdownKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||||
|
)
|
||||||
|
expect(component['currentItemIndex']).toBe(1)
|
||||||
|
expect(firstItemFocusSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const secondaryItemFocusSpy = jest.spyOn(
|
||||||
|
component.secondaryButtons.get(1).nativeElement,
|
||||||
|
'focus'
|
||||||
|
)
|
||||||
|
component.dropdownKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowRight' })
|
||||||
|
)
|
||||||
|
expect(secondaryItemFocusSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.dropdownKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowLeft' })
|
||||||
|
)
|
||||||
|
expect(firstItemFocusSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const zeroItemSpy = jest.spyOn(
|
||||||
|
component.primaryButtons.get(0).nativeElement,
|
||||||
|
'focus'
|
||||||
|
)
|
||||||
|
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
|
||||||
|
expect(component['currentItemIndex']).toBe(0)
|
||||||
|
expect(zeroItemSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const inputFocusSpy = jest.spyOn(
|
||||||
|
component.searchInput.nativeElement,
|
||||||
|
'focus'
|
||||||
|
)
|
||||||
|
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
|
||||||
|
expect(component['currentItemIndex']).toBe(-1)
|
||||||
|
expect(inputFocusSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.dropdownKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||||
|
)
|
||||||
|
component['currentItemIndex'] = searchResults.total - 1
|
||||||
|
component['setCurrentItem']()
|
||||||
|
component.dropdownKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||||
|
)
|
||||||
|
expect(component['currentItemIndex']).toBe(-1)
|
||||||
|
|
||||||
|
// Search input
|
||||||
|
|
||||||
|
component.searchInputKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowUp' })
|
||||||
|
)
|
||||||
|
expect(component['currentItemIndex']).toBe(searchResults.total - 1)
|
||||||
|
|
||||||
|
component.searchInputKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||||
|
)
|
||||||
|
expect(component['currentItemIndex']).toBe(0)
|
||||||
|
|
||||||
|
component.searchResults = { total: 1 } as any
|
||||||
|
const primaryActionSpy = jest.spyOn(component, 'primaryAction')
|
||||||
|
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||||
|
expect(primaryActionSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.query = 'test'
|
||||||
|
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
|
||||||
|
component.searchInputKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Escape' })
|
||||||
|
)
|
||||||
|
expect(resetSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.query = ''
|
||||||
|
const blurSpy = jest.spyOn(component.searchInput.nativeElement, 'blur')
|
||||||
|
component.searchInputKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Escape' })
|
||||||
|
)
|
||||||
|
expect(blurSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.searchResults = { total: 1 } as any
|
||||||
|
component.resultsDropdown.open()
|
||||||
|
|
||||||
|
component.searchInputKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||||
|
)
|
||||||
|
expect(component['currentItemIndex']).toBe(0)
|
||||||
|
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
|
||||||
|
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||||
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.searchResults = searchResults as any
|
||||||
|
component.resultsDropdown.open()
|
||||||
|
component.query = 'test'
|
||||||
|
const advancedSearchSpy = jest.spyOn(component, 'runFullSearch')
|
||||||
|
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||||
|
expect(advancedSearchSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search on query debounce', fakeAsync(() => {
|
||||||
|
const query = 'test'
|
||||||
|
const searchSpy = jest.spyOn(searchService, 'globalSearch')
|
||||||
|
searchSpy.mockReturnValue(of({} as any))
|
||||||
|
const dropdownOpenSpy = jest.spyOn(component.resultsDropdown, 'open')
|
||||||
|
component.queryDebounce.next(query)
|
||||||
|
tick(401)
|
||||||
|
expect(searchSpy).toHaveBeenCalledWith(query)
|
||||||
|
expect(dropdownOpenSpy).toHaveBeenCalled()
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('should support primary action', () => {
|
||||||
|
const object = { id: 1 }
|
||||||
|
const routerSpy = jest.spyOn(router, 'navigate')
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
|
|
||||||
|
component.primaryAction(DataType.Document, object)
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/documents', object.id], {})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.SavedView, object)
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/view', object.id], {})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.Correspondent, object)
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
|
||||||
|
queryParams: Object.assign(
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
reverse: 1,
|
||||||
|
sort: 'created',
|
||||||
|
},
|
||||||
|
queryParamsFromFilterRules([
|
||||||
|
{
|
||||||
|
rule_type: FILTER_HAS_CORRESPONDENT_ANY,
|
||||||
|
value: object.id.toString(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.DocumentType, object)
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
|
||||||
|
queryParams: Object.assign(
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
reverse: 1,
|
||||||
|
sort: 'created',
|
||||||
|
},
|
||||||
|
queryParamsFromFilterRules([
|
||||||
|
{
|
||||||
|
rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||||
|
value: object.id.toString(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.StoragePath, object)
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
|
||||||
|
queryParams: Object.assign(
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
reverse: 1,
|
||||||
|
sort: 'created',
|
||||||
|
},
|
||||||
|
queryParamsFromFilterRules([
|
||||||
|
{
|
||||||
|
rule_type: FILTER_HAS_STORAGE_PATH_ANY,
|
||||||
|
value: object.id.toString(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.Tag, object)
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
|
||||||
|
queryParams: Object.assign(
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
reverse: 1,
|
||||||
|
sort: 'created',
|
||||||
|
},
|
||||||
|
queryParamsFromFilterRules([
|
||||||
|
{ rule_type: FILTER_HAS_TAGS_ALL, value: object.id.toString() },
|
||||||
|
])
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.User, object)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(UserEditDialogComponent, {
|
||||||
|
size: 'lg',
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.Group, object)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(GroupEditDialogComponent, {
|
||||||
|
size: 'lg',
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.MailAccount, object)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(MailAccountEditDialogComponent, {
|
||||||
|
size: 'xl',
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.MailRule, object)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(MailRuleEditDialogComponent, {
|
||||||
|
size: 'xl',
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.CustomField, object)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(CustomFieldEditDialogComponent, {
|
||||||
|
size: 'md',
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.Workflow, object)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(WorkflowEditDialogComponent, {
|
||||||
|
size: 'xl',
|
||||||
|
})
|
||||||
|
|
||||||
|
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
|
||||||
|
// fail first
|
||||||
|
editDialog.failed.emit({ error: 'error creating item' })
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
// succeed
|
||||||
|
editDialog.succeeded.emit(true)
|
||||||
|
expect(toastInfoSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support secondary action', () => {
|
||||||
|
const doc = searchResults.documents[0]
|
||||||
|
const openSpy = jest.spyOn(window, 'open')
|
||||||
|
component.secondaryAction('document', doc)
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(documentService.getDownloadUrl(doc.id))
|
||||||
|
|
||||||
|
const correspondent = searchResults.correspondents[0]
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
|
|
||||||
|
component.secondaryAction(DataType.Correspondent, correspondent)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||||
|
size: 'md',
|
||||||
|
})
|
||||||
|
|
||||||
|
component.secondaryAction(
|
||||||
|
DataType.DocumentType,
|
||||||
|
searchResults.document_types[0]
|
||||||
|
)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||||
|
size: 'md',
|
||||||
|
})
|
||||||
|
|
||||||
|
component.secondaryAction(
|
||||||
|
DataType.StoragePath,
|
||||||
|
searchResults.storage_paths[0]
|
||||||
|
)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||||
|
size: 'md',
|
||||||
|
})
|
||||||
|
|
||||||
|
component.secondaryAction(DataType.Tag, searchResults.tags[0])
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||||
|
size: 'md',
|
||||||
|
})
|
||||||
|
|
||||||
|
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
|
||||||
|
// fail first
|
||||||
|
editDialog.failed.emit({ error: 'error creating item' })
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
// succeed
|
||||||
|
editDialog.succeeded.emit(true)
|
||||||
|
expect(toastInfoSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support reset', () => {
|
||||||
|
const debounce = jest.spyOn(component.queryDebounce, 'next')
|
||||||
|
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
|
||||||
|
component['reset'](true)
|
||||||
|
expect(debounce).toHaveBeenCalledWith(null)
|
||||||
|
expect(component.searchResults).toBeNull()
|
||||||
|
expect(component['currentItemIndex']).toBe(-1)
|
||||||
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support focus current item', () => {
|
||||||
|
component.searchResults = searchResults as any
|
||||||
|
fixture.detectChanges()
|
||||||
|
const focusSpy = jest.spyOn(
|
||||||
|
component.primaryButtons.get(0).nativeElement,
|
||||||
|
'focus'
|
||||||
|
)
|
||||||
|
component['currentItemIndex'] = 0
|
||||||
|
component['setCurrentItem']()
|
||||||
|
expect(focusSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reset on dropdown close', () => {
|
||||||
|
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
|
||||||
|
component.onDropdownOpenChange(false)
|
||||||
|
expect(resetSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should focus button on dropdown item hover', () => {
|
||||||
|
component.searchResults = searchResults as any
|
||||||
|
fixture.detectChanges()
|
||||||
|
const item: ElementRef = component.resultItems.first
|
||||||
|
const focusSpy = jest.spyOn(
|
||||||
|
component.primaryButtons.first.nativeElement,
|
||||||
|
'focus'
|
||||||
|
)
|
||||||
|
component.onItemHover({ currentTarget: item.nativeElement } as any)
|
||||||
|
expect(component['currentItemIndex']).toBe(0)
|
||||||
|
expect(focusSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should focus on button hover', () => {
|
||||||
|
const event = { currentTarget: { focus: jest.fn() } }
|
||||||
|
const focusSpy = jest.spyOn(event.currentTarget, 'focus')
|
||||||
|
component.onButtonHover(event as any)
|
||||||
|
expect(focusSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support open in new window', () => {
|
||||||
|
const openSpy = jest.spyOn(window, 'open')
|
||||||
|
const event = new Event('click')
|
||||||
|
event['ctrlKey'] = true
|
||||||
|
component.primaryAction(DataType.Document, { id: 2 }, event as any)
|
||||||
|
expect(openSpy).toHaveBeenCalledWith('/documents/2', '_blank')
|
||||||
|
|
||||||
|
component.searchResults = searchResults as any
|
||||||
|
component.resultsDropdown.open()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
const button = component.primaryButtons.get(0).nativeElement
|
||||||
|
const keyboardEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
ctrlKey: true,
|
||||||
|
})
|
||||||
|
const dispatchSpy = jest.spyOn(button, 'dispatchEvent')
|
||||||
|
button.dispatchEvent(keyboardEvent)
|
||||||
|
expect(dispatchSpy).toHaveBeenCalledTimes(2) // once for keydown, second for click
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support title content search and advanced search', () => {
|
||||||
|
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||||
|
component.query = 'test'
|
||||||
|
component.runFullSearch()
|
||||||
|
expect(qfSpy).toHaveBeenCalledWith([
|
||||||
|
{ rule_type: FILTER_TITLE_CONTENT, value: 'test' },
|
||||||
|
])
|
||||||
|
|
||||||
|
settingsService.set(
|
||||||
|
SETTINGS_KEYS.SEARCH_FULL_TYPE,
|
||||||
|
GlobalSearchType.ADVANCED
|
||||||
|
)
|
||||||
|
component.query = 'test'
|
||||||
|
component.runFullSearch()
|
||||||
|
expect(qfSpy).toHaveBeenCalledWith([
|
||||||
|
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
@ -0,0 +1,416 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ViewChild,
|
||||||
|
ElementRef,
|
||||||
|
ViewChildren,
|
||||||
|
QueryList,
|
||||||
|
OnInit,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
|
import { NgbDropdown, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { Subject, debounceTime, distinctUntilChanged, filter, map } from 'rxjs'
|
||||||
|
import {
|
||||||
|
FILTER_FULLTEXT_QUERY,
|
||||||
|
FILTER_HAS_CORRESPONDENT_ANY,
|
||||||
|
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||||
|
FILTER_HAS_STORAGE_PATH_ANY,
|
||||||
|
FILTER_HAS_TAGS_ALL,
|
||||||
|
FILTER_TITLE_CONTENT,
|
||||||
|
} from 'src/app/data/filter-rule-type'
|
||||||
|
import { DataType } from 'src/app/data/datatype'
|
||||||
|
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||||
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
|
import {
|
||||||
|
PermissionsService,
|
||||||
|
PermissionAction,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import {
|
||||||
|
GlobalSearchResult,
|
||||||
|
SearchService,
|
||||||
|
} from 'src/app/services/rest/search.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
|
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
|
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
|
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||||
|
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
|
||||||
|
import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
|
||||||
|
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
|
||||||
|
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||||
|
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||||
|
import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
|
||||||
|
import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
|
||||||
|
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||||
|
import { paramsFromViewState } from 'src/app/utils/query-params'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-global-search',
|
||||||
|
templateUrl: './global-search.component.html',
|
||||||
|
styleUrl: './global-search.component.scss',
|
||||||
|
})
|
||||||
|
export class GlobalSearchComponent implements OnInit {
|
||||||
|
public DataType = DataType
|
||||||
|
public query: string
|
||||||
|
public queryDebounce: Subject<string>
|
||||||
|
public searchResults: GlobalSearchResult
|
||||||
|
private currentItemIndex: number = -1
|
||||||
|
private domIndex: number = -1
|
||||||
|
public loading: boolean = false
|
||||||
|
|
||||||
|
@ViewChild('searchInput') searchInput: ElementRef
|
||||||
|
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
|
||||||
|
@ViewChildren('resultItem') resultItems: QueryList<ElementRef>
|
||||||
|
@ViewChildren('primaryButton') primaryButtons: QueryList<ElementRef>
|
||||||
|
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
||||||
|
|
||||||
|
get useAdvancedForFullSearch(): boolean {
|
||||||
|
return (
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
|
||||||
|
GlobalSearchType.ADVANCED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public searchService: SearchService,
|
||||||
|
private router: Router,
|
||||||
|
private modalService: NgbModal,
|
||||||
|
private documentService: DocumentService,
|
||||||
|
private documentListViewService: DocumentListViewService,
|
||||||
|
private permissionsService: PermissionsService,
|
||||||
|
private toastService: ToastService,
|
||||||
|
private hotkeyService: HotKeyService,
|
||||||
|
private settingsService: SettingsService
|
||||||
|
) {
|
||||||
|
this.queryDebounce = new Subject<string>()
|
||||||
|
|
||||||
|
this.queryDebounce
|
||||||
|
.pipe(
|
||||||
|
debounceTime(400),
|
||||||
|
map((query) => query?.trim()),
|
||||||
|
filter((query) => !query?.length || query?.length > 2),
|
||||||
|
distinctUntilChanged()
|
||||||
|
)
|
||||||
|
.subscribe((text) => {
|
||||||
|
this.query = text
|
||||||
|
if (text) this.search(text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit() {
|
||||||
|
this.hotkeyService
|
||||||
|
.addShortcut({ keys: '/', description: $localize`Global search` })
|
||||||
|
.subscribe(() => {
|
||||||
|
this.searchInput.nativeElement.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private search(query: string) {
|
||||||
|
this.loading = true
|
||||||
|
this.searchService.globalSearch(query).subscribe((results) => {
|
||||||
|
this.searchResults = results
|
||||||
|
this.loading = false
|
||||||
|
this.resultsDropdown.open()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public primaryAction(
|
||||||
|
type: string,
|
||||||
|
object: ObjectWithId,
|
||||||
|
event: PointerEvent = null
|
||||||
|
) {
|
||||||
|
const newWindow = event?.metaKey || event?.ctrlKey
|
||||||
|
this.reset(true)
|
||||||
|
let filterRuleType: number
|
||||||
|
let editDialogComponent: any
|
||||||
|
let size: string = 'md'
|
||||||
|
switch (type) {
|
||||||
|
case DataType.Document:
|
||||||
|
this.navigateOrOpenInNewWindow(['/documents', object.id], newWindow)
|
||||||
|
return
|
||||||
|
case DataType.SavedView:
|
||||||
|
this.navigateOrOpenInNewWindow(['/view', object.id], newWindow)
|
||||||
|
return
|
||||||
|
case DataType.Correspondent:
|
||||||
|
filterRuleType = FILTER_HAS_CORRESPONDENT_ANY
|
||||||
|
break
|
||||||
|
case DataType.DocumentType:
|
||||||
|
filterRuleType = FILTER_HAS_DOCUMENT_TYPE_ANY
|
||||||
|
break
|
||||||
|
case DataType.StoragePath:
|
||||||
|
filterRuleType = FILTER_HAS_STORAGE_PATH_ANY
|
||||||
|
break
|
||||||
|
case DataType.Tag:
|
||||||
|
filterRuleType = FILTER_HAS_TAGS_ALL
|
||||||
|
break
|
||||||
|
case DataType.User:
|
||||||
|
editDialogComponent = UserEditDialogComponent
|
||||||
|
size = 'lg'
|
||||||
|
break
|
||||||
|
case DataType.Group:
|
||||||
|
editDialogComponent = GroupEditDialogComponent
|
||||||
|
size = 'lg'
|
||||||
|
break
|
||||||
|
case DataType.MailAccount:
|
||||||
|
editDialogComponent = MailAccountEditDialogComponent
|
||||||
|
size = 'xl'
|
||||||
|
break
|
||||||
|
case DataType.MailRule:
|
||||||
|
editDialogComponent = MailRuleEditDialogComponent
|
||||||
|
size = 'xl'
|
||||||
|
break
|
||||||
|
case DataType.CustomField:
|
||||||
|
editDialogComponent = CustomFieldEditDialogComponent
|
||||||
|
break
|
||||||
|
case DataType.Workflow:
|
||||||
|
editDialogComponent = WorkflowEditDialogComponent
|
||||||
|
size = 'xl'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterRuleType) {
|
||||||
|
let params = paramsFromViewState({
|
||||||
|
filterRules: [
|
||||||
|
{ rule_type: filterRuleType, value: object.id.toString() },
|
||||||
|
],
|
||||||
|
currentPage: 1,
|
||||||
|
sortField: this.documentListViewService.sortField ?? 'created',
|
||||||
|
sortReverse: this.documentListViewService.sortReverse,
|
||||||
|
})
|
||||||
|
this.navigateOrOpenInNewWindow(['/documents'], newWindow, {
|
||||||
|
queryParams: params,
|
||||||
|
})
|
||||||
|
} else if (editDialogComponent) {
|
||||||
|
const modalRef: NgbModalRef = this.modalService.open(
|
||||||
|
editDialogComponent,
|
||||||
|
{ size }
|
||||||
|
)
|
||||||
|
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
|
||||||
|
modalRef.componentInstance.object = object
|
||||||
|
modalRef.componentInstance.succeeded.subscribe(() => {
|
||||||
|
this.toastService.showInfo($localize`Successfully updated object.`)
|
||||||
|
})
|
||||||
|
modalRef.componentInstance.failed.subscribe((e) => {
|
||||||
|
this.toastService.showError($localize`Error occurred saving object.`, e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public secondaryAction(type: string, object: ObjectWithId) {
|
||||||
|
this.reset(true)
|
||||||
|
let editDialogComponent: any
|
||||||
|
let size: string = 'md'
|
||||||
|
switch (type) {
|
||||||
|
case DataType.Document:
|
||||||
|
window.open(this.documentService.getDownloadUrl(object.id))
|
||||||
|
break
|
||||||
|
case DataType.Correspondent:
|
||||||
|
editDialogComponent = CorrespondentEditDialogComponent
|
||||||
|
break
|
||||||
|
case DataType.DocumentType:
|
||||||
|
editDialogComponent = DocumentTypeEditDialogComponent
|
||||||
|
break
|
||||||
|
case DataType.StoragePath:
|
||||||
|
editDialogComponent = StoragePathEditDialogComponent
|
||||||
|
break
|
||||||
|
case DataType.Tag:
|
||||||
|
editDialogComponent = TagEditDialogComponent
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editDialogComponent) {
|
||||||
|
const modalRef: NgbModalRef = this.modalService.open(
|
||||||
|
editDialogComponent,
|
||||||
|
{ size }
|
||||||
|
)
|
||||||
|
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
|
||||||
|
modalRef.componentInstance.object = object
|
||||||
|
modalRef.componentInstance.succeeded.subscribe(() => {
|
||||||
|
this.toastService.showInfo($localize`Successfully updated object.`)
|
||||||
|
})
|
||||||
|
modalRef.componentInstance.failed.subscribe((e) => {
|
||||||
|
this.toastService.showError($localize`Error occurred saving object.`, e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset(close: boolean = false) {
|
||||||
|
this.queryDebounce.next(null)
|
||||||
|
this.query = null
|
||||||
|
this.searchResults = null
|
||||||
|
this.currentItemIndex = -1
|
||||||
|
if (close) {
|
||||||
|
this.resultsDropdown.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCurrentItem() {
|
||||||
|
// QueryLists do not always reflect the current DOM order, so we need to find the actual element
|
||||||
|
// Yes, using some vanilla JS
|
||||||
|
const result: HTMLElement = this.resultItems.first.nativeElement.parentNode
|
||||||
|
.querySelectorAll('.dropdown-item')
|
||||||
|
.item(this.currentItemIndex)
|
||||||
|
this.domIndex = this.resultItems
|
||||||
|
.toArray()
|
||||||
|
.indexOf(this.resultItems.find((item) => item.nativeElement === result))
|
||||||
|
const item: ElementRef = this.primaryButtons.get(this.domIndex)
|
||||||
|
item.nativeElement.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
public onItemHover(event: MouseEvent) {
|
||||||
|
const item: ElementRef = this.resultItems
|
||||||
|
.toArray()
|
||||||
|
.find((item) => item.nativeElement === event.currentTarget)
|
||||||
|
this.currentItemIndex = this.resultItems.toArray().indexOf(item)
|
||||||
|
this.setCurrentItem()
|
||||||
|
}
|
||||||
|
|
||||||
|
public onButtonHover(event: MouseEvent) {
|
||||||
|
;(event.currentTarget as HTMLElement).focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
public searchInputKeyDown(event: KeyboardEvent) {
|
||||||
|
if (
|
||||||
|
event.key === 'ArrowDown' &&
|
||||||
|
this.searchResults?.total &&
|
||||||
|
this.resultsDropdown.isOpen()
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
this.currentItemIndex = 0
|
||||||
|
this.setCurrentItem()
|
||||||
|
} else if (
|
||||||
|
event.key === 'ArrowUp' &&
|
||||||
|
this.searchResults?.total &&
|
||||||
|
this.resultsDropdown.isOpen()
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
this.currentItemIndex = this.searchResults.total - 1
|
||||||
|
this.setCurrentItem()
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
if (this.searchResults?.total === 1 && this.resultsDropdown.isOpen()) {
|
||||||
|
this.primaryButtons.first.nativeElement.click()
|
||||||
|
this.searchInput.nativeElement.blur()
|
||||||
|
} else if (this.query?.length) {
|
||||||
|
this.runFullSearch()
|
||||||
|
this.reset(true)
|
||||||
|
}
|
||||||
|
} else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
|
||||||
|
if (this.query?.length) {
|
||||||
|
this.reset(true)
|
||||||
|
} else {
|
||||||
|
this.searchInput.nativeElement.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public dropdownKeyDown(event: KeyboardEvent) {
|
||||||
|
if (
|
||||||
|
this.searchResults?.total &&
|
||||||
|
this.resultsDropdown.isOpen() &&
|
||||||
|
document.activeElement !== this.searchInput.nativeElement
|
||||||
|
) {
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
if (this.currentItemIndex < this.searchResults.total - 1) {
|
||||||
|
this.currentItemIndex++
|
||||||
|
this.setCurrentItem()
|
||||||
|
} else {
|
||||||
|
this.searchInput.nativeElement.focus()
|
||||||
|
this.currentItemIndex = -1
|
||||||
|
}
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
if (this.currentItemIndex > 0) {
|
||||||
|
this.currentItemIndex--
|
||||||
|
this.setCurrentItem()
|
||||||
|
} else {
|
||||||
|
this.searchInput.nativeElement.focus()
|
||||||
|
this.currentItemIndex = -1
|
||||||
|
}
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
this.secondaryButtons.get(this.domIndex)?.nativeElement.focus()
|
||||||
|
} else if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
this.primaryButtons.get(this.domIndex).nativeElement.focus()
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
this.reset(true)
|
||||||
|
this.searchInput.nativeElement.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onButtonKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||||
|
event.target.dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDropdownOpenChange(open: boolean) {
|
||||||
|
if (!open) {
|
||||||
|
this.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public disablePrimaryButton(type: DataType, object: ObjectWithId): boolean {
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
DataType.Workflow,
|
||||||
|
DataType.CustomField,
|
||||||
|
DataType.Group,
|
||||||
|
DataType.User,
|
||||||
|
].includes(type)
|
||||||
|
) {
|
||||||
|
return !this.permissionsService.currentUserHasObjectPermissions(
|
||||||
|
PermissionAction.Change,
|
||||||
|
object
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
public disableSecondaryButton(type: DataType, object: ObjectWithId): boolean {
|
||||||
|
if (DataType.Document === type) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !this.permissionsService.currentUserHasObjectPermissions(
|
||||||
|
PermissionAction.Change,
|
||||||
|
object
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public runFullSearch() {
|
||||||
|
const ruleType = this.useAdvancedForFullSearch
|
||||||
|
? FILTER_FULLTEXT_QUERY
|
||||||
|
: FILTER_TITLE_CONTENT
|
||||||
|
this.documentListViewService.quickFilter([
|
||||||
|
{ rule_type: ruleType, value: this.query },
|
||||||
|
])
|
||||||
|
this.reset(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private navigateOrOpenInNewWindow(
|
||||||
|
commands: any,
|
||||||
|
newWindow: boolean = false,
|
||||||
|
extras: Object = {}
|
||||||
|
) {
|
||||||
|
if (newWindow) {
|
||||||
|
const url = this.router.serializeUrl(
|
||||||
|
this.router.createUrlTree(commands, extras)
|
||||||
|
)
|
||||||
|
window.open(url, '_blank')
|
||||||
|
} else {
|
||||||
|
this.router.navigate(commands, extras)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,11 @@
|
|||||||
<button *ngIf="active" class="position-absolute top-0 start-100 translate-middle badge bg-secondary border border-light rounded-pill p-1" title="Clear" i18n-title (click)="onClick($event)">
|
@if (active) {
|
||||||
<svg *ngIf="!isNumbered && selected" width="1em" height="1em" class="check m-0 p-0 opacity-75" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
<button class="position-absolute top-0 start-100 translate-middle badge bg-secondary border border-light rounded-pill p-1" title="Clear" i18n-title (click)="onClick($event)">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#check-lg"/>
|
@if (!isNumbered && selected) {
|
||||||
</svg>
|
<i-bs class="check" width="1em" height="1em" name="check-lg"></i-bs>
|
||||||
<div *ngIf="isNumbered" class="number">{{number}}<span class="visually-hidden">selected</span></div>
|
}
|
||||||
<svg width=".9em" height="1em" class="x m-0 p-0 opacity-75" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
@if (isNumbered) {
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#x-lg"/>
|
<div class="number">{{number}}<span class="visually-hidden">selected</span></div>
|
||||||
</svg>
|
}
|
||||||
|
<i-bs class="x" width=".9em" height="1em" name="x-lg"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
|
@ -22,7 +22,7 @@ button:hover {
|
|||||||
.x {
|
.x {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 5px;
|
top: .4em;
|
||||||
left: calc(50% - 4px);
|
left: calc(50% - .4em);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { ClearableBadgeComponent } from './clearable-badge.component'
|
import { ClearableBadgeComponent } from './clearable-badge.component'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
describe('ClearableBadgeComponent', () => {
|
describe('ClearableBadgeComponent', () => {
|
||||||
let component: ClearableBadgeComponent
|
let component: ClearableBadgeComponent
|
||||||
@ -8,6 +9,7 @@ describe('ClearableBadgeComponent', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
declarations: [ClearableBadgeComponent],
|
declarations: [ClearableBadgeComponent],
|
||||||
|
imports: [NgxBootstrapIconsModule.pick(allIcons)],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ClearableBadgeComponent)
|
fixture = TestBed.createComponent(ClearableBadgeComponent)
|
||||||
|