Merge pull request #695 from paperless-ngx/beta

[Beta] Paperless-ngx v1.7.0 Release Candidate 1
This commit is contained in:
shamoon 2022-04-25 10:24:27 -07:00 committed by GitHub
commit a358477cda
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
431 changed files with 41947 additions and 13705 deletions

View File

@ -17,3 +17,5 @@
**/htmlcov **/htmlcov
/src/.pytest_cache /src/.pytest_cache
.idea .idea
.venv/
.vscode/

View File

@ -18,14 +18,20 @@ max_line_length = off
indent_size = 4 indent_size = 4
indent_style = space indent_style = space
[*.yml] [*.{yml,yaml}]
indent_style = space indent_style = space
[*.rst] [*.rst]
indent_style = space indent_style = space
[*.md]
indent_style = space
# Tests don't get a line width restriction. It's still a good idea to follow # Tests don't get a line width restriction. It's still a good idea to follow
# the 79 character rule, but in the interests of clarity, tests often need to # the 79 character rule, but in the interests of clarity, tests often need to
# violate it. # violate it.
[**/test_*.py] [**/test_*.py]
max_line_length = off max_line_length = off
[Dockerfile]
indent_style = space

2
.env
View File

@ -1,2 +1,2 @@
COMPOSE_PROJECT_NAME=paperless COMPOSE_PROJECT_NAME=paperless
export PROMPT="(pipenv-projectname)$P$G" export PROMPT="(pipenv-projectname)$P$G"

View File

@ -8,7 +8,7 @@ updates:
target-branch: "dev" target-branch: "dev"
# Look for `package.json` and `lock` files in the `root` directory # Look for `package.json` and `lock` files in the `root` directory
directory: "/src-ui" directory: "/src-ui"
# Check the npm registry for updates every week # Check the npm registry for updates every month
schedule: schedule:
interval: "monthly" interval: "monthly"
# Add reviewers # Add reviewers
@ -23,6 +23,19 @@ updates:
# Check for updates once a week # Check for updates once a week
schedule: schedule:
interval: "weekly" interval: "weekly"
labels:
- "backend"
- "dependencies"
# Enable updates for Github Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
# Check for updates to GitHub Actions every month
interval: "monthly"
labels:
- "ci-cd"
- "dependencies"
# Add reviewers # Add reviewers
reviewers: reviewers:
- "paperless-ngx/backend" - "paperless-ngx/backend"

View File

@ -13,90 +13,100 @@ on:
jobs: jobs:
documentation: documentation:
name: "Build Documentation"
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
steps: steps:
- -
name: Checkout name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v3
-
name: Install pipenv
run: pipx install pipenv
- -
name: Set up Python name: Set up Python
uses: actions/setup-python@v2 uses: actions/setup-python@v3
with: with:
python-version: 3.9 python-version: 3.9
- cache: "pipenv"
name: Get pip cache dir cache-dependency-path: 'Pipfile.lock'
id: pip-cache
run: |
echo "::set-output name=dir::$(pip cache dir)"
-
name: Persistent Github pip cache
uses: actions/cache@v2
with:
path: ${{ steps.pip-cache.outputs.dir }}
key: ${{ runner.os }}-pip3.8}
- -
name: Install dependencies name: Install dependencies
run: | run: |
pip install --upgrade pipenv pipenv sync --dev
pipenv install --system --dev --ignore-pipfile
- -
name: Make documentation name: Make documentation
run: | run: |
cd docs/ cd docs/
make html pipenv run make html
- -
name: Upload artifact name: Upload artifact
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: documentation name: documentation
path: docs/_build/html/ path: docs/_build/html/
codestyle: code-checks-backend:
name: "Backend Code Checks"
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
steps: steps:
- -
name: Checkout name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v3
- -
name: Set up Python name: Install checkers
uses: actions/setup-python@v2
with:
python-version: 3.9
-
name: Get pip cache dir
id: pip-cache
run: | run: |
echo "::set-output name=dir::$(pip cache dir)" pipx install reorder-python-imports
pipx install yesqa
pipx install add-trailing-comma
pipx install flake8
- -
name: Persistent Github pip cache name: Run reorder-python-imports
uses: actions/cache@v2
with:
path: ${{ steps.pip-cache.outputs.dir }}
key: ${{ runner.os }}-pip${{ matrix.python-version }}
-
name: Install dependencies
run: | run: |
pip install --upgrade pipenv find src/ -type f -name '*.py' ! -path "*/migrations/*" | xargs reorder-python-imports
pipenv install --system --dev --ignore-pipfile
- -
name: Codestyle name: Run yesqa
run: | run: |
cd src/ find src/ -type f -name '*.py' ! -path "*/migrations/*" | xargs yesqa
pycodestyle --max-line-length=88 --ignore=E121,E123,E126,E226,E24,E704,W503,W504,E203
codeformatting:
runs-on: ubuntu-20.04
steps:
- -
name: Checkout name: Run add-trailing-comma
uses: actions/checkout@v2 run: |
find src/ -type f -name '*.py' ! -path "*/migrations/*" | xargs add-trailing-comma
# black is placed after add-trailing-comma because it may format differently
# if a trailing comma is added
- -
name: Run black name: Run black
uses: psf/black@stable uses: psf/black@stable
with: with:
options: "--check --diff" options: "--check --diff"
version: "22.3.0" version: "22.3.0"
-
name: Run flake8 checks
run: |
cd src/
flake8 --max-line-length=88 --ignore=E203,W503
tests: code-checks-frontend:
name: "Frontend Code Checks"
runs-on: ubuntu-20.04
steps:
-
name: Checkout
uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '16'
-
name: Install prettier
run: |
npm install prettier
-
name: Run prettier
run:
npx prettier --check --ignore-path Pipfile.lock **/*.js **/*.ts *.md **/*.md
tests-backend:
needs: [code-checks-backend]
name: "Backend Tests (${{ matrix.python-version }})"
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
strategy: strategy:
matrix: matrix:
@ -105,73 +115,94 @@ jobs:
steps: steps:
- -
name: Checkout name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v3
with:
fetch-depth: 2
-
name: Install pipenv
run: pipx install pipenv
- -
name: Set up Python name: Set up Python
uses: actions/setup-python@v2 uses: actions/setup-python@v3
with: with:
python-version: "${{ matrix.python-version }}" python-version: "${{ matrix.python-version }}"
cache: "pipenv"
cache-dependency-path: 'Pipfile.lock'
- -
name: Get pip cache dir name: Install system dependencies
id: pip-cache
run: |
echo "::set-output name=dir::$(pip cache dir)"
-
name: Persistent Github pip cache
uses: actions/cache@v2
with:
path: ${{ steps.pip-cache.outputs.dir }}
key: ${{ runner.os }}-pip${{ matrix.python-version }}
-
name: Install dependencies
run: | run: |
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends unpaper tesseract-ocr imagemagick ghostscript optipng sudo apt-get install -qq --no-install-recommends unpaper tesseract-ocr imagemagick ghostscript optipng libzbar0 poppler-utils
pip install --upgrade pipenv -
pipenv install --system --dev --ignore-pipfile name: Install Python dependencies
run: |
pipenv sync --dev
- -
name: Tests name: Tests
run: | run: |
cd src/ cd src/
pytest pipenv run pytest
-
name: Get changed files
id: changed-files-specific
uses: tj-actions/changed-files@v18.1
with:
files: |
src/**
-
name: List all changed files
run: |
for file in ${{ steps.changed-files-specific.outputs.all_changed_files }}; do
echo "${file} was changed"
done
- -
name: Publish coverage results name: Publish coverage results
if: matrix.python-version == '3.9' if: matrix.python-version == '3.9' && steps.changed-files-specific.outputs.any_changed == 'true'
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# https://github.com/coveralls-clients/coveralls-python/issues/251 # https://github.com/coveralls-clients/coveralls-python/issues/251
run: | run: |
cd src/ cd src/
coveralls --service=github pipenv run coveralls --service=github
tests-frontend:
needs: [code-checks-frontend]
name: "Frontend Tests"
runs-on: ubuntu-20.04
strategy:
matrix:
node-version: [16.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: cd src-ui && npm ci
- run: cd src-ui && npm run test
- run: cd src-ui && npm run e2e:ci
# build and push image to docker hub. # build and push image to docker hub.
build-docker-image: build-docker-image:
if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || startsWith(github.ref, 'refs/tags/ngx-') || startsWith(github.ref, 'refs/tags/beta-')) if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || startsWith(github.ref, 'refs/tags/ngx-') || startsWith(github.ref, 'refs/tags/beta-'))
runs-on: ubuntu-latest concurrency:
needs: [tests, codeformatting, codestyle] group: ${{ github.workflow }}-build-docker-image-${{ github.ref }}
cancel-in-progress: true
runs-on: ubuntu-20.04
needs: [tests-backend, tests-frontend]
steps: steps:
- -
name: Prepare name: Gather Docker metadata
id: prepare id: docker-meta
run: | uses: docker/metadata-action@v3
IMAGE_NAME=ghcr.io/${{ github.repository }} with:
if [[ $GITHUB_REF == refs/tags/ngx-* ]]; then images: ghcr.io/${{ github.repository }}
TAGS=${IMAGE_NAME}:${GITHUB_REF#refs/tags/ngx-},${IMAGE_NAME}:latest tags: |
INSPECT_TAG=${IMAGE_NAME}:latest type=ref,event=branch
elif [[ $GITHUB_REF == refs/tags/beta-* ]]; then type=ref,event=tag
TAGS=${IMAGE_NAME}:beta
INSPECT_TAG=${TAGS}
elif [[ $GITHUB_REF == refs/heads/* ]]; then
TAGS=${IMAGE_NAME}:${GITHUB_REF#refs/heads/}
INSPECT_TAG=${TAGS}
else
exit 1
fi
echo ::set-output name=tags::${TAGS}
echo ::set-output name=inspect_tag::${INSPECT_TAG}
- -
name: Checkout name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v3
- -
name: Set up Docker Buildx name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v1
@ -192,36 +223,37 @@ jobs:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v7,linux/arm64
push: true push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.prepare.outputs.tags }} tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
- -
name: Inspect image name: Inspect image
run: | run: |
docker buildx imagetools inspect ${{ steps.prepare.outputs.inspect_tag }} docker buildx imagetools inspect ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }}
- -
name: Export frontend artifact from docker name: Export frontend artifact from docker
run: | run: |
docker run -d --name frontend-extract ${{ steps.prepare.outputs.inspect_tag }} docker create --name frontend-extract ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }}
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@v2 uses: actions/upload-artifact@v3
with: with:
name: frontend-compiled name: frontend-compiled
path: src/documents/static/frontend/ path: src/documents/static/frontend/
build-release: build-release:
needs: [build-docker-image, documentation, tests, codeformatting, codestyle] needs: [build-docker-image, documentation]
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
steps: steps:
- -
name: Checkout name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v3
- -
name: Set up Python name: Set up Python
uses: actions/setup-python@v2 uses: actions/setup-python@v3
with: with:
python-version: 3.9 python-version: 3.9
- -
@ -233,13 +265,13 @@ jobs:
pip3 install -r requirements.txt pip3 install -r requirements.txt
- -
name: Download frontend artifact name: Download frontend artifact
uses: actions/download-artifact@v2 uses: actions/download-artifact@v3
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@v2 uses: actions/download-artifact@v3
with: with:
name: documentation name: documentation
path: docs/_build/html/ path: docs/_build/html/
@ -274,19 +306,19 @@ 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@v2 uses: actions/upload-artifact@v3
with: with:
name: release name: release
path: dist/paperless-ngx.tar.xz path: dist/paperless-ngx.tar.xz
publish-release: publish-release:
runs-on: ubuntu-latest runs-on: ubuntu-20.04
needs: build-release needs: build-release
if: contains(github.ref, 'refs/tags/ngx-') || contains(github.ref, 'refs/tags/beta-') if: contains(github.ref, 'refs/tags/ngx-') || contains(github.ref, 'refs/tags/beta-')
steps: steps:
- -
name: Download release artifact name: Download release artifact
uses: actions/download-artifact@v2 uses: actions/download-artifact@v3
with: with:
name: release name: release
path: ./ path: ./
@ -297,24 +329,22 @@ jobs:
if [[ $GITHUB_REF == refs/tags/ngx-* ]]; then if [[ $GITHUB_REF == refs/tags/ngx-* ]]; then
echo ::set-output name=version::${GITHUB_REF#refs/tags/ngx-} echo ::set-output name=version::${GITHUB_REF#refs/tags/ngx-}
echo ::set-output name=prerelease::false echo ::set-output name=prerelease::false
echo ::set-output name=body::"For a complete list of changes, see the changelog at https://paperless-ngx.readthedocs.io/en/latest/changelog.html"
elif [[ $GITHUB_REF == refs/tags/beta-* ]]; then elif [[ $GITHUB_REF == refs/tags/beta-* ]]; then
echo ::set-output name=version::${GITHUB_REF#refs/tags/beta-} echo ::set-output name=version::${GITHUB_REF#refs/tags/beta-}
echo ::set-output name=prerelease::true echo ::set-output name=prerelease::true
echo ::set-output name=body::"For a complete list of changes, see the changelog at https://github.com/paperless-ngx/paperless-ngx/blob/beta/docs/changelog.rst"
fi fi
- -
name: Create release name: Create Release and Changelog
id: create_release id: create-release
uses: actions/create-release@v1 uses: release-drafter/release-drafter@v5
with:
name: Paperless-ngx ${{ steps.get_version.outputs.version }}
tag: ngx-${{ steps.get_version.outputs.version }}
version: ${{ steps.get_version.outputs.version }}
prerelease: ${{ steps.get_version.outputs.prerelease }}
publish: true # ensures release is not marked as draft
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ngx-${{ steps.get_version.outputs.version }}
release_name: Paperless-ngx ${{ steps.get_version.outputs.version }}
draft: false
prerelease: ${{ steps.get_version.outputs.prerelease }}
body: ${{ steps.get_version.outputs.body }}
- -
name: Upload release archive name: Upload release archive
id: upload-release-asset id: upload-release-asset
@ -322,7 +352,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps upload_url: ${{ steps.create-release.outputs.upload_url }}
asset_path: ./paperless-ngx.tar.xz asset_path: ./paperless-ngx.tar.xz
asset_name: paperless-ngx-${{ steps.get_version.outputs.version }}.tar.xz asset_name: paperless-ngx-${{ steps.get_version.outputs.version }}.tar.xz
asset_content_type: application/x-xz asset_content_type: application/x-xz

3
.gitignore vendored
View File

@ -61,6 +61,9 @@ target/
# PyCharm # PyCharm
.idea .idea
# VS Code
.vscode
# Other stuff that doesn't belong # Other stuff that doesn't belong
.virtualenv .virtualenv
virtualenv virtualenv

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
# https://prettier.io/docs/en/options.html#semicolons
semi: false
# https://prettier.io/docs/en/options.html#quotes
singleQuote: true

10
CODEOWNERS Normal file
View File

@ -0,0 +1,10 @@
/.github/workflows/ @paperless-ngx/ci-cd
/docker/ @paperless-ngx/ci-cd
/scripts/ @paperless-ngx/ci-cd
/src-ui/ @paperless-ngx/frontend
/src/ @paperless-ngx/backend
Pipfile* @paperless-ngx/backend
*.py @paperless-ngx/backend
requirements.txt @paperless-ngx/backend

View File

@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our Examples of behavior that contributes to a positive environment for our
community include: community include:
* Demonstrating empathy and kindness toward other people - Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences - Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback - Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, - Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience and learning from the experience
* Focusing on what is best not just for us as individuals, but for the - Focusing on what is best not just for us as individuals, but for the
overall community overall community
Examples of unacceptable behavior include: Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or - The use of sexualized language or imagery, and sexual attention or
advances of any kind advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks - Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment - Public or private harassment
* Publishing others' private information, such as a physical or email - Publishing others' private information, such as a physical or email
address, without their explicit permission address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a - Other conduct which could reasonably be considered inappropriate in a
professional setting professional setting
## Enforcement Responsibilities ## Enforcement Responsibilities
@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
### 4. Permanent Ban ### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community **Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals. individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within **Consequence**: A permanent ban from any sort of public interaction within

View File

@ -4,10 +4,10 @@ If you feel like contributing to the project, please do! Bug fixes and improveme
If you want to implement something big: If you want to implement something big:
* Please start a discussion about that in the issues! Maybe something similar is already in development and we can make it happen together. - Please start a discussion about that in the issues! Maybe something similar is already in development and we can make it happen together.
* When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project. - When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project.
* Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change. - Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change.
* Please see the [paperless-ngx merge process](#merging-prs) below. - Please see the [paperless-ngx merge process](#merging-prs) below.
## Python ## Python
@ -27,6 +27,8 @@ Please format and test your code! I know it's a hassle, but it makes sure that y
To test your code, execute `pytest` in the src/ directory. This also generates a html coverage report, which you can use to see if you missed anything important during testing. To test your code, execute `pytest` in the src/ directory. This also generates a html coverage report, which you can use to see if you missed anything important during testing.
Before you can run `pytest`, ensure to [properly set up your local environment](https://paperless-ngx.readthedocs.io/en/latest/extending.html#initial-setup-and-first-start).
## More info: ## More info:
... is available in the documentation. https://paperless-ngx.readthedocs.io/en/latest/extending.html ... is available in the documentation. https://paperless-ngx.readthedocs.io/en/latest/extending.html
@ -41,9 +43,9 @@ PRs deemed `non-trivial` will go through a stricter review process before being
Examples of `non-trivial` PRs might include: Examples of `non-trivial` PRs might include:
* Additional features - Additional features
* Large changes to many distinct files - Large changes to many distinct files
* Breaking or depreciation of existing features - Breaking or depreciation of existing features
Our community review process for `non-trivial` PRs is the following: Our community review process for `non-trivial` PRs is the following:
@ -75,18 +77,18 @@ If a language has already been added, and you would like to contribute new trans
If you would like the project to be translated to another language, first head over to https://crwd.in/paperless-ngx to check if that language has already been enabled for translation. If you would like the project to be translated to another language, first head over to https://crwd.in/paperless-ngx to check if that language has already been enabled for translation.
If not, please request the language to be added by creating an issue on GitHub. The issue should contain: If not, please request the language to be added by creating an issue on GitHub. The issue should contain:
* English name of the language (the localized name can be added on Crowdin). - English name of the language (the localized name can be added on Crowdin).
* ISO language code. A list of those can be found here: https://support.crowdin.com/enterprise/language-codes/ - ISO language code. A list of those can be found here: https://support.crowdin.com/enterprise/language-codes/
* Date format commonly used for the language, e.g. dd/mm/yyyy, mm/dd/yyyy, etc. - Date format commonly used for the language, e.g. dd/mm/yyyy, mm/dd/yyyy, etc.
After the language has been added and some translations have been made on Crowdin, the language needs to be enabled in the code. After the language has been added and some translations have been made on Crowdin, the language needs to be enabled in the code.
Note that there is no need to manually add a .po of .xlf file as those will be automatically generated and imported from Crowdin. Note that there is no need to manually add a .po of .xlf file as those will be automatically generated and imported from Crowdin.
The following files need to be changed: 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 _getLanguageOptions_ method)
* 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.
Note that _en-us_ needs to stay on top of the list, as it is the default project language Note that _en-us_ needs to stay on top of the list, as it is the default project language
@ -102,26 +104,26 @@ Paperless-ngx is a community project. We do our best to delegate permission and
As of writing, there are 21 members in paperless-ngx. 4 of these people have complete administrative privileges to the repo: As of writing, there are 21 members in paperless-ngx. 4 of these people have complete administrative privileges to the repo:
* [@shamoon](https://github.com/shamoon) - [@shamoon](https://github.com/shamoon)
* [@bauerj](https://github.com/bauerj) - [@bauerj](https://github.com/bauerj)
* [@qcasey](https://github.com/qcasey) - [@qcasey](https://github.com/qcasey)
* [@FrankStrieter](https://github.com/FrankStrieter) - [@FrankStrieter](https://github.com/FrankStrieter)
There are 5 teams collaborating on specific tasks within paperless-ngx: There are 5 teams collaborating on specific tasks within paperless-ngx:
* @paperless-ngx/backend (Python / django) - @paperless-ngx/backend (Python / django)
* @paperless-ngx/frontend (JavaScript / Typescript) - @paperless-ngx/frontend (JavaScript / Typescript)
* @paperless-ngx/ci-cd (GitHub Actions / Deployment) - @paperless-ngx/ci-cd (GitHub Actions / Deployment)
* @paperless-ngx/issues (Issue triage) - @paperless-ngx/issues (Issue triage)
* @paperless-ngx/test (General testing for larger PRs) - @paperless-ngx/test (General testing for larger PRs)
## Permissions ## Permissions
All team members are notified when mentioned or assigned to a relevant issue or pull request. Additionally, each team has slightly different access to paperless-ngx: All team members are notified when mentioned or assigned to a relevant issue or pull request. Additionally, each team has slightly different access to paperless-ngx:
* The **test** team has no special permissions. - The **test** team has no special permissions.
* The **issues** team has `triage` access. This means they can organize issues and pull requests. - The **issues** team has `triage` access. This means they can organize issues and pull requests.
* The **backend**, **frontend**, and **ci-cd** teams have `write` access. This means they can approve PRs and push code, containers, releases, and more. - The **backend**, **frontend**, and **ci-cd** teams have `write` access. This means they can approve PRs and push code, containers, releases, and more.
## Joining ## Joining

View File

@ -3,64 +3,16 @@ FROM node:16 AS compile-frontend
COPY . /src COPY . /src
WORKDIR /src/src-ui WORKDIR /src/src-ui
RUN npm update npm -g && npm install RUN npm update npm -g && npm ci --no-optional
RUN ./node_modules/.bin/ng build --configuration production RUN ./node_modules/.bin/ng build --configuration production
FROM ghcr.io/paperless-ngx/builder/ngx-base:1.7.0 as main-app
FROM ubuntu:20.04 AS jbig2enc LABEL org.opencontainers.image.authors="paperless-ngx team <hello@paperless-ngx.com>"
LABEL org.opencontainers.image.documentation="https://paperless-ngx.readthedocs.io/en/latest/"
WORKDIR /usr/src/jbig2enc LABEL org.opencontainers.image.source="https://github.com/paperless-ngx/paperless-ngx"
LABEL org.opencontainers.image.url="https://github.com/paperless-ngx/paperless-ngx"
RUN apt-get update && apt-get install -y --no-install-recommends build-essential automake libtool libleptonica-dev zlib1g-dev git ca-certificates LABEL org.opencontainers.image.licenses="GPL-3.0-only"
RUN git clone https://github.com/agl/jbig2enc .
RUN ./autogen.sh
RUN ./configure && make
FROM python:3.9-slim-bullseye
# Binary dependencies
RUN apt-get update \
&& apt-get -y --no-install-recommends install \
# Basic dependencies
curl \
gnupg \
imagemagick \
gettext \
tzdata \
gosu \
# fonts for text file thumbnail generation
fonts-liberation \
# for Numpy
libatlas-base-dev \
libxslt1-dev \
# thumbnail size reduction
optipng \
libxml2 \
pngquant \
unpaper \
zlib1g \
ghostscript \
icc-profiles-free \
# Mime type detection
file \
libmagic-dev \
media-types \
# OCRmyPDF dependencies
liblept5 \
tesseract-ocr \
tesseract-ocr-eng \
tesseract-ocr-deu \
tesseract-ocr-fra \
tesseract-ocr-ita \
tesseract-ocr-spa \
&& rm -rf /var/lib/apt/lists/*
# copy jbig2enc
COPY --from=jbig2enc /usr/src/jbig2enc/src/.libs/libjbig2enc* /usr/local/lib/
COPY --from=jbig2enc /usr/src/jbig2enc/src/jbig2 /usr/local/bin/
COPY --from=jbig2enc /usr/src/jbig2enc/src/*.h /usr/local/include/
WORKDIR /usr/src/paperless/src/ WORKDIR /usr/src/paperless/src/
@ -68,47 +20,31 @@ COPY requirements.txt ../
# Python dependencies # Python dependencies
RUN apt-get update \ RUN apt-get update \
# python-Levenshtein still needs to be compiled here
&& apt-get -y --no-install-recommends install \ && apt-get -y --no-install-recommends install \
build-essential \ build-essential \
libpq-dev \ && python3 -m pip install --upgrade --no-cache-dir pip wheel \
git \ && python3 -m pip install --default-timeout=1000 --upgrade --no-cache-dir supervisor \
zlib1g-dev \ && python3 -m pip install --default-timeout=1000 --no-cache-dir -r ../requirements.txt \
libjpeg62-turbo-dev \ && apt-get -y purge build-essential \
&& if [ "$(uname -m)" = "armv7l" ] || [ "$(uname -m)" = "aarch64" ]; \ && apt-get -y autoremove --purge \
then echo "Building qpdf" \ && rm -rf /var/lib/apt/lists/*
&& mkdir -p /usr/src/qpdf \
&& cd /usr/src/qpdf \
&& git clone https://github.com/qpdf/qpdf.git . \
&& git checkout --quiet release-qpdf-10.6.2 \
&& ./configure \
&& make \
&& make install \
&& cd /usr/src/paperless/src/ \
&& rm -rf /usr/src/qpdf; \
else \
echo "Skipping qpdf build because pikepdf binary wheels are available."; \
fi \
&& python3 -m pip install --upgrade pip wheel \
&& python3 -m pip install --default-timeout=1000 --upgrade --no-cache-dir supervisor \
&& python3 -m pip install --default-timeout=1000 --no-cache-dir -r ../requirements.txt \
&& apt-get -y purge build-essential git zlib1g-dev libjpeg62-turbo-dev \
&& apt-get -y autoremove --purge \
&& rm -rf /var/lib/apt/lists/*
# setup docker-specific things # setup docker-specific things
COPY docker/ ./docker/ COPY docker/ ./docker/
RUN cd docker \ RUN cd docker \
&& cp imagemagick-policy.xml /etc/ImageMagick-6/policy.xml \ && cp imagemagick-policy.xml /etc/ImageMagick-6/policy.xml \
&& mkdir /var/log/supervisord /var/run/supervisord \ && mkdir /var/log/supervisord /var/run/supervisord \
&& cp supervisord.conf /etc/supervisord.conf \ && cp supervisord.conf /etc/supervisord.conf \
&& cp docker-entrypoint.sh /sbin/docker-entrypoint.sh \ && cp docker-entrypoint.sh /sbin/docker-entrypoint.sh \
&& cp docker-prepare.sh /sbin/docker-prepare.sh \ && chmod 755 /sbin/docker-entrypoint.sh \
&& chmod 755 /sbin/docker-entrypoint.sh \ && cp docker-prepare.sh /sbin/docker-prepare.sh \
&& chmod +x install_management_commands.sh \ && chmod 755 /sbin/docker-prepare.sh \
&& ./install_management_commands.sh \ && chmod +x install_management_commands.sh \
&& cd .. \ && ./install_management_commands.sh \
&& rm docker -rf && cd .. \
&& rm -rf docker/
COPY gunicorn.conf.py ../ COPY gunicorn.conf.py ../
@ -117,18 +53,18 @@ COPY --from=compile-frontend /src/src/ ./
# add users, setup scripts # add users, setup scripts
RUN addgroup --gid 1000 paperless \ RUN addgroup --gid 1000 paperless \
&& useradd --uid 1000 --gid paperless --home-dir /usr/src/paperless paperless \ && useradd --uid 1000 --gid paperless --home-dir /usr/src/paperless paperless \
&& chown -R paperless:paperless ../ \ && chown -R paperless:paperless ../ \
&& gosu paperless python3 manage.py collectstatic --clear --no-input \ && gosu paperless python3 manage.py collectstatic --clear --no-input \
&& gosu paperless python3 manage.py compilemessages && gosu paperless python3 manage.py compilemessages
VOLUME ["/usr/src/paperless/data", \
"/usr/src/paperless/media", \
"/usr/src/paperless/consume", \
"/usr/src/paperless/export"]
VOLUME ["/usr/src/paperless/data", "/usr/src/paperless/media", "/usr/src/paperless/consume", "/usr/src/paperless/export"]
ENTRYPOINT ["/sbin/docker-entrypoint.sh"] ENTRYPOINT ["/sbin/docker-entrypoint.sh"]
EXPOSE 8000
CMD ["/usr/local/bin/supervisord", "-c", "/etc/supervisord.conf"]
LABEL org.opencontainers.image.authors="paperless-ngx team <hello@paperless-ngx.com>" EXPOSE 8000
LABEL org.opencontainers.image.documentation="https://paperless-ngx.readthedocs.io/en/latest/"
LABEL org.opencontainers.image.source="https://github.com/paperless-ngx/paperless-ngx" CMD ["/usr/local/bin/supervisord", "-c", "/etc/supervisord.conf"]
LABEL org.opencontainers.image.url="https://github.com/paperless-ngx/paperless-ngx"
LABEL org.opencontainers.image.licenses="GPL-3.0-only"

35
Pipfile
View File

@ -9,35 +9,36 @@ verify_ssl = true
name = "piwheels" name = "piwheels"
[packages] [packages]
dateparser = "~=1.1.0" dateparser = "~=1.1"
django = "~=3.2" django = "~=4.0"
django-cors-headers = "*" django-cors-headers = "*"
django-extensions = "*" django-extensions = "*"
django-filter = "~=21.1" django-filter = "~=21.1"
django-q = "~=1.3.4" django-q = "~=1.3"
djangorestframework = "~=3.13.1" djangorestframework = "~=3.13"
filelock = "*" filelock = "*"
fuzzywuzzy = {extras = ["speedup"], version = "*"} fuzzywuzzy = {extras = ["speedup"], version = "*"}
gunicorn = "*" gunicorn = "*"
imap-tools = "*" imap-tools = "*"
langdetect = "*" langdetect = "*"
numpy = "~=1.22.0"
pathvalidate = "*" pathvalidate = "*"
pillow = "~=9.0" pillow = "~=9.1"
pikepdf = "~=5.0" # Any version update to pikepdf requires a base image update
pikepdf = "~=5.1"
python-gnupg = "*" python-gnupg = "*"
python-dotenv = "*" python-dotenv = "*"
python-dateutil = "*" python-dateutil = "*"
python-magic = "*" python-magic = "*"
psycopg2-binary = "*" # Any version update to psycopg2 requires a base image update
psycopg2 = "*"
redis = "*" redis = "*"
# Pinned because aarch64 wheels and updates cause warnings when loading the classifier model. # Pinned because aarch64 wheels and updates cause warnings when loading the classifier model.
scikit-learn="==0.24.0" scikit-learn="==1.0.2"
whitenoise = "~=6.0.0" whitenoise = "~=6.0.0"
watchdog = "~=2.1.0" watchdog = "~=2.1.0"
whoosh="~=2.7.4" whoosh="~=2.7.4"
inotifyrecursive = "~=0.3.4" inotifyrecursive = "~=0.3"
ocrmypdf = "~=13.4.0" ocrmypdf = "~=13.4"
tqdm = "*" tqdm = "*"
tika = "*" tika = "*"
# TODO: This will sadly also install daphne+dependencies, # TODO: This will sadly also install daphne+dependencies,
@ -46,11 +47,12 @@ channels = "~=3.0"
channels-redis = "*" channels-redis = "*"
uvicorn = {extras = ["standard"], version = "*"} uvicorn = {extras = ["standard"], version = "*"}
concurrent-log-handler = "*" concurrent-log-handler = "*"
# uvloop 0.15+ incompatible with python 3.6
uvloop = "~=0.16"
cryptography = "~=36.0.1"
"pdfminer.six" = "*" "pdfminer.six" = "*"
"backports.zoneinfo" = "*" "backports.zoneinfo" = {version = "*", markers = "python_version < '3.9'"}
"importlib-resources" = {version = "*", markers = "python_version < '3.9'"}
zipp = {version = "*", markers = "python_version < '3.9'"}
pyzbar = "*"
pdf2image = "*"
[dev-packages] [dev-packages]
coveralls = "*" coveralls = "*"
@ -62,7 +64,8 @@ pytest-django = "*"
pytest-env = "*" pytest-env = "*"
pytest-sugar = "*" pytest-sugar = "*"
pytest-xdist = "*" pytest-xdist = "*"
sphinx = "~=3.4.2" sphinx = "~=4.5.0"
sphinx_rtd_theme = "*" sphinx_rtd_theme = "*"
tox = "*" tox = "*"
black = "*" black = "*"
pre-commit = "*"

1153
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,23 +10,23 @@
</p> </p>
<!-- omit in toc --> <!-- omit in toc -->
# Paperless-ngx # Paperless-ngx
Paperless-ngx is a document management system that transforms your physical documents into a searchable online archive so you can keep, well, *less paper*. Paperless-ngx is a document management system that transforms your physical documents into a searchable online archive so you can keep, well, _less paper_.
Paperless-ngx forked from [paperless-ng](https://github.com/jonaswinkler/paperless-ng) to continue the great work and distribute responsibility of supporting and advancing the project among a team of people. [Consider joining us!](#community-support) Discussion of this transition can be found in issues Paperless-ngx forked from [paperless-ng](https://github.com/jonaswinkler/paperless-ng) to continue the great work and distribute responsibility of supporting and advancing the project among a team of people. [Consider joining us!](#community-support) Discussion of this transition can be found in issues
[#1599](https://github.com/jonaswinkler/paperless-ng/issues/1599) and [#1632](https://github.com/jonaswinkler/paperless-ng/issues/1632). [#1599](https://github.com/jonaswinkler/paperless-ng/issues/1599) and [#1632](https://github.com/jonaswinkler/paperless-ng/issues/1632).
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.* 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)
- [Contributing](#contributing) - [Contributing](#contributing)
- [Community Support](#community-support) - [Community Support](#community-support)
- [Translation](#translation) - [Translation](#translation)
- [Feature Requests](#feature-requests) - [Feature Requests](#feature-requests)
- [Bugs](#bugs) - [Bugs](#bugs)
- [Affiliated Projects](#affiliated-projects) - [Affiliated Projects](#affiliated-projects)
- [Important Note](#important-note) - [Important Note](#important-note)
@ -35,28 +35,28 @@ A demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com)
![Dashboard](https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docs/_static/screenshots/documents-wchrome.png#gh-light-mode-only) ![Dashboard](https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docs/_static/screenshots/documents-wchrome.png#gh-light-mode-only)
![Dashboard](https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docs/_static/screenshots/documents-wchrome-dark.png#gh-dark-mode-only) ![Dashboard](https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docs/_static/screenshots/documents-wchrome-dark.png#gh-dark-mode-only)
* Organize and index your scanned documents with tags, correspondents, types, and more. - Organize and index your scanned documents with tags, correspondents, types, and more.
* Performs OCR on your documents, adds selectable text to image only documents and adds tags, correspondents and document types to your documents. - Performs OCR on your documents, adds selectable text to image only documents and adds tags, correspondents and document types to your documents.
* Supports PDF documents, images, plain text files, and Office documents (Word, Excel, Powerpoint, and LibreOffice equivalents). - Supports PDF documents, images, plain text files, and Office documents (Word, Excel, Powerpoint, and LibreOffice equivalents).
* Office document support is optional and provided by Apache Tika (see [configuration](https://paperless-ngx.readthedocs.io/en/latest/configuration.html#tika-settings)) - Office document support is optional and provided by Apache Tika (see [configuration](https://paperless-ngx.readthedocs.io/en/latest/configuration.html#tika-settings))
* Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely. - Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely.
* Single page application front end. - Single page application front end.
* Includes a dashboard that shows basic statistics and has document upload. - Includes a dashboard that shows basic statistics and has document upload.
* Filtering by tags, correspondents, types, and more. - Filtering by tags, correspondents, types, and more.
* Customizable views can be saved and displayed on the dashboard. - Customizable views can be saved and displayed on the dashboard.
* Full text search helps you find what you need. - Full text search helps you find what you need.
* Auto completion suggests relevant words from your documents. - Auto completion suggests relevant words from your documents.
* Results are sorted by relevance to your search query. - Results are sorted by relevance to your search query.
* Highlighting shows you which parts of the document matched the query. - Highlighting shows you which parts of the document matched the query.
* Searching for similar documents ("More like this") - Searching for similar documents ("More like this")
* Email processing: Paperless adds documents from your email accounts. - Email processing: Paperless adds documents from your email accounts.
* Configure multiple accounts and filters for each account. - Configure multiple accounts and filters for each account.
* When adding documents from mail, paperless can move these mail to a new folder, mark them as read, flag them as important or delete them. - When adding documents from mail, paperless can move these mail to a new folder, mark them as read, flag them as important or delete them.
* Machine learning powered document matching. - Machine learning powered document matching.
* Paperless-ngx learns from your documents and will be able to automatically assign tags, correspondents and types to documents once you've stored a few documents in paperless. - Paperless-ngx learns from your documents and will be able to automatically assign tags, correspondents and types to documents once you've stored a few documents in paperless.
* 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.
* [More screenshots are available in the documentation](https://paperless-ngx.readthedocs.io/en/latest/screenshots.html). - [More screenshots are available in the documentation](https://paperless-ngx.readthedocs.io/en/latest/screenshots.html).
# Getting started # Getting started
@ -65,7 +65,7 @@ The easiest way to deploy paperless is docker-compose. The files in the [`/docke
If you'd like to jump right in, you can configure a docker-compose environment with our install script: If you'd like to jump right in, you can configure a docker-compose environment with our install script:
```bash ```bash
bash -c "$(curl -L https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/master/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://paperless-ngx.readthedocs.io/en/latest/setup.html#installation) has a step by step guide on how to do it. Alternatively, you can install the dependencies and setup apache and a database server yourself. The [documentation](https://paperless-ngx.readthedocs.io/en/latest/setup.html#installation) has a step by step guide on how to do it.
@ -73,6 +73,7 @@ Alternatively, you can install the dependencies and setup apache and a database
Migrating from Paperless-ng is easy, just drop in the new docker image! See the [documentation on migrating](https://paperless-ngx.readthedocs.io/en/latest/setup.html#migrating-from-paperless-ng) for more details. Migrating from Paperless-ng is easy, just drop in the new docker image! See the [documentation on migrating](https://paperless-ngx.readthedocs.io/en/latest/setup.html#migrating-from-paperless-ng) for more details.
<!-- omit in toc --> <!-- omit in toc -->
### Documentation ### Documentation
The documentation for Paperless-ngx is available on [ReadTheDocs](https://paperless-ngx.readthedocs.io/). The documentation for Paperless-ngx is available on [ReadTheDocs](https://paperless-ngx.readthedocs.io/).
@ -101,18 +102,18 @@ For bugs please [open an issue](https://github.com/paperless-ngx/paperless-ngx/i
Paperless has been around a while now, and people are starting to build stuff on top of it. If you're one of those people, we can add your project to this list: Paperless has been around a while now, and people are starting to build stuff on top of it. If you're one of those people, we can add your project to this list:
* [Paperless App](https://github.com/bauerj/paperless_app): An Android/iOS app for Paperless-ngx. Also works with the original Paperless and Paperless-ng. - [Paperless App](https://github.com/bauerj/paperless_app): An Android/iOS app for Paperless-ngx. Also works with the original Paperless and Paperless-ngx.
* [Paperless Share](https://github.com/qcasey/paperless_share). Share any files from your Android application with paperless. Very simple, but works with all of the mobile scanning apps out there that allow you to share scanned documents. - [Paperless Share](https://github.com/qcasey/paperless_share). Share any files from your Android application with paperless. Very simple, but works with all of the mobile scanning apps out there that allow you to share scanned documents.
* [Scan to Paperless](https://github.com/sbrunner/scan-to-paperless): Scan and prepare (crop, deskew, OCR, ...) your documents for Paperless. - [Scan to Paperless](https://github.com/sbrunner/scan-to-paperless): Scan and prepare (crop, deskew, OCR, ...) your documents for Paperless.
These projects also exist, but their status and compatibility with paperless-ngx is unknown. These projects also exist, but their status and compatibility with paperless-ngx is unknown.
* [paperless-cli](https://github.com/stgarf/paperless-cli): A golang command line binary to interact with a Paperless instance. - [paperless-cli](https://github.com/stgarf/paperless-cli): A golang command line binary to interact with a Paperless instance.
This project also exists, but needs updates to be compatible with paperless-ngx. This project also exists, but needs updates to be compatible with paperless-ngx.
* [Paperless Desktop](https://github.com/thomasbrueggemann/paperless-desktop): A desktop UI for your Paperless installation. Runs on Mac, Linux, and Windows. - [Paperless Desktop](https://github.com/thomasbrueggemann/paperless-desktop): A desktop UI for your Paperless installation. Runs on Mac, Linux, and Windows.
Known issues on Mac: (Could not load reminders and documents) Known issues on Mac: (Could not load reminders and documents)
# Important Note # Important Note

View File

@ -22,6 +22,10 @@
# Docker setup does not use the configuration file. # Docker setup does not use the configuration file.
# A few commonly adjusted settings are provided below. # A few commonly adjusted settings are provided below.
# This is required if you will be exposing Paperless-ngx on a public domain
# (if doing so please consider security measures such as reverse proxy)
#PAPERLESS_URL=https://paperless.example.com
# Adjust this key if you plan to make paperless available publicly. It should # Adjust this key if you plan to make paperless available publicly. It should
# be a very long sequence of random characters. You don't need to remember it. # be a very long sequence of random characters. You don't need to remember it.
#PAPERLESS_SECRET_KEY=change-me #PAPERLESS_SECRET_KEY=change-me

View File

@ -1,6 +1,7 @@
# docker-compose file for running paperless from the Docker Hub. # docker-compose file for running paperless from the docker container registry.
# This file contains everything paperless needs to run. # This file contains everything paperless needs to run.
# Paperless supports amd64, arm and arm64 hardware. # Paperless supports amd64, arm and arm64 hardware. The apache/tika image
# does not support arm or arm64, however.
# #
# All compose files of paperless configure paperless in the following way: # All compose files of paperless configure paperless in the following way:
# #
@ -79,8 +80,9 @@ services:
gotenberg: gotenberg:
image: gotenberg/gotenberg:7 image: gotenberg/gotenberg:7
restart: unless-stopped restart: unless-stopped
environment: command:
CHROMIUM_DISABLE_ROUTES: 1 - "gotenberg"
- "--chromium-disable-routes=true"
tika: tika:
image: apache/tika image: apache/tika

View File

@ -0,0 +1,85 @@
# docker-compose file for running paperless from the docker container registry.
# This file contains everything paperless needs to run.
# Paperless supports amd64, arm and arm64 hardware.
#
# All compose files of paperless configure paperless in the following way:
#
# - Paperless is (re)started on system boot, if it was running before shutdown.
# - Docker volumes for storing data are managed by Docker.
# - Folders for importing and exporting files are created in the same directory
# as this file and mounted to the correct folders inside the container.
# - Paperless listens on port 8000.
#
# SQLite is used as the database. The SQLite file is stored in the data volume.
#
# iwishiwasaneagle/apache-tika-arm docker image is used to enable arm64 arch
# which apache/tika does not currently support.
#
# In addition to that, this docker-compose file adds the following optional
# configurations:
#
# - Apache Tika and Gotenberg servers are started with paperless and paperless
# is configured to use these services. These provide support for consuming
# Office documents (Word, Excel, Power Point and their LibreOffice counter-
# parts.
#
# To install and update paperless with this file, do the following:
#
# - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env'
# and '.env' into a folder.
# - Run 'docker-compose pull'.
# - Run 'docker-compose run --rm webserver createsuperuser' to create a user.
# - Run 'docker-compose up -d'.
#
# For more extensive installation and update instructions, refer to the
# documentation.
version: "3.4"
services:
broker:
image: redis:6.0
restart: unless-stopped
volumes:
- redisdata:/data
webserver:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
restart: unless-stopped
depends_on:
- broker
- gotenberg
- tika
ports:
- 8000:8000
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000"]
interval: 30s
timeout: 10s
retries: 5
volumes:
- data:/usr/src/paperless/data
- media:/usr/src/paperless/media
- ./export:/usr/src/paperless/export
- ./consume:/usr/src/paperless/consume
env_file: docker-compose.env
environment:
PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_TIKA_ENABLED: 1
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: gotenberg/gotenberg:7
restart: unless-stopped
command:
- "gotenberg"
- "--chromium-disable-routes=true"
tika:
image: iwishiwasaneagle/apache-tika-arm@sha256:a78c25ffe57ecb1a194b2859d42a61af46e9e845191512b8f1a4bf90578ffdfd
restart: unless-stopped
volumes:
data:
media:
redisdata:

View File

@ -1,6 +1,7 @@
# docker-compose file for running paperless from the Docker Hub. # docker-compose file for running paperless from the docker container registry.
# This file contains everything paperless needs to run. # This file contains everything paperless needs to run.
# Paperless supports amd64, arm and arm64 hardware. # Paperless supports amd64, arm and arm64 hardware. The apache/tika image
# does not support arm or arm64, however.
# #
# All compose files of paperless configure paperless in the following way: # All compose files of paperless configure paperless in the following way:
# #
@ -68,8 +69,9 @@ services:
gotenberg: gotenberg:
image: gotenberg/gotenberg:7 image: gotenberg/gotenberg:7
restart: unless-stopped restart: unless-stopped
environment: command:
CHROMIUM_DISABLE_ROUTES: 1 - "gotenberg"
- "--chromium-disable-routes=true"
tika: tika:
image: apache/tika image: apache/tika

View File

@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
set -e set -e
@ -10,7 +10,7 @@ map_uidgid() {
USERMAP_NEW_GID=${USERMAP_GID:-${USERMAP_ORIG_GID:-$USERMAP_NEW_UID}} USERMAP_NEW_GID=${USERMAP_GID:-${USERMAP_ORIG_GID:-$USERMAP_NEW_UID}}
if [[ ${USERMAP_NEW_UID} != "${USERMAP_ORIG_UID}" || ${USERMAP_NEW_GID} != "${USERMAP_ORIG_GID}" ]]; then if [[ ${USERMAP_NEW_UID} != "${USERMAP_ORIG_UID}" || ${USERMAP_NEW_GID} != "${USERMAP_ORIG_GID}" ]]; then
echo "Mapping UID and GID for paperless:paperless to $USERMAP_NEW_UID:$USERMAP_NEW_GID" echo "Mapping UID and GID for paperless:paperless to $USERMAP_NEW_UID:$USERMAP_NEW_GID"
usermod -u "${USERMAP_NEW_UID}" paperless usermod -o -u "${USERMAP_NEW_UID}" paperless
groupmod -o -g "${USERMAP_NEW_GID}" paperless groupmod -o -g "${USERMAP_NEW_GID}" paperless
fi fi
} }
@ -56,12 +56,12 @@ install_languages() {
# continue # continue
#fi #fi
if dpkg -s $pkg &>/dev/null; then if dpkg -s "$pkg" &>/dev/null; then
echo "Package $pkg already installed!" echo "Package $pkg already installed!"
continue continue
fi fi
if ! apt-cache show $pkg &>/dev/null; then if ! apt-cache show "$pkg" &>/dev/null; then
echo "Package $pkg not found! :(" echo "Package $pkg not found! :("
continue continue
fi fi
@ -77,7 +77,7 @@ install_languages() {
echo "Paperless-ngx docker container starting..." echo "Paperless-ngx docker container starting..."
# Install additional languages if specified # Install additional languages if specified
if [[ ! -z "$PAPERLESS_OCR_LANGUAGES" ]]; then if [[ -n "$PAPERLESS_OCR_LANGUAGES" ]]; then
install_languages "$PAPERLESS_OCR_LANGUAGES" install_languages "$PAPERLESS_OCR_LANGUAGES"
fi fi

View File

@ -6,14 +6,11 @@ wait_for_postgres() {
echo "Waiting for PostgreSQL to start..." echo "Waiting for PostgreSQL to start..."
host="${PAPERLESS_DBHOST}" host="${PAPERLESS_DBHOST:=localhost}"
port="${PAPERLESS_DBPORT}" port="${PAPERLESS_DBPORT:=5342}"
if [[ -z $port ]]; then
port="5432"
fi
while ! </dev/tcp/$host/$port; do while [ ! "$(pg_isready -h $host -p $port)" ]; do
if [ $attempt_num -eq $max_attempts ]; then if [ $attempt_num -eq $max_attempts ]; then
echo "Unable to connect to database." echo "Unable to connect to database."
@ -23,7 +20,7 @@ wait_for_postgres() {
fi fi
attempt_num=$(expr "$attempt_num" + 1) attempt_num=$(("$attempt_num" + 1))
sleep 5 sleep 5
done done
} }

View File

@ -1,3 +1,5 @@
#!/usr/bin/env bash
for command in document_archiver document_exporter document_importer mail_fetcher document_create_classifier document_index document_renamer document_retagger document_thumbnails document_sanity_checker manage_superuser; for command in document_archiver document_exporter document_importer mail_fetcher document_create_classifier document_index document_renamer document_retagger document_thumbnails document_sanity_checker manage_superuser;
do do
echo "installing $command..." echo "installing $command..."

View File

@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
set -e set -e
@ -6,10 +6,10 @@ cd /usr/src/paperless/src/
if [[ $(id -u) == 0 ]] ; if [[ $(id -u) == 0 ]] ;
then then
gosu paperless python3 manage.py management_command "$@" gosu paperless python3 manage.py management_command "$@"
elif [[ $(id -un) == "paperless" ]] ; elif [[ $(id -un) == "paperless" ]] ;
then then
python3 manage.py management_command "$@" python3 manage.py management_command "$@"
else else
echo "Unknown user." echo "Unknown user."
fi fi

View File

@ -1,5 +1,4 @@
FROM python:3.5.1 FROM python:3.5.1
MAINTAINER Pit Kleyersburg <pitkley@googlemail.com>
# Install Sphinx and Pygments # Install Sphinx and Pygments
RUN pip install Sphinx Pygments RUN pip install Sphinx Pygments

View File

@ -379,7 +379,7 @@ the naming scheme.
The command takes no arguments and processes all your documents at once. The command takes no arguments and processes all your documents at once.
Learn how to use :ref:`Management Utilities<Management utilities>`. Learn how to use :ref:`Management Utilities<utilities-management-commands>`.
.. _utilities-sanity-checker: .. _utilities-sanity-checker:

View File

@ -179,13 +179,14 @@ Assumed you have ``/home/foo/paperless-ngx/scripts/post-consumption-example.sh``
You can pass that script into the consumer container via a host mount in your ``docker-compose.yml``. You can pass that script into the consumer container via a host mount in your ``docker-compose.yml``.
.. code:: bash .. code:: bash
...
consumer: ...
... consumer:
volumes: ...
... volumes:
- /home/paperless-ngx/scripts:/path/in/container/scripts/ ...
... - /home/paperless-ngx/scripts:/path/in/container/scripts/
...
Example (docker-compose.yml): ``- /home/foo/paperless-ngx/scripts:/usr/src/paperless/scripts`` Example (docker-compose.yml): ``- /home/foo/paperless-ngx/scripts:/usr/src/paperless/scripts``

View File

@ -5,6 +5,87 @@
Changelog Changelog
********* *********
paperless-ngx 1.7.0
###################
Breaking Changes
* ``PAPERLESS_URL`` is now required when using a reverse proxy. See `#674`_.
Features
* Allow setting more than one tag in mail rules `@jonasc`_ (#270)
* global drag'n'drop `@shamoon`_ (#283).
* Fix: download buttons should disable while waiting `@shamoon`_ (#630).
* Update checker `@shamoon`_ (#591).
* Show prompt on password-protected pdfs `@shamoon`_ (#564).
* Filtering query params aka browser navigation for filtering `@shamoon`_ (#540).
* Clickable tags in dashboard widgets `@shamoon`_ (#515).
* Add bottom pagination `@shamoon`_ (#372).
* Feature barcode splitter `@gador`_ (#532).
* App loading screen `@shamoon`_ (#298).
* Use progress bar for delayed buttons `@shamoon`_ (#415).
* Add minimum length for documents text filter `@shamoon`_ (#401).
* Added nav buttons in the document detail view `@GruberViktor`_ (#273).
* Improve date keyboard input `@shamoon`_ (#253).
* Color theming `@shamoon`_ (#243).
* Parse dates when entered without separators `@GruberViktor`_ (#250).
Bug Fixes
* add "localhost" to ALLOWED_HOSTS `@gador`_ (#700).
* Fix: scanners table `@qcasey`_ (#690).
* Adds wait for file before consuming `@stumpylog`_ (#483).
* Fix: frontend document editing erases time data `@shamoon`_ (#654).
* Increase length of SavedViewFilterRule `@stumpylog`_ (#612).
* Fixes attachment filename matching during mail fetching `@stumpylog`_ (#680).
* Add ``PAPERLESS_URL`` env variable & CSRF var `@shamoon`_ (#674).
* Fix: download buttons should disable while waiting `@shamoon`_ (#630).
* Fixes downloaded filename, add more consumer ignore settings `@stumpylog`_ (#599).
* FIX BUG: case-sensitive matching was not possible `@danielBreitlauch`_ (#594).
* uses shutil.move instead of rename `@gador`_ (#617).
* Fix npm deps 01.02.22 2 `@shamoon`_ (#610).
* Fix npm dependencies 01.02.22 `@shamoon`_ (#600).
* fix issue 416: implement PAPERLESS_OCR_MAX_IMAGE_PIXELS `@hacker-h`_ (#441).
* fix: exclude cypress from build in Dockerfile `@FrankStrieter`_ (#526).
* Corrections to pass pre-commit hooks `@schnuffle`_ (#454).
* Fix 311 unable to click checkboxes in document list `@shamoon`_ (#313).
* Fix imap tools bug `@stumpylog`_ (#393).
* Fix filterable dropdown buttons arent translated `@shamoon`_ (#366).
* Fix 224: "Auto-detected date is day before receipt date" `@a17t`_ (#246).
* Fix minor sphinx errors `@shamoon`_ (#322).
* Fix page links hidden `@shamoon`_ (#314).
* Fix: Include excluded items in dropdown count `@shamoon`_ (#263).
Translation
* `@miku323`_ contributed to Slovenian translation.
* `@FaintGhost`_ contributed to Chinese Simplified translation.
* `@DarkoBG79`_ contributed to Serbian translation.
* `Kemal Secer`_ contributed to Turkish translation.
* `@Prominence`_ contributed to Belarusian translation.
Documentation
* Fix: scanners table `@qcasey`_ (#690).
* Add `PAPERLESS_URL` env variable & CSRF var `@shamoon`_ (#674).
* Fixes downloaded filename, add more consumer ignore settings `@stumpylog`_ (#599).
* fix issue 416: implement ``PAPERLESS_OCR_MAX_IMAGE_PIXELS`` `@hacker-h`_ (#441).
* Fix minor sphinx errors `@shamoon`_ (#322).
Maintenance
* Add ``PAPERLESS_URL`` env variable & CSRF var `@shamoon`_ (#674).
* Chore: Implement release-drafter action for Changelogs `@qcasey`_ (#669).
* Chore: Add CODEOWNERS `@qcasey`_ (#667).
* Support docker-compose v2 in install `@stumpylog`_ (#611).
* Add Belarusian localization `@shamoon`_ (#588).
* Add Turkish localization `@shamoon`_ (#536).
* Add Serbian localization `@shamoon`_ (#504).
* Create PULL_REQUEST_TEMPLATE.md `@shamoon`_ (#304).
* Add Chinese localization `@shamoon`_ (#247).
* Add Slovenian language for frontend `@shamoon`_ (#315).
paperless-ngx 1.6.0 paperless-ngx 1.6.0
################### ###################
@ -144,7 +225,7 @@ paperless-ng 1.4.0
* New URL pattern for accessing documents by ASN directly (http://<paperless>/asn/123) * New URL pattern for accessing documents by ASN directly (http://<paperless>/asn/123)
* Added logging when executing pre- and post-consume scripts. * Added logging when executing pre* and post-consume scripts.
* Better error logging during document consumption. * Better error logging during document consumption.
@ -1580,6 +1661,16 @@ bulk of the work on this big change.
.. _@azapater: https://github.com/azapater .. _@azapater: https://github.com/azapater
.. _@tim-vogel: https://github.com/tim-vogel .. _@tim-vogel: https://github.com/tim-vogel
.. _@jschpp: https://github.com/jschpp .. _@jschpp: https://github.com/jschpp
.. _@schnuffle: https://github.com/schnuffle
.. _@GruberViktor: https://github.com/gruberviktor
.. _@hacker-h: https://github.com/hacker-h
.. _@danielBreitlauch: https://github.com/danielbreitlauch
.. _@miku323: https://github.com/miku323
.. _@FaintGhost: https://github.com/FaintGhost
.. _@DarkoBG79: https://github.com/DarkoBG79
.. _Kemal Secer: https://crowdin.com/profile/kemal.secer
.. _@Prominence: https://github.com/Prominence
.. _@jonasc: https://github.com/jonasc
.. _#20: https://github.com/the-paperless-project/paperless/issues/20 .. _#20: https://github.com/the-paperless-project/paperless/issues/20
.. _#44: https://github.com/the-paperless-project/paperless/issues/44 .. _#44: https://github.com/the-paperless-project/paperless/issues/44
@ -1688,6 +1779,7 @@ bulk of the work on this big change.
.. _#488: https://github.com/the-paperless-project/paperless/pull/488 .. _#488: https://github.com/the-paperless-project/paperless/pull/488
.. _#489: https://github.com/the-paperless-project/paperless/pull/489 .. _#489: https://github.com/the-paperless-project/paperless/pull/489
.. _#492: https://github.com/the-paperless-project/paperless/pull/492 .. _#492: https://github.com/the-paperless-project/paperless/pull/492
.. _#674: https://github.com/paperless-ngx/paperless-ngx/pull/674
.. _a new home on Docker Hub: https://hub.docker.com/r/danielquinn/paperless/ .. _a new home on Docker Hub: https://hub.docker.com/r/danielquinn/paperless/
.. _optipng: http://optipng.sourceforge.net/ .. _optipng: http://optipng.sourceforge.net/

View File

@ -130,6 +130,8 @@ PAPERLESS_LOGROTATE_MAX_BACKUPS=<num>
Defaults to 20. Defaults to 20.
.. _hosting-and-security:
Hosting & Security Hosting & Security
################## ##################
@ -142,7 +144,24 @@ PAPERLESS_SECRET_KEY=<key>
Default is listed in the file ``src/paperless/settings.py``. Default is listed in the file ``src/paperless/settings.py``.
PAPERLESS_ALLOWED_HOSTS<comma-separated-list> PAPERLESS_URL=<url>
This setting can be used to set the three options below (ALLOWED_HOSTS,
CORS_ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS). If the other options are
set the values will be combined with this one. Do not include a trailing
slash. E.g. https://paperless.domain.com
Defaults to empty string, leaving the other settings unaffected.
PAPERLESS_CSRF_TRUSTED_ORIGINS=<comma-separated-list>
A list of trusted origins for unsafe requests (e.g. POST). As of Django 4.0
this is required to access the Django admin via the web.
See https://docs.djangoproject.com/en/4.0/ref/settings/#csrf-trusted-origins
Can also be set using PAPERLESS_URL (see above).
Defaults to empty string, which does not add any origins to the trusted list.
PAPERLESS_ALLOWED_HOSTS=<comma-separated-list>
If you're planning on putting Paperless on the open internet, then you If you're planning on putting Paperless on the open internet, then you
really should set this value to the domain name you're using. Failing to do really should set this value to the domain name you're using. Failing to do
so leaves you open to HTTP host header attacks: so leaves you open to HTTP host header attacks:
@ -151,12 +170,19 @@ PAPERLESS_ALLOWED_HOSTS<comma-separated-list>
Just remember that this is a comma-separated list, so "example.com" is fine, Just remember that this is a comma-separated list, so "example.com" is fine,
as is "example.com,www.example.com", but NOT " example.com" or "example.com," as is "example.com,www.example.com", but NOT " example.com" or "example.com,"
Can also be set using PAPERLESS_URL (see above).
If manually set, please remember to include "localhost". Otherwise docker
healthcheck will fail.
Defaults to "*", which is all hosts. Defaults to "*", which is all hosts.
PAPERLESS_CORS_ALLOWED_HOSTS<comma-separated-list> PAPERLESS_CORS_ALLOWED_HOSTS=<comma-separated-list>
You need to add your servers to the list of allowed hosts that can do CORS You need to add your servers to the list of allowed hosts that can do CORS
calls. Set this to your public domain name. calls. Set this to your public domain name.
Can also be set using PAPERLESS_URL (see above).
Defaults to "http://localhost:8000". Defaults to "http://localhost:8000".
PAPERLESS_FORCE_SCRIPT_NAME=<path> PAPERLESS_FORCE_SCRIPT_NAME=<path>
@ -389,6 +415,15 @@ PAPERLESS_OCR_IMAGE_DPI=<num>
Default is none, which will automatically calculate image DPI so that Default is none, which will automatically calculate image DPI so that
the produced PDF documents are A4 sized. the produced PDF documents are A4 sized.
PAPERLESS_OCR_MAX_IMAGE_PIXELS=<num>
Paperless will not OCR images that have more pixels than this limit.
This is intended to prevent decompression bombs from overloading paperless.
Increasing this limit is desired if you face a DecompressionBombError despite
the concerning file not being malicious; this could e.g. be caused by invalidly
recognized metadata.
If you have enough resources or if you are certain that your uploaded files
are not malicious you can increase this value to your needs.
The default value is 256000000, an image with more pixels than that would not be parsed.
PAPERLESS_OCR_USER_ARGS=<json> PAPERLESS_OCR_USER_ARGS=<json>
OCRmyPDF offers many more options. Use this parameter to specify any OCRmyPDF offers many more options. Use this parameter to specify any
@ -462,8 +497,9 @@ requires are as follows:
gotenberg: gotenberg:
image: gotenberg/gotenberg:7 image: gotenberg/gotenberg:7
restart: unless-stopped restart: unless-stopped
environment: command:
CHROMIUM_DISABLE_ROUTES: 1 - "gotenberg"
- "--chromium-disable-routes=true"
tika: tika:
image: apache/tika image: apache/tika
@ -473,6 +509,8 @@ Add the configuration variables to the environment of the webserver (alternative
put the configuration in the ``docker-compose.env`` file) and add the additional put the configuration in the ``docker-compose.env`` file) and add the additional
services below the webserver service. Watch out for indentation. services below the webserver service. Watch out for indentation.
Make sure to use the correct format `PAPERLESS_TIKA_ENABLED = 1` so python_dotenv can parse the statement correctly.
Software tweaks Software tweaks
############### ###############
@ -528,6 +566,10 @@ PAPERLESS_WORKER_TIMEOUT=<num>
large documents within the default 1800 seconds. So extending this timeout large documents within the default 1800 seconds. So extending this timeout
may prove to be useful on weak hardware setups. may prove to be useful on weak hardware setups.
PAPERLESS_WORKER_RETRY=<num>
If PAPERLESS_WORKER_TIMEOUT has been configured, the retry time for a task can
also be configured. By default, this value will be set to 10s more than the
worker timeout. This value should never be set less than the worker timeout.
PAPERLESS_TIME_ZONE=<timezone> PAPERLESS_TIME_ZONE=<timezone>
Set the time zone here. Set the time zone here.
@ -576,6 +618,27 @@ PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS=<bool>
Defaults to false. Defaults to false.
PAPERLESS_CONSUMER_ENABLE_BARCODES=<bool>
Enables the scanning and page separation based on detected barcodes.
This allows for scanning and adding multiple documents per uploaded
file, which are separated by one or multiple barcode pages.
For ease of use, it is suggested to use a standardized separation page,
e.g. `here <https://www.alliancegroup.co.uk/patch-codes.htm>`_.
If no barcodes are detected in the uploaded file, no page separation
will happen.
Defaults to false.
PAPERLESS_CONSUMER_BARCODE_STRING=PATCHT
Defines the string to be detected as a separator barcode.
If paperless is used with the PATCH-T separator pages, users
shouldn't change this.
Defaults to "PATCHT"
PAPERLESS_CONVERT_MEMORY_LIMIT=<num> PAPERLESS_CONVERT_MEMORY_LIMIT=<num>
On smaller systems, or even in the case of Very Large Documents, the consumer On smaller systems, or even in the case of Very Large Documents, the consumer
@ -659,7 +722,7 @@ PAPERLESS_CONSUMER_IGNORE_PATTERNS=<json>
This can be adjusted by configuring a custom json array with patterns to exclude. This can be adjusted by configuring a custom json array with patterns to exclude.
Defaults to ``[".DS_STORE/*", "._*", ".stfolder/*"]``. Defaults to ``[".DS_STORE/*", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini"]``.
Binaries Binaries
######## ########
@ -752,3 +815,26 @@ PAPERLESS_OCR_LANGUAGES=<list>
PAPERLESS_OCR_LANGUAGE=tur PAPERLESS_OCR_LANGUAGE=tur
Defaults to none, which does not install any additional languages. Defaults to none, which does not install any additional languages.
.. _configuration-update-checking:
Update Checking
###############
PAPERLESS_ENABLE_UPDATE_CHECK=<bool>
Enable (or disable) the automatic check for available updates. This feature is disabled
by default but if it is not explicitly set Paperless-ngx will show a message about this.
If enabled, the feature works by pinging the the Github API for the latest release e.g.
https://api.github.com/repos/paperless-ngx/paperless-ngx/releases/latest
to determine whether a new version is available.
Actual updating of the app must still be performed manually.
Note that for users of thirdy-party containers e.g. linuxserver.io this notification
may be 'ahead' of a new release from the third-party maintainers.
In either case, no tracking data is collected by the app in any way.
Defaults to none, which disables the feature.

View File

@ -34,6 +34,8 @@ it fixed for everyone!
Before contributing please review our `code of conduct`_ and other important Before contributing please review our `code of conduct`_ and other important
information in the `contributing guidelines`_. information in the `contributing guidelines`_.
.. _code-formatting-with-pre-commit-hooks:
Code formatting with pre-commit Hooks Code formatting with pre-commit Hooks
===================================== =====================================
@ -85,6 +87,7 @@ To do the setup you need to perform the steps from the following chapters in a c
docker run -d -p 6379:6379 --restart unless-stopped redis:latest docker run -d -p 6379:6379 --restart unless-stopped redis:latest
7. Install the python dependencies by performing in the src/ directory. 7. Install the python dependencies by performing in the src/ directory.
.. code:: shell-session .. code:: shell-session
pipenv install --dev pipenv install --dev
@ -139,8 +142,9 @@ Testing and code style:
* Run ``pytest`` in the src/ directory to execute all tests. This also generates a HTML coverage * Run ``pytest`` in the src/ directory to execute all tests. This also generates a HTML coverage
report. When runnings test, paperless.conf is loaded as well. However: the tests rely on the default report. When runnings test, paperless.conf is loaded as well. However: the tests rely on the default
configuration. This is not ideal. But for now, make sure no settings except for DEBUG are overridden when testing. configuration. This is not ideal. But for now, make sure no settings except for DEBUG are overridden when testing.
* Run ``black`` to format your code. * Coding style is enforced by the Git pre-commit hooks. These will ensure your code is formatted and do some
* Run ``pycodestyle`` to test your code for issues with the configured code style settings. linting when you do a `git commit`.
* You can also run ``black`` manually to format your code
.. note:: .. note::
@ -182,6 +186,31 @@ X-Frame-Options are in place so that the front end behaves exactly as in product
relies on you being logged into the back end. Without a valid session, The front end will simply relies on you being logged into the back end. Without a valid session, The front end will simply
not work. not work.
Testing and code style:
* The frontend code (.ts, .html, .scss) use ``prettier`` for code formatting via the Git
``pre-commit`` hooks which run automatically on commit. See
:ref:`above <code-formatting-with-pre-commit-hooks>` for installation. You can also run this
via cli with a command such as
.. code:: shell-session
$ git ls-files -- '*.ts' | xargs pre-commit run prettier --files
* Frontend testing uses jest and cypress. There is currently a need for significantly more
frontend tests. Unit tests and e2e tests, respectively, can be run non-interactively with:
.. code:: shell-session
$ ng test
$ npm run e2e:ci
Cypress also includes a UI which can be run from within the ``src-ui`` directory with
.. code:: shell-session
$ ./node_modules/.bin/cypress open
In order to build the front end and serve it as part of django, execute In order to build the front end and serve it as part of django, execute
.. code:: shell-session .. code:: shell-session

View File

@ -5,11 +5,11 @@ Frequently asked questions
**Q:** *What's the general plan for Paperless-ngx?* **Q:** *What's the general plan for Paperless-ngx?*
**A:** While Paperless-ngx is already considered largely "feature-complete" it is a community-driven **A:** While Paperless-ngx is already considered largely "feature-complete" it is a community-driven
project and development will be guided in this way. New features can be submitted via project and development will be guided in this way. New features can be submitted via
GitHub discussions and "up-voted" by the community but this is not a guarantee the feature GitHub discussions and "up-voted" by the community but this is not a guarantee the feature
will be implemented. This project will always be open to collaboration in the form of PRs, will be implemented. This project will always be open to collaboration in the form of PRs,
ideas etc. ideas etc.
**Q:** *I'm using docker. Where are my documents?* **Q:** *I'm using docker. Where are my documents?*
@ -81,11 +81,10 @@ python requirements do not have precompiled packages for ARM / ARM64. Installati
of these will require additional development libraries and compilation will take of these will require additional development libraries and compilation will take
a long time. a long time.
**Q:** *How do I run this on unRaid?* **Q:** *How do I run this on Unraid?*
**A:** Head over to `<https://github.com/selfhosters/unRAID-CA-templates>`_, **A:** Paperless-ngx is available as `community app <https://unraid.net/community/apps?q=paperless-ngx>`_
`Uli Fahrer <https://github.com/Tooa>`_ created a container template for that. in Unraid. `Uli Fahrer <https://github.com/Tooa>`_ created a container template for that.
I don't exactly know how to use that though, since I don't use unRaid.
**Q:** *How do I run this on my toaster?* **Q:** *How do I run this on my toaster?*

View File

@ -13,43 +13,43 @@ that works right for you based on recommendations from other Paperless users.
Physical scanners Physical scanners
================= =================
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brand | Model | Supports | Recommended By | | Brand | Model | Supports | Recommended By |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| | | FTP | NFS | SMB | SMTP | API [1]_ | | | | | FTP | SFTP | NFS | SMB | SMTP | API [1]_ | |
+=========+================+=====+=====+=====+======+==========+================+ +=========+================+=====+======+=====+=====+======+==========+================+
| Brother | `ADS-1700W`_ | yes | | yes | yes | |`holzhannes`_ | | Brother | `ADS-1700W`_ | yes | | | yes | yes | |`holzhannes`_ |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `ADS-1600W`_ | yes | | yes | yes | |`holzhannes`_ | | Brother | `ADS-1600W`_ | yes | | | yes | yes | |`holzhannes`_ |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `ADS-1500W`_ | yes | | yes | yes | |`danielquinn`_ | | Brother | `ADS-1500W`_ | yes | | | yes | yes | |`danielquinn`_ |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `ADS-1100W`_ | yes | | | | |`ytzelf`_ | | Brother | `ADS-1100W`_ | yes | | | | | |`ytzelf`_ |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `ADS-2800W`_ | yes | yes | | yes | yes |`philpagel`_ | | Brother | `ADS-2800W`_ | yes | yes | | yes | yes | |`philpagel`_ |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `MFC-J6930DW`_ | yes | | | | |`ayounggun`_ | | Brother | `MFC-J6930DW`_ | yes | | | | | |`ayounggun`_ |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `MFC-L5850DW`_ | yes | | | yes | |`holzhannes`_ | | Brother | `MFC-L5850DW`_ | yes | | | | yes | |`holzhannes`_ |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `MFC-L2750DW`_ | yes | | yes | yes | |`muued`_ | | Brother | `MFC-L2750DW`_ | yes | | | yes | yes | |`muued`_ |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `MFC-J5910DW`_ | yes | | | | |`bmsleight`_ | | Brother | `MFC-J5910DW`_ | yes | | | | | |`bmsleight`_ |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `MFC-8950DW`_ | yes | | | yes | yes |`philpagel`_ | | Brother | `MFC-8950DW`_ | yes | | | yes | yes | |`philpagel`_ |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Brother | `MFC-9142CDN`_ | yes | | yes | | |`REOLDEV`_ | | Brother | `MFC-9142CDN`_ | yes | | | yes | | |`REOLDEV`_ |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Fujitsu | `ix500`_ | yes | | yes | | |`eonist`_ | | Fujitsu | `ix500`_ | yes | | | yes | | |`eonist`_ |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Epson | `ES-580W`_ | yes | | yes | yes | |`fignew`_ | | Epson | `ES-580W`_ | yes | | | yes | yes | |`fignew`_ |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Epson | `WF-7710DWF`_ | yes | | yes | | |`Skylinar`_ | | Epson | `WF-7710DWF`_ | yes | | | yes | | |`Skylinar`_ |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Fujitsu | `S1300i`_ | yes | | yes | | |`jonaswinkler`_ | | Fujitsu | `S1300i`_ | yes | | | yes | | |`jonaswinkler`_ |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
| Doxie | `Q2`_ | | | | | yes |`Unkn0wnCat`_ | | Doxie | `Q2`_ | | | | | | yes |`Unkn0wnCat`_ |
+---------+----------------+-----+-----+-----+------+----------+----------------+ +---------+----------------+-----+------+-----+-----+------+----------+----------------+
.. _MFC-L5850DW: https://www.brother-usa.com/products/mfcl5850dw .. _MFC-L5850DW: https://www.brother-usa.com/products/mfcl5850dw
.. _MFC-L2750DW: https://www.brother.de/drucker/laserdrucker/mfc-l2750dw .. _MFC-L2750DW: https://www.brother.de/drucker/laserdrucker/mfc-l2750dw
@ -131,4 +131,3 @@ This part assumes your Doxie is connected to WiFi and you know its IP.
6. Click *Submit* at the bottom of the page 6. Click *Submit* at the bottom of the page
Congrats, you can now scan directly from your Doxie to your Paperless-ngx instance! Congrats, you can now scan directly from your Doxie to your Paperless-ngx instance!

View File

@ -110,7 +110,7 @@ performs all the steps described in :ref:`setup-docker_hub` automatically.
.. code:: shell-session .. code:: shell-session
$ bash -c "$(curl -L https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/master/install-paperless-ngx.sh)" $ bash -c "$(curl -L https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/install-paperless-ngx.sh)"
.. _setup-docker_hub: .. _setup-docker_hub:
@ -481,7 +481,7 @@ Migrating from Paperless-ng
=========================== ===========================
Paperless-ngx is meant to be a drop-in replacement for Paperless-ng and thus upgrading should be Paperless-ngx is meant to be a drop-in replacement for Paperless-ng and thus upgrading should be
trivial for most users, especially when using docker. However, as with any major change, it is trivial for most users, especially when using docker. However, as with any major change, it is
recommended to take a full backup first. Once you are ready, simply change the docker image to recommended to take a full backup first. Once you are ready, simply change the docker image to
point to the new source. E.g. if using Docker Compose, edit ``docker-compose.yml`` and change: point to the new source. E.g. if using Docker Compose, edit ``docker-compose.yml`` and change:
@ -494,12 +494,12 @@ to
.. code:: .. code::
image: ghcr.io/paperless-ngx/paperless-ngx:latest image: ghcr.io/paperless-ngx/paperless-ngx:latest
and then run ``docker-compose up -d`` which will pull the new image recreate the container. and then run ``docker-compose up -d`` which will pull the new image recreate the container.
That's it! That's it!
Users who installed with the bare-metal route should also update their Git clone to point to Users who installed with the bare-metal route should also update their Git clone to point to
``https://github.com/paperless-ngx/paperless-ngx``, e.g. using the command ``https://github.com/paperless-ngx/paperless-ngx``, e.g. using the command
``git remote set-url origin https://github.com/paperless-ngx/paperless-ngx`` and then pull the ``git remote set-url origin https://github.com/paperless-ngx/paperless-ngx`` and then pull the
lastest version. lastest version.
@ -728,6 +728,8 @@ configuring some options in paperless can help improve performance immensely:
times. Thumbnails will be about 20% larger. times. Thumbnails will be about 20% larger.
* If using docker, consider setting ``PAPERLESS_WEBSERVER_WORKERS`` to * If using docker, consider setting ``PAPERLESS_WEBSERVER_WORKERS`` to
1. This will save some memory. 1. This will save some memory.
* Use the arm compatible docker-compose if you're wanting to use Tika on something like
a raspberry pi. The official apache/tika image does not support the arm architecture.
For details, refer to :ref:`configuration`. For details, refer to :ref:`configuration`.
@ -786,4 +788,6 @@ the following configuration is required for paperless to operate:
} }
} }
The ``PAPERLESS_URL`` configuration variable is also required when using a reverse proxy. Please refer to the :ref:`hosting-and-security` docs.
Also read `this <https://channels.readthedocs.io/en/stable/deploying.html#nginx-supervisor-ubuntu>`__, towards the end of the section. Also read `this <https://channels.readthedocs.io/en/stable/deploying.html#nginx-supervisor-ubuntu>`__, towards the end of the section.

View File

@ -119,7 +119,7 @@ You may experience these errors when using the optional TIKA integration:
Gotenberg is a server that converts Office documents into PDF documents and has a default timeout of 30 seconds. Gotenberg is a server that converts Office documents into PDF documents and has a default timeout of 30 seconds.
When conversion takes longer, Gotenberg raises this error. When conversion takes longer, Gotenberg raises this error.
You can increase the timeout by configuring an environment variable for Gotenberg (see also `here <https://gotenberg.dev/docs/modules/api#properties>`__). You can increase the timeout by configuring a command flag for Gotenberg (see also `here <https://gotenberg.dev/docs/modules/api#properties>`__).
If using docker-compose, this is achieved by the following configuration change in the ``docker-compose.yml`` file: If using docker-compose, this is achieved by the following configuration change in the ``docker-compose.yml`` file:
.. code:: yaml .. code:: yaml
@ -127,9 +127,10 @@ If using docker-compose, this is achieved by the following configuration change
gotenberg: gotenberg:
image: gotenberg/gotenberg:7 image: gotenberg/gotenberg:7
restart: unless-stopped restart: unless-stopped
environment: command:
CHROMIUM_DISABLE_ROUTES: 1 - "gotenberg"
API_PROCESS_TIMEOUT: 60 - "--chromium-disable-routes=true"
- "--api-timeout=60"
Permission denied errors in the consumption directory Permission denied errors in the consumption directory
##################################################### #####################################################

View File

@ -180,6 +180,14 @@ These are as follows:
automatically or manually and tell paperless to move them to yet another folder automatically or manually and tell paperless to move them to yet another folder
after consumption. It's up to you. after consumption. It's up to you.
.. note::
When defining a mail rule with a folder, you may need to try different characters to
define how the sub-folders are separated. Common values include ".", "/" or "|", but
this varies by the mail server. Unfortunately, this isn't a value we can determine
automatically. Either check the documentation for your mail server, or check for
errors in the logs and try different folder separator values.
.. note:: .. note::
Paperless will process the rules in the order defined in the admin page. Paperless will process the rules in the order defined in the admin page.

View File

@ -3,16 +3,16 @@
ask() { ask() {
while true ; do while true ; do
if [[ -z $3 ]] ; then if [[ -z $3 ]] ; then
read -p "$1 [$2]: " result read -r -p "$1 [$2]: " result
else else
read -p "$1 ($3) [$2]: " result read -r -p "$1 ($3) [$2]: " result
fi fi
if [[ -z $result ]]; then if [[ -z $result ]]; then
ask_result=$2 ask_result=$2
return return
fi fi
array=$3 array=$3
if [[ -z $3 || " ${array[@]} " =~ " ${result} " ]]; then if [[ -z $3 || " ${array[*]} " =~ ${result} ]]; then
ask_result=$result ask_result=$result
return return
else else
@ -24,7 +24,7 @@ ask() {
ask_docker_folder() { ask_docker_folder() {
while true ; do while true ; do
read -p "$1 [$2]: " result read -r -p "$1 [$2]: " result
if [[ -z $result ]]; then if [[ -z $result ]]; then
ask_result=$2 ask_result=$2
@ -47,25 +47,29 @@ if [[ $(id -u) == "0" ]] ; then
exit 1 exit 1
fi fi
if [[ -z $(which wget) ]] ; then if ! command -v wget &> /dev/null ; then
echo "wget executable not found. Is wget installed?" echo "wget executable not found. Is wget installed?"
exit 1 exit 1
fi fi
if [[ -z $(which docker) ]] ; then if ! command -v docker &> /dev/null ; then
echo "docker executable not found. Is docker installed?" echo "docker executable not found. Is docker installed?"
exit 1 exit 1
fi fi
if [[ -z $(which docker-compose) ]] ; then DOCKER_COMPOSE_CMD="docker-compose"
echo "docker-compose executable not found. Is docker-compose installed?" if ! command -v ${DOCKER_COMPOSE_CMD} ; then
exit 1 if docker compose version &> /dev/null ; then
DOCKER_COMPOSE_CMD="docker compose"
else
echo "docker-compose executable not found. Is docker-compose installed?"
exit 1
fi
fi fi
# Check if user has permissions to run Docker by trying to get the status of Docker (docker status). # Check if user has permissions to run Docker by trying to get the status of Docker (docker status).
# If this fails, the user probably does not have permissions for Docker. # If this fails, the user probably does not have permissions for Docker.
docker stats --no-stream 2>/dev/null 1>&2 if ! docker stats --no-stream &> /dev/null ; then
if [ $? -ne 0 ] ; then
echo "" echo ""
echo "WARN: It look like the current user does not have Docker permissions." echo "WARN: It look like the current user does not have Docker permissions."
echo "WARN: Use 'sudo usermod -aG docker $USER' to assign Docker permissions to the user." echo "WARN: Use 'sudo usermod -aG docker $USER' to assign Docker permissions to the user."
@ -88,6 +92,14 @@ echo ""
echo "1. Application configuration" echo "1. Application configuration"
echo "============================" echo "============================"
echo ""
echo "The URL paperless will be available at. This is required if the"
echo "installation will be accessible via the web, otherwise can be left blank."
echo ""
ask "URL" ""
URL=$ask_result
echo "" echo ""
echo "The port on which the paperless webserver will listen for incoming" echo "The port on which the paperless webserver will listen for incoming"
echo "connections." echo "connections."
@ -162,7 +174,7 @@ ask "Target folder" "$(pwd)/paperless-ngx"
TARGET_FOLDER=$ask_result TARGET_FOLDER=$ask_result
echo "" echo ""
echo "The consume folder is where paperles will search for new documents." echo "The consume folder is where paperless will search for new documents."
echo "Point this to a folder where your scanner is able to put your scanned" echo "Point this to a folder where your scanner is able to put your scanned"
echo "documents." echo "documents."
echo "" echo ""
@ -228,7 +240,7 @@ ask "Paperless username" "$(whoami)"
USERNAME=$ask_result USERNAME=$ask_result
while true; do while true; do
read -sp "Paperless password: " PASSWORD read -r -sp "Paperless password: " PASSWORD
echo "" echo ""
if [[ -z $PASSWORD ]] ; then if [[ -z $PASSWORD ]] ; then
@ -236,7 +248,7 @@ while true; do
continue continue
fi fi
read -sp "Paperless password (again): " PASSWORD_REPEAT read -r -sp "Paperless password (again): " PASSWORD_REPEAT
echo "" echo ""
if [[ ! "$PASSWORD" == "$PASSWORD_REPEAT" ]] ; then if [[ ! "$PASSWORD" == "$PASSWORD_REPEAT" ]] ; then
@ -274,6 +286,7 @@ if [[ "$DATABASE_BACKEND" == "postgres" ]] ; then
fi fi
fi fi
echo "" echo ""
echo "URL: $URL"
echo "Port: $PORT" echo "Port: $PORT"
echo "Database: $DATABASE_BACKEND" echo "Database: $DATABASE_BACKEND"
echo "Tika enabled: $TIKA_ENABLED" echo "Tika enabled: $TIKA_ENABLED"
@ -285,7 +298,7 @@ echo "Paperless username: $USERNAME"
echo "Paperless email: $EMAIL" echo "Paperless email: $EMAIL"
echo "" echo ""
read -p "Press any key to install." read -r -p "Press any key to install."
echo "" echo ""
echo "Installing paperless..." echo "Installing paperless..."
@ -301,14 +314,17 @@ if [[ $TIKA_ENABLED == "yes" ]] ; then
DOCKER_COMPOSE_VERSION="$DOCKER_COMPOSE_VERSION-tika" DOCKER_COMPOSE_VERSION="$DOCKER_COMPOSE_VERSION-tika"
fi fi
wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/master/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/master/docker/compose/.env" -O .env wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/.env" -O .env
SECRET_KEY=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 64 | head -n 1) SECRET_KEY=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | fold -w 64 | head -n 1)
DEFAULT_LANGUAGES="deu eng fra ita spa" DEFAULT_LANGUAGES="deu eng fra ita spa"
{ {
if [[ ! $URL == "" ]] ; then
echo "PAPERLESS_URL=$URL"
fi
if [[ ! $USERMAP_UID == "1000" ]] ; then if [[ ! $USERMAP_UID == "1000" ]] ; then
echo "USERMAP_UID=$USERMAP_UID" echo "USERMAP_UID=$USERMAP_UID"
fi fi
@ -318,7 +334,7 @@ DEFAULT_LANGUAGES="deu eng fra ita spa"
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_LANGUAGE} " ]] ; then if [[ ! " ${DEFAULT_LANGUAGES[*]} " =~ ${OCR_LANGUAGE} ]] ; then
echo "PAPERLESS_OCR_LANGUAGES=$OCR_LANGUAGE" echo "PAPERLESS_OCR_LANGUAGES=$OCR_LANGUAGE"
fi fi
} > docker-compose.env } > docker-compose.env
@ -329,18 +345,31 @@ sed -i "s#- \./consume:/usr/src/paperless/consume#- $CONSUME_FOLDER:/usr/src/pap
if [[ -n $MEDIA_FOLDER ]] ; then if [[ -n $MEDIA_FOLDER ]] ; then
sed -i "s#- media:/usr/src/paperless/media#- $MEDIA_FOLDER:/usr/src/paperless/media#g" docker-compose.yml sed -i "s#- media:/usr/src/paperless/media#- $MEDIA_FOLDER:/usr/src/paperless/media#g" docker-compose.yml
sed -i "/^\s*media:/d" docker-compose.yml
fi fi
if [[ -n $DATA_FOLDER ]] ; then if [[ -n $DATA_FOLDER ]] ; then
sed -i "s#- data:/usr/src/paperless/data#- $DATA_FOLDER:/usr/src/paperless/data#g" docker-compose.yml sed -i "s#- data:/usr/src/paperless/data#- $DATA_FOLDER:/usr/src/paperless/data#g" docker-compose.yml
sed -i "/^\s*data:/d" docker-compose.yml
fi fi
if [[ -n $POSTGRES_FOLDER ]] ; then if [[ -n $POSTGRES_FOLDER ]] ; then
sed -i "s#- pgdata:/var/lib/postgresql/data#- $POSTGRES_FOLDER:/var/lib/postgresql/data#g" docker-compose.yml sed -i "s#- pgdata:/var/lib/postgresql/data#- $POSTGRES_FOLDER:/var/lib/postgresql/data#g" docker-compose.yml
sed -i "/^\s*pgdata:/d" docker-compose.yml
fi fi
docker-compose pull # remove trailing blank lines from end of file
sed -i -e :a -e '/^\n*$/{$d;N;};/\n$/ba' docker-compose.yml
# if last line in file contains "volumes:", remove that line since no more named volumes are left
l1=$(grep -n '^volumes:' docker-compose.yml | cut -d : -f 1) # get line number containing volume: at begin of line
l2=$(wc -l < docker-compose.yml) # get total number of lines
if [ "$l1" -eq "$l2" ] ; then
sed -i "/^volumes:/d" docker-compose.yml
fi
docker-compose run --rm -e DJANGO_SUPERUSER_PASSWORD="$PASSWORD" webserver createsuperuser --noinput --username "$USERNAME" --email "$EMAIL"
docker-compose up -d ${DOCKER_COMPOSE_CMD} pull
${DOCKER_COMPOSE_CMD} run --rm -e DJANGO_SUPERUSER_PASSWORD="$PASSWORD" webserver createsuperuser --noinput --username "$USERNAME" --email "$EMAIL"
${DOCKER_COMPOSE_CMD} up -d

View File

@ -27,8 +27,10 @@
# Security and hosting # Security and hosting
#PAPERLESS_SECRET_KEY=change-me #PAPERLESS_SECRET_KEY=change-me
#PAPERLESS_ALLOWED_HOSTS=example.com,www.example.com #PAPERLESS_URL=https://example.com
#PAPERLESS_CORS_ALLOWED_HOSTS=http://example.com,http://localhost:8000 #PAPERLESS_CSRF_TRUSTED_ORIGINS=https://example.com # can be set using PAPERLESS_URL
#PAPERLESS_ALLOWED_HOSTS=example.com,www.example.com # can be set using PAPERLESS_URL
#PAPERLESS_CORS_ALLOWED_HOSTS=https://localhost:8080,https://example.com # can be set using PAPERLESS_URL
#PAPERLESS_FORCE_SCRIPT_NAME= #PAPERLESS_FORCE_SCRIPT_NAME=
#PAPERLESS_STATIC_URL=/static/ #PAPERLESS_STATIC_URL=/static/
#PAPERLESS_AUTO_LOGIN_USERNAME= #PAPERLESS_AUTO_LOGIN_USERNAME=
@ -58,8 +60,10 @@
#PAPERLESS_CONSUMER_POLLING=10 #PAPERLESS_CONSUMER_POLLING=10
#PAPERLESS_CONSUMER_DELETE_DUPLICATES=false #PAPERLESS_CONSUMER_DELETE_DUPLICATES=false
#PAPERLESS_CONSUMER_RECURSIVE=false #PAPERLESS_CONSUMER_RECURSIVE=false
#PAPERLESS_CONSUMER_IGNORE_PATTERNS=[".DS_STORE/*", "._*", ".stfolder/*"] #PAPERLESS_CONSUMER_IGNORE_PATTERNS=[".DS_STORE/*", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini"]
#PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS=false #PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS=false
#PAPERLESS_CONSUMER_ENABLE_BARCODES=false
#PAPERLESS_CONSUMER_ENABLE_BARCODES=PATCHT
#PAPERLESS_OPTIMIZE_THUMBNAILS=true #PAPERLESS_OPTIMIZE_THUMBNAILS=true
#PAPERLESS_PRE_CONSUME_SCRIPT=/path/to/an/arbitrary/script.sh #PAPERLESS_PRE_CONSUME_SCRIPT=/path/to/an/arbitrary/script.sh
#PAPERLESS_POST_CONSUME_SCRIPT=/path/to/an/arbitrary/script.sh #PAPERLESS_POST_CONSUME_SCRIPT=/path/to/an/arbitrary/script.sh
@ -67,6 +71,7 @@
#PAPERLESS_FILENAME_PARSE_TRANSFORMS=[] #PAPERLESS_FILENAME_PARSE_TRANSFORMS=[]
#PAPERLESS_THUMBNAIL_FONT_NAME= #PAPERLESS_THUMBNAIL_FONT_NAME=
#PAPERLESS_IGNORE_DATES= #PAPERLESS_IGNORE_DATES=
#PAPERLESS_ENABLE_UPDATE_CHECK=
# Tika settings # Tika settings

View File

@ -5,49 +5,50 @@
# pipenv lock --requirements # pipenv lock --requirements
# #
-i https://pypi.python.org/simple -i https://pypi.python.org/simple/
--extra-index-url https://www.piwheels.org/simple --extra-index-url https://www.piwheels.org/simple/
aioredis==1.3.1 aioredis==1.3.1
anyio==3.5.0; python_full_version >= '3.6.2'
arrow==1.2.2; python_version >= '3.6' arrow==1.2.2; python_version >= '3.6'
asgiref==3.5.0; python_version >= '3.7' asgiref==3.5.0; python_version >= '3.7'
async-timeout==4.0.2; python_version >= '3.6' async-timeout==4.0.2; python_version >= '3.6'
attrs==21.4.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' attrs==21.4.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
autobahn==22.2.2; python_version >= '3.7' autobahn==22.3.2; python_version >= '3.7'
automat==20.2.0 automat==20.2.0
backports.zoneinfo==0.2.1 backports.zoneinfo==0.2.1; python_version < '3.9'
blessed==1.19.1; python_version >= '2.7' blessed==1.19.1; python_version >= '2.7'
certifi==2021.10.8 certifi==2021.10.8
cffi==1.15.0 cffi==1.15.0
channels-redis==3.3.1 channels-redis==3.4.0
channels==3.0.4 channels==3.0.4
chardet==4.0.0; python_version >= '3.1' chardet==4.0.0; python_version >= '3.1'
charset-normalizer==2.0.12; python_version >= '3' charset-normalizer==2.0.12; python_version >= '3'
click==8.0.4; python_version >= '3.6' click==8.1.2; python_version >= '3.7'
coloredlogs==15.0.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' coloredlogs==15.0.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
concurrent-log-handler==0.9.20 concurrent-log-handler==0.9.20
constantly==15.1.0 constantly==15.1.0
cryptography==36.0.1 cryptography==36.0.2; python_version >= '3.6'
daphne==3.0.2; python_version >= '3.6' daphne==3.0.2; python_version >= '3.6'
dateparser==1.1.0 dateparser==1.1.1
django-cors-headers==3.11.0 django-cors-headers==3.11.0
django-extensions==3.1.5 django-extensions==3.1.5
django-filter==21.1 django-filter==21.1
django-picklefield==3.0.1; python_version >= '3' django-picklefield==3.0.1; python_version >= '3'
django-q==1.3.9 django-q==1.3.9
django==3.2.12 django==4.0.4
djangorestframework==3.13.1 djangorestframework==3.13.1
filelock==3.6.0 filelock==3.6.0
fuzzywuzzy[speedup]==0.18.0 fuzzywuzzy[speedup]==0.18.0
gunicorn==20.1.0 gunicorn==20.1.0
h11==0.13.0; python_version >= '3.6' h11==0.13.0; python_version >= '3.6'
hiredis==2.0.0; python_version >= '3.6' hiredis==2.0.0; python_version >= '3.6'
httptools==0.3.0 httptools==0.4.0
humanfriendly==10.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' humanfriendly==10.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
hyperlink==21.0.0 hyperlink==21.0.0
idna==3.3; python_version >= '3.5' idna==3.3; python_version >= '3.5'
imap-tools==0.51.1 imap-tools==0.53.0
img2pdf==0.4.3 img2pdf==0.4.4
importlib-resources==5.4.0; python_version < '3.9' importlib-resources==5.6.0; python_version < '3.9'
incremental==21.3.0 incremental==21.3.0
inotify-simple==1.3.5; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' inotify-simple==1.3.5; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
inotifyrecursive==0.3.5 inotifyrecursive==0.3.5
@ -55,55 +56,58 @@ joblib==1.1.0; python_version >= '3.6'
langdetect==1.0.9 langdetect==1.0.9
lxml==4.8.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' lxml==4.8.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
msgpack==1.0.3 msgpack==1.0.3
numpy==1.22.2 numpy==1.22.3; python_version >= '3.8'
ocrmypdf==13.4.0 ocrmypdf==13.4.2
packaging==21.3; python_version >= '3.6' packaging==21.3; python_version >= '3.6'
pathvalidate==2.5.0 pathvalidate==2.5.0
pdfminer.six==20211012 pdf2image==1.16.0
pikepdf==5.0.1 pdfminer.six==20220319
pillow==9.0.1 pikepdf==5.1.1
pillow==9.1.0
pluggy==1.0.0; python_version >= '3.6' pluggy==1.0.0; python_version >= '3.6'
portalocker==2.4.0; python_version >= '3' portalocker==2.4.0; python_version >= '3'
psycopg2-binary==2.9.3 psycopg2==2.9.3
pyasn1-modules==0.2.8 pyasn1-modules==0.2.8
pyasn1==0.4.8 pyasn1==0.4.8
pycparser==2.21; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' pycparser==2.21
pyopenssl==22.0.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' pyopenssl==22.0.0
pyparsing==3.0.7; python_version >= '3.6' pyparsing==3.0.8; python_full_version >= '3.6.8'
python-dateutil==2.8.2 python-dateutil==2.8.2
python-dotenv==0.19.2 python-dotenv==0.20.0
python-gnupg==0.4.8 python-gnupg==0.4.8
python-levenshtein==0.12.2 python-levenshtein==0.12.2
python-magic==0.4.25 python-magic==0.4.25
pytz-deprecation-shim==0.1.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' pytz-deprecation-shim==0.1.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
pytz==2021.3 pytz==2022.1
pyyaml==6.0 pyyaml==6.0
pyzbar==0.1.9
redis==3.5.3 redis==3.5.3
regex==2022.1.18 regex==2022.3.2; python_version >= '3.6'
reportlab==3.6.7; python_version >= '3.6' and python_version < '4' reportlab==3.6.9; python_version >= '3.7' and python_version < '4'
requests==2.27.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' requests==2.27.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
scikit-learn==0.24.0 scikit-learn==1.0.2
scipy==1.8.0; python_version < '3.11' and python_version >= '3.8' scipy==1.8.0; python_version < '3.11' and python_version >= '3.8'
service-identity==21.1.0 service-identity==21.1.0
setuptools==60.9.3; python_version >= '3.7' setuptools==62.1.0; python_version >= '3.7'
six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
sniffio==1.2.0; python_version >= '3.5'
sqlparse==0.4.2; python_version >= '3.5' sqlparse==0.4.2; python_version >= '3.5'
threadpoolctl==3.1.0; python_version >= '3.6' threadpoolctl==3.1.0; python_version >= '3.6'
tika==1.24 tika==1.24
tqdm==4.62.3 tqdm==4.64.0
twisted[tls]==22.1.0; python_full_version >= '3.6.7' twisted[tls]==22.4.0; python_full_version >= '3.6.7'
txaio==22.2.1; python_version >= '3.6' txaio==22.2.1; python_version >= '3.6'
typing-extensions==4.1.1; python_version >= '3.6' typing-extensions==4.1.1; python_version >= '3.6'
tzdata==2021.5; python_version >= '3.6' tzdata==2022.1; python_version >= '3.6'
tzlocal==4.1; python_version >= '3.6' tzlocal==4.2; python_version >= '3.6'
urllib3==1.26.8; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4' urllib3==1.26.9; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'
uvicorn[standard]==0.17.5 uvicorn[standard]==0.17.6
uvloop==0.16.0 uvloop==0.16.0
watchdog==2.1.6 watchdog==2.1.7
watchgod==0.7 watchgod==0.8.2
wcwidth==0.2.5 wcwidth==0.2.5
websockets==10.2 websockets==10.2
whitenoise==6.0.0 whitenoise==6.0.0
whoosh==2.7.4 whoosh==2.7.4
zipp==3.7.0; python_version < '3.10' zipp==3.8.0; python_version < '3.9'
zope.interface==5.4.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' zope.interface==5.4.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'

View File

@ -1,3 +1,5 @@
#!/usr/bin/env bash
docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:13 docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:13
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 docker run -p 3000:3000 -d gotenberg/gotenberg:7

4
src-ui/.gitignore vendored
View File

@ -45,3 +45,7 @@ testem.log
# System Files # System Files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Cypress
cypress/videos/**/*
cypress/screenshots/**/*

View File

@ -16,6 +16,7 @@
"i18n": { "i18n": {
"sourceLocale": "en-US", "sourceLocale": "en-US",
"locales": { "locales": {
"be-BY": "src/locale/messages.be_BY.xlf",
"cs-CZ": "src/locale/messages.cs_CZ.xlf", "cs-CZ": "src/locale/messages.cs_CZ.xlf",
"da-DK": "src/locale/messages.da_DK.xlf", "da-DK": "src/locale/messages.da_DK.xlf",
"de-DE": "src/locale/messages.de_DE.xlf", "de-DE": "src/locale/messages.de_DE.xlf",
@ -30,8 +31,12 @@
"pt-PT": "src/locale/messages.pt_PT.xlf", "pt-PT": "src/locale/messages.pt_PT.xlf",
"ro-RO": "src/locale/messages.ro_RO.xlf", "ro-RO": "src/locale/messages.ro_RO.xlf",
"ru-RU": "src/locale/messages.ru_RU.xlf", "ru-RU": "src/locale/messages.ru_RU.xlf",
"sv-SE": "src/locale/messages.sv_SE.xlf" "sl-SI": "src/locale/messages.sl_SI.xlf",
} "sr-CS": "src/locale/messages.sr_CS.xlf",
"sv-SE": "src/locale/messages.sv_SE.xlf",
"tr-TR": "src/locale/messages.tr_TR.xlf",
"zh-CN": "src/locale/messages.zh_CN.xlf"
}
}, },
"architect": { "architect": {
"build": { "build": {
@ -121,12 +126,9 @@
} }
}, },
"test": { "test": {
"builder": "@angular-devkit/build-angular:karma", "builder": "@angular-builders/jest:run",
"options": { "options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"assets": [ "assets": [
"src/favicon.ico", "src/favicon.ico",
"src/apple-touch-icon.png", "src/apple-touch-icon.png",
@ -140,9 +142,21 @@
} }
}, },
"e2e": { "e2e": {
"builder": "@angular-devkit/build-angular:protractor", "builder": "@cypress/schematic:cypress",
"options": {
"devServerTarget": "paperless-ui:serve",
"watch": true,
"headless": false
},
"configurations": {
"production": {
"devServerTarget": "paperless-ui:serve:production"
}
}
},
"cypress-run": {
"builder": "@cypress/schematic:cypress",
"options": { "options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "paperless-ui:serve" "devServerTarget": "paperless-ui:serve"
}, },
"configurations": { "configurations": {
@ -150,6 +164,13 @@
"devServerTarget": "paperless-ui:serve:production" "devServerTarget": "paperless-ui:serve:production"
} }
} }
},
"cypress-open": {
"builder": "@cypress/schematic:cypress",
"options": {
"watch": true,
"headless": false
}
} }
} }
} }

9
src-ui/cypress.json Normal file
View File

@ -0,0 +1,9 @@
{
"integrationFolder": "cypress/integration",
"supportFile": "cypress/support/index.ts",
"videosFolder": "cypress/videos",
"screenshotsFolder": "cypress/screenshots",
"pluginsFile": "cypress/plugins/index.ts",
"fixturesFolder": "cypress/fixtures",
"baseUrl": "http://localhost:4200"
}

View File

@ -0,0 +1 @@
{"count":27,"next":"http://localhost:8000/api/correspondents/?page=2","previous":null,"results":[{"id":9,"slug":"abc-test-correspondent","name":"ABC Test Correspondent","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":13,"slug":"corresp-10","name":"Corresp 10","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":14,"slug":"corresp-11","name":"Corresp 11","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":15,"slug":"corresp-12","name":"Corresp 12","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":16,"slug":"corresp-13","name":"Corresp 13","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":18,"slug":"corresp-15","name":"Corresp 15","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":19,"slug":"corresp-16","name":"Corresp 16","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":20,"slug":"corresp-17","name":"Corresp 17","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":21,"slug":"corresp-18","name":"Corresp 18","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":22,"slug":"corresp-19","name":"Corresp 19","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":23,"slug":"corresp-20","name":"Corresp 20","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":24,"slug":"corresp-21","name":"Corresp 21","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":25,"slug":"corresp-22","name":"Corresp 22","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":26,"slug":"corresp-23","name":"Corresp 23","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":5,"slug":"corresp-3","name":"Corresp 3","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":6,"slug":"corresp-4","name":"Corresp 4","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":7,"slug":"corresp-5","name":"Corresp 5","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":8,"slug":"corresp-6","name":"Corresp 6","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":10,"slug":"corresp-7","name":"Corresp 7","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":11,"slug":"corresp-8","name":"Corresp 8","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":12,"slug":"corresp-9","name":"Corresp 9","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":17,"slug":"correspondent-14","name":"Correspondent 14","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0,"last_correspondence":null},{"id":2,"slug":"correspondent-2","name":"Correspondent 2","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":7,"last_correspondence":"2021-01-20T23:37:58.204614Z"},{"id":27,"slug":"michael-shamoon","name":"Michael Shamoon","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":1,"last_correspondence":"2022-03-16T03:48:50.089624Z"},{"id":4,"slug":"newest-correspondent","name":"Newest Correspondent","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":1,"last_correspondence":"2021-02-07T08:00:00Z"}]}

View File

@ -0,0 +1 @@
{"count":1,"next":null,"previous":null,"results":[{"id":1,"slug":"test","name":"Test Doc Type","match":"","matching_algorithm":1,"is_insensitive":true,"document_count":0}]}

View File

@ -0,0 +1 @@
{"original_checksum":"e959bc7d593245d92685213264e962ba","original_size":963754,"original_mime_type":"application/pdf","media_filename":"2022/lorem-ipsum.pdf","has_archive_version":true,"original_metadata":[],"archive_checksum":"5a1f46a9150bcade978c764b039ce4d0","archive_media_filename":"2022/lorem-ipsum.pdf","archive_size":351160,"archive_metadata":[{"namespace":"http://ns.adobe.com/pdf/1.3/","prefix":"pdf","key":"Producer","value":"pikepdf5.0.1"},{"namespace":"http://ns.adobe.com/xap/1.0/","prefix":"xmp","key":"ModifyDate","value":"2022-03-22T04:53:18+00:00"},{"namespace":"http://ns.adobe.com/xap/1.0/","prefix":"xmp","key":"CreateDate","value":"2022-03-22T18:05:43+00:00"},{"namespace":"http://ns.adobe.com/xap/1.0/","prefix":"xmp","key":"CreatorTool","value":"ocrmypdf13.4.0/TesseractOCR-PDF4.1.1"},{"namespace":"http://ns.adobe.com/xap/1.0/mm/","prefix":"xmpMM","key":"DocumentID","value":"uuid:df27edcf-e34a-11f7-0000-8fa6067a3c04"},{"namespace":"http://purl.org/dc/elements/1.1/","prefix":"dc","key":"format","value":"application/pdf"},{"namespace":"http://purl.org/dc/elements/1.1/","prefix":"dc","key":"title","value":"ScannedDocument"},{"namespace":"http://www.aiim.org/pdfa/ns/id/","prefix":"pdfaid","key":"part","value":"2"},{"namespace":"http://www.aiim.org/pdfa/ns/id/","prefix":"pdfaid","key":"conformance","value":"B"},{"namespace":"http://purl.org/dc/elements/1.1/","prefix":"dc","key":"creator","value":"None"},{"namespace":"http://ns.adobe.com/xap/1.0/","prefix":"xmp","key":"MetadataDate","value":"2022-03-22T21:53:18.882551-07:00"}]}

View File

@ -0,0 +1 @@
{"correspondents":[],"tags":[3],"document_types":[1]}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

View File

@ -0,0 +1 @@
{"count":3,"next":null,"previous":null,"results":[{"id":1,"name":"Inbox","show_on_dashboard":true,"show_in_sidebar":true,"sort_field":"created","sort_reverse":true,"filter_rules":[{"rule_type":6,"value":"18"}]},{"id":2,"name":"Recently Added","show_on_dashboard":true,"show_in_sidebar":false,"sort_field":"created","sort_reverse":true,"filter_rules":[]},{"id":11,"name":"Taxes","show_on_dashboard":false,"show_in_sidebar":true,"sort_field":"created","sort_reverse":true,"filter_rules":[{"rule_type":6,"value":"39"}]}]}

View File

@ -0,0 +1 @@
{"count":8,"next":null,"previous":null,"results":[{"id":4,"slug":"another-sample-tag","name":"Another Sample Tag","color":"#a6cee3","text_color":"#000000","match":"","matching_algorithm":6,"is_insensitive":true,"is_inbox_tag":false,"document_count":3},{"id":7,"slug":"newone","name":"NewOne","color":"#9e4ad1","text_color":"#ffffff","match":"","matching_algorithm":1,"is_insensitive":true,"is_inbox_tag":false,"document_count":2},{"id":6,"slug":"partial-tag","name":"Partial Tag","color":"#72dba7","text_color":"#000000","match":"","matching_algorithm":1,"is_insensitive":true,"is_inbox_tag":false,"document_count":1},{"id":2,"slug":"tag-2","name":"Tag 2","color":"#612db7","text_color":"#ffffff","match":"","matching_algorithm":1,"is_insensitive":true,"is_inbox_tag":false,"document_count":3},{"id":3,"slug":"tag-3","name":"Tag 3","color":"#b2df8a","text_color":"#000000","match":"","matching_algorithm":1,"is_insensitive":true,"is_inbox_tag":false,"document_count":4},{"id":5,"slug":"tagwithpartial","name":"TagWithPartial","color":"#3b2db4","text_color":"#ffffff","match":"","matching_algorithm":6,"is_insensitive":true,"is_inbox_tag":false,"document_count":2},{"id":8,"slug":"test-another","name":"Test Another","color":"#3ccea5","text_color":"#000000","match":"","matching_algorithm":4,"is_insensitive":true,"is_inbox_tag":false,"document_count":0},{"id":1,"slug":"test-tag","name":"Test Tag","color":"#fb9a99","text_color":"#000000","match":"","matching_algorithm":1,"is_insensitive":true,"is_inbox_tag":false,"document_count":4}]}

View File

@ -0,0 +1,64 @@
describe('document-detail', () => {
beforeEach(() => {
this.modifiedDocuments = []
cy.fixture('documents/documents.json').then((documentsJson) => {
cy.intercept('GET', 'http://localhost:8000/api/documents/1/', (req) => {
let response = { ...documentsJson }
response = response.results.find((d) => d.id == 1)
req.reply(response)
})
})
cy.intercept('PUT', 'http://localhost:8000/api/documents/1/', (req) => {
this.modifiedDocuments.push(req.body) // store this for later
req.reply({ result: 'OK' })
}).as('saveDoc')
cy.intercept('http://localhost:8000/api/documents/1/metadata/', {
fixture: 'documents/1/metadata.json',
})
cy.intercept('http://localhost:8000/api/documents/1/suggestions/', {
fixture: 'documents/1/suggestions.json',
})
cy.intercept('http://localhost:8000/api/saved_views/*', {
fixture: 'saved_views/savedviews.json',
})
cy.intercept('http://localhost:8000/api/tags/*', {
fixture: 'tags/tags.json',
})
cy.intercept('http://localhost:8000/api/correspondents/*', {
fixture: 'correspondents/correspondents.json',
})
cy.intercept('http://localhost:8000/api/document_types/*', {
fixture: 'document_types/doctypes.json',
})
cy.viewport(1024, 1024)
cy.visit('/documents/1/')
})
it('should activate / deactivate save button when changes are saved', () => {
cy.contains('button', 'Save').should('be.disabled')
cy.get('app-input-text[formcontrolname="title"]')
.type(' additional')
.wait(1500) // this delay is for frontend debounce
cy.contains('button', 'Save').should('not.be.disabled')
})
it('should warn on unsaved changes', () => {
cy.get('app-input-text[formcontrolname="title"]')
.type(' additional')
.wait(1500) // this delay is for frontend debounce
cy.get('button[title="Close"]').click()
cy.contains('You have unsaved changes')
cy.contains('button', 'Cancel').click().wait(150)
cy.contains('button', 'Save').click().wait('@saveDoc').wait(2000) // navigates away after saving
cy.contains('You have unsaved changes').should('not.exist')
})
})

View File

@ -0,0 +1,143 @@
describe('documents-list', () => {
beforeEach(() => {
this.bulkEdits = {}
// mock API methods
cy.fixture('documents/documents.json').then((documentsJson) => {
// bulk edit
cy.intercept(
'POST',
'http://localhost:8000/api/documents/bulk_edit/',
(req) => {
this.bulkEdits = req.body // store this for later
req.reply({ result: 'OK' })
}
)
cy.intercept('GET', 'http://localhost:8000/api/documents/*', (req) => {
let response = { ...documentsJson }
// bulkEdits was set earlier by bulk_edit intercept
if (this.bulkEdits.hasOwnProperty('documents')) {
response.results = response.results.map((d) => {
if ((this.bulkEdits['documents'] as Array<number>).includes(d.id)) {
switch (this.bulkEdits['method']) {
case 'modify_tags':
d.tags = (d.tags as Array<number>).concat([
this.bulkEdits['parameters']['add_tags'],
])
break
case 'set_correspondent':
d.correspondent =
this.bulkEdits['parameters']['correspondent']
break
case 'set_document_type':
d.document_type =
this.bulkEdits['parameters']['document_type']
break
}
}
return d
})
} else if (req.query.hasOwnProperty('tags__id__all')) {
// filtering e.g. http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&tags__id__all=2
const tag_id = +req.query['tags__id__all']
response.results = (documentsJson.results as Array<any>).filter((d) =>
(d.tags as Array<number>).includes(tag_id)
)
response.count = response.results.length
}
req.reply(response)
})
})
cy.intercept('http://localhost:8000/api/documents/1/thumb/', {
fixture: 'documents/lorem-ipsum.png',
})
cy.intercept('http://localhost:8000/api/tags/*', {
fixture: 'tags/tags.json',
})
cy.intercept('http://localhost:8000/api/correspondents/*', {
fixture: 'correspondents/correspondents.json',
})
cy.intercept('http://localhost:8000/api/document_types/*', {
fixture: 'document_types/doctypes.json',
})
cy.visit('/documents')
})
it('should show a list of documents rendered as cards with thumbnails', () => {
cy.contains('3 documents')
cy.contains('lorem-ipsum')
cy.get('app-document-card-small:first-of-type img')
.invoke('attr', 'src')
.should('eq', 'http://localhost:8000/api/documents/1/thumb/')
})
it('should change to table "details" view', () => {
cy.get('div.btn-group-toggle input[value="details"]').parent().click()
cy.get('table')
})
it('should change to large cards view', () => {
cy.get('div.btn-group-toggle input[value="largeCards"]').parent().click()
cy.get('app-document-card-large')
})
it('should filter tags', () => {
cy.get('app-filter-editor app-filterable-dropdown[title="Tags"]').within(
() => {
cy.contains('button', 'Tags').click()
cy.contains('button', 'Tag 2').click()
}
)
cy.contains('One document')
})
it('should apply tags', () => {
cy.get('app-document-card-small:first-of-type').click()
cy.get('app-bulk-editor app-filterable-dropdown[title="Tags"]').within(
() => {
cy.contains('button', 'Tags').click()
cy.contains('button', 'Test Tag').click()
cy.contains('button', 'Apply').click()
}
)
cy.contains('button', 'Confirm').click()
cy.get('app-document-card-small:first-of-type').contains('Test Tag')
})
it('should apply correspondent', () => {
cy.get('app-document-card-small:first-of-type').click()
cy.get(
'app-bulk-editor app-filterable-dropdown[title="Correspondent"]'
).within(() => {
cy.contains('button', 'Correspondent').click()
cy.contains('button', 'ABC Test Correspondent').click()
cy.contains('button', 'Apply').click()
})
cy.contains('button', 'Confirm').click()
cy.get('app-document-card-small:first-of-type').contains(
'ABC Test Correspondent'
)
})
it('should apply document type', () => {
cy.get('app-document-card-small:first-of-type').click()
cy.get(
'app-bulk-editor app-filterable-dropdown[title="Document type"]'
).within(() => {
cy.contains('button', 'Document type').click()
cy.contains('button', 'Test Doc Type').click()
cy.contains('button', 'Apply').click()
})
cy.contains('button', 'Confirm').click()
cy.get('app-document-card-small:first-of-type').contains('Test Doc Type')
})
})

View File

@ -0,0 +1,32 @@
describe('manage', () => {
beforeEach(() => {
cy.intercept('http://localhost:8000/api/correspondents/*', {
fixture: 'correspondents/correspondents.json',
})
cy.intercept('http://localhost:8000/api/tags/*', {
fixture: 'tags/tags.json',
})
})
it('should show a list of correspondents with bottom pagination as well', () => {
cy.visit('/correspondents')
cy.get('tbody').find('tr').its('length').should('eq', 25)
cy.get('ngb-pagination').its('length').should('eq', 2)
})
it('should show a list of tags without bottom pagination', () => {
cy.visit('/tags')
cy.get('tbody').find('tr').its('length').should('eq', 8)
cy.get('ngb-pagination').its('length').should('eq', 1)
})
it('should show a list of documents filtered by tag', () => {
cy.intercept('http://localhost:8000/api/documents/*', (req) => {
if (req.url.indexOf('tags__id__all=4'))
req.reply({ count: 3, next: null, previous: null, results: [] })
})
cy.visit('/tags')
cy.get('tbody').find('button').contains('Documents').first().click() // id = 4
cy.contains('3 documents')
})
})

View File

@ -0,0 +1,91 @@
describe('settings', () => {
beforeEach(() => {
this.modifiedViews = []
// mock API methods
cy.fixture('saved_views/savedviews.json').then((savedViewsJson) => {
// saved views PATCH
cy.intercept(
'PATCH',
'http://localhost:8000/api/saved_views/*',
(req) => {
this.modifiedViews.push(req.body) // store this for later
req.reply({ result: 'OK' })
}
)
cy.intercept('GET', 'http://localhost:8000/api/saved_views/*', (req) => {
let response = { ...savedViewsJson }
if (this.modifiedViews.length) {
response.results = response.results.map((v) => {
if (this.modifiedViews.find((mv) => mv.id == v.id))
v = this.modifiedViews.find((mv) => mv.id == v.id)
return v
})
}
req.reply(response)
}).as('savedViews')
})
cy.fixture('documents/documents.json').then((documentsJson) => {
cy.intercept('GET', 'http://localhost:8000/api/documents/1/', (req) => {
let response = { ...documentsJson }
response = response.results.find((d) => d.id == 1)
req.reply(response)
})
})
cy.intercept('http://localhost:8000/api/documents/1/metadata/', {
fixture: 'documents/1/metadata.json',
})
cy.intercept('http://localhost:8000/api/documents/1/suggestions/', {
fixture: 'documents/1/suggestions.json',
})
cy.viewport(1024, 1024)
cy.visit('/settings')
cy.wait('@savedViews')
})
it('should activate / deactivate save button when settings change and are saved', () => {
cy.contains('button', 'Save').should('be.disabled')
cy.contains('Use system settings').click()
cy.contains('button', 'Save').should('not.be.disabled')
cy.contains('button', 'Save').click()
cy.contains('button', 'Save').should('be.disabled')
})
it('should warn on unsaved changes', () => {
cy.contains('Use system settings').click()
cy.contains('a', 'Dashboard').click()
cy.contains('You have unsaved changes')
cy.contains('button', 'Cancel').click()
cy.contains('button', 'Save').click().wait('@savedViews')
cy.contains('a', 'Dashboard').click()
cy.contains('You have unsaved changes').should('not.exist')
})
it('should apply appearance changes when set', () => {
cy.contains('Use system settings').click()
cy.get('body').should('not.have.class', 'color-scheme-system')
cy.contains('Enable dark mode').click()
cy.get('body').should('have.class', 'color-scheme-dark')
})
it('should remove saved view from sidebar when unset', () => {
cy.contains('a', 'Saved views').click()
cy.get('#show_in_sidebar_1').click()
cy.contains('button', 'Save').click().wait('@savedViews')
cy.contains('li', 'Inbox').should('not.exist')
})
it('should remove saved view from dashboard when unset', () => {
cy.contains('a', 'Saved views').click()
cy.get('#show_on_dashboard_1').click()
cy.contains('button', 'Save').click().wait('@savedViews')
cy.visit('/dashboard')
cy.get('app-saved-view-widget').contains('Inbox').should('not.exist')
})
})

View File

@ -0,0 +1,3 @@
// Plugins enable you to tap into, modify, or extend the internal behavior of Cypress
// For more info, visit https://on.cypress.io/plugins-api
module.exports = (on, config) => {}

View File

@ -0,0 +1,43 @@
// ***********************************************
// This example namespace declaration will help
// with Intellisense and code completion in your
// IDE or Text Editor.
// ***********************************************
// declare namespace Cypress {
// interface Chainable<Subject = any> {
// customCommand(param: any): typeof customCommand;
// }
// }
//
// function customCommand(param: any): void {
// console.warn(param);
// }
//
// NOTE: You can use it like so:
// Cypress.Commands.add('customCommand', customCommand);
//
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })

View File

@ -0,0 +1,17 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// When a command from ./commands is ready to use, import with `import './commands'` syntax
// import './commands';

View File

@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"include": ["**/*.ts"],
"compilerOptions": {
"sourceMap": false,
"types": ["cypress"]
}
}

View File

@ -2,18 +2,16 @@
// Protractor configuration file, see link for more information // Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts // https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter'); const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter')
/** /**
* @type { import("protractor").Config } * @type { import("protractor").Config }
*/ */
exports.config = { exports.config = {
allScriptsTimeout: 11000, allScriptsTimeout: 11000,
specs: [ specs: ['./src/**/*.e2e-spec.ts'],
'./src/**/*.e2e-spec.ts'
],
capabilities: { capabilities: {
browserName: 'chrome' browserName: 'chrome',
}, },
directConnect: true, directConnect: true,
baseUrl: 'http://localhost:4200/', baseUrl: 'http://localhost:4200/',
@ -21,16 +19,18 @@ exports.config = {
jasmineNodeOpts: { jasmineNodeOpts: {
showColors: true, showColors: true,
defaultTimeoutInterval: 30000, defaultTimeoutInterval: 30000,
print: function() {} print: function () {},
}, },
onPrepare() { onPrepare() {
require('ts-node').register({ require('ts-node').register({
project: require('path').join(__dirname, './tsconfig.json') project: require('path').join(__dirname, './tsconfig.json'),
}); })
jasmine.getEnv().addReporter(new SpecReporter({ jasmine.getEnv().addReporter(
spec: { new SpecReporter({
displayStacktrace: StacktraceOption.PRETTY spec: {
} displayStacktrace: StacktraceOption.PRETTY,
})); },
} })
}; )
},
}

View File

@ -1,23 +1,25 @@
import { AppPage } from './app.po'; import { AppPage } from './app.po'
import { browser, logging } from 'protractor'; import { browser, logging } from 'protractor'
describe('workspace-project App', () => { describe('workspace-project App', () => {
let page: AppPage; let page: AppPage
beforeEach(() => { beforeEach(() => {
page = new AppPage(); page = new AppPage()
}); })
it('should display welcome message', () => { it('should display welcome message', () => {
page.navigateTo(); page.navigateTo()
expect(page.getTitleText()).toEqual('paperless-ui app is running!'); expect(page.getTitleText()).toEqual('paperless-ui app is running!')
}); })
afterEach(async () => { afterEach(async () => {
// Assert that there are no errors emitted from the browser // Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER); const logs = await browser.manage().logs().get(logging.Type.BROWSER)
expect(logs).not.toContain(jasmine.objectContaining({ expect(logs).not.toContain(
level: logging.Level.SEVERE, jasmine.objectContaining({
} as logging.Entry)); level: logging.Level.SEVERE,
}); } as logging.Entry)
}); )
})
})

View File

@ -1,11 +1,13 @@
import { browser, by, element } from 'protractor'; import { browser, by, element } from 'protractor'
export class AppPage { export class AppPage {
navigateTo(): Promise<unknown> { navigateTo(): Promise<unknown> {
return browser.get(browser.baseUrl) as Promise<unknown>; return browser.get(browser.baseUrl) as Promise<unknown>
} }
getTitleText(): Promise<string> { getTitleText(): Promise<string> {
return element(by.css('app-root .content span')).getText() as Promise<string>; return element(
by.css('app-root .content span')
).getText() as Promise<string>
} }
} }

8
src-ui/jest.config.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
moduleNameMapper: {
'@core/(.*)': '<rootDir>/src/app/core/$1',
},
preset: 'jest-preset-angular',
setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
testPathIgnorePatterns: ['/node_modules/', '/cypress/'],
}

View File

@ -1,32 +0,0 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, './coverage/paperless-ui'),
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};

14809
src-ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,53 +7,52 @@
"build": "ng build", "build": "ng build",
"test": "ng test", "test": "ng test",
"lint": "ng lint", "lint": "ng lint",
"e2e": "ng e2e" "e2e": "ng e2e",
"cy:run": "cypress run",
"e2e:ci": "concurrently 'npm run start' 'wait-on http-get://localhost:4200 && npm run cy:run' --kill-others --success first"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "~13.2.4", "@angular/common": "~13.3.1",
"@angular/common": "~13.2.5", "@angular/compiler": "~13.3.1",
"@angular/compiler": "~13.2.4", "@angular/core": "~13.3.1",
"@angular/core": "~13.2.4", "@angular/forms": "~13.3.1",
"@angular/forms": "~13.2.5", "@angular/localize": "~13.3.1",
"@angular/localize": "~13.2.4", "@angular/platform-browser": "~13.3.1",
"@angular/platform-browser": "~13.2.5", "@angular/platform-browser-dynamic": "~13.3.1",
"@angular/platform-browser-dynamic": "~13.2.4", "@angular/router": "~13.3.1",
"@angular/router": "~13.2.5", "@ng-bootstrap/ng-bootstrap": "^12.0.1",
"@ng-bootstrap/ng-bootstrap": "^12.0.0",
"@ng-select/ng-select": "^8.1.1", "@ng-select/ng-select": "^8.1.1",
"@ngneat/dirty-check-forms": "^1.1.0", "@ngneat/dirty-check-forms": "^3.0.2",
"@popperjs/core": "^2.11.2", "@popperjs/core": "^2.11.4",
"bootstrap": "^5.1.3", "bootstrap": "^5.1.3",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"ng2-pdf-viewer": "^8.0.1", "ng2-pdf-viewer": "^9.0.0",
"ngx-color": "^7.3.3", "ngx-color": "^7.3.3",
"ngx-cookie-service": "^13.1.2", "ngx-cookie-service": "^13.1.2",
"ngx-file-drop": "^13.0.0", "ngx-file-drop": "^13.0.0",
"ngx-infinite-scroll": "^10.0.1", "rxjs": "~7.5.5",
"rxjs": "~6.6.7",
"tslib": "^2.3.1", "tslib": "^2.3.1",
"uuid": "^8.3.1", "uuid": "^8.3.1",
"zone.js": "~0.11.4" "zone.js": "~0.11.4"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "~13.2.5", "@angular-builders/jest": "13.0.3",
"@angular/cli": "~13.2.5", "@angular-devkit/build-angular": "~13.3.1",
"@angular/compiler-cli": "~13.2.4", "@angular/cli": "~13.3.1",
"@types/jasmine": "~3.10.3", "@angular/compiler-cli": "~13.3.1",
"@types/jasminewd2": "~2.0.10", "@types/jest": "27.4.1",
"@types/node": "^17.0.21", "@types/node": "^17.0.23",
"codelyzer": "^6.0.2", "codelyzer": "^6.0.2",
"jasmine-core": "~4.0.1", "concurrently": "7.0.0",
"jasmine-spec-reporter": "~7.0.0", "jest": "27.5.1",
"karma": "~6.3.16",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~3.0.3",
"karma-jasmine": "~4.0.1",
"karma-jasmine-html-reporter": "^1.7.0",
"protractor": "~7.0.0",
"ts-node": "~10.7.0", "ts-node": "~10.7.0",
"tslint": "~6.1.3", "tslint": "~6.1.3",
"typescript": "~4.5.5" "typescript": "~4.6.3",
"wait-on": "~6.0.1"
},
"optionalDependencies": {
"cypress": "~9.5.3",
"@cypress/schematic": "^1.6.0"
} }
} }

30
src-ui/setup-jest.ts Normal file
View File

@ -0,0 +1,30 @@
import 'jest-preset-angular/setup-jest'
/* global mocks for jsdom */
const mock = () => {
let storage: { [key: string]: string } = {}
return {
getItem: (key: string) => (key in storage ? storage[key] : null),
setItem: (key: string, value: string) => (storage[key] = value || ''),
removeItem: (key: string) => delete storage[key],
clear: () => (storage = {}),
}
}
Object.defineProperty(window, 'localStorage', { value: mock() })
Object.defineProperty(window, 'sessionStorage', { value: mock() })
Object.defineProperty(window, 'getComputedStyle', {
value: () => ['-webkit-appearance'],
})
Object.defineProperty(document.body.style, 'transform', {
value: () => {
return {
enumerable: true,
configurable: true,
}
},
})
/* output shorter and more meaningful Zone error stack traces */
// Error.stackTraceLimit = 2

View File

@ -1,39 +1,47 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core'
import { Routes, RouterModule } from '@angular/router'; import { Routes, RouterModule } from '@angular/router'
import { AppFrameComponent } from './components/app-frame/app-frame.component'; import { AppFrameComponent } from './components/app-frame/app-frame.component'
import { DashboardComponent } from './components/dashboard/dashboard.component'; import { DashboardComponent } from './components/dashboard/dashboard.component'
import { DocumentDetailComponent } from './components/document-detail/document-detail.component'; import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
import { DocumentListComponent } from './components/document-list/document-list.component'; import { DocumentListComponent } from './components/document-list/document-list.component'
import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component'; import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component'
import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component'; import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component'
import { LogsComponent } from './components/manage/logs/logs.component'; import { LogsComponent } from './components/manage/logs/logs.component'
import { SettingsComponent } from './components/manage/settings/settings.component'; import { SettingsComponent } from './components/manage/settings/settings.component'
import { TagListComponent } from './components/manage/tag-list/tag-list.component'; import { TagListComponent } from './components/manage/tag-list/tag-list.component'
import { NotFoundComponent } from './components/not-found/not-found.component'; import { NotFoundComponent } from './components/not-found/not-found.component'
import {DocumentAsnComponent} from "./components/document-asn/document-asn.component"; import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
import { DirtyFormGuard } from './guards/dirty-form.guard'; import { DirtyFormGuard } from './guards/dirty-form.guard'
const routes: Routes = [ const routes: Routes = [
{path: '', redirectTo: 'dashboard', pathMatch: 'full'}, { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{path: '', component: AppFrameComponent, children: [ {
{path: 'dashboard', component: DashboardComponent }, path: '',
{path: 'documents', component: DocumentListComponent }, component: AppFrameComponent,
{path: 'view/:id', component: DocumentListComponent }, children: [
{path: 'documents/:id', component: DocumentDetailComponent }, { path: 'dashboard', component: DashboardComponent },
{path: 'asn/:id', component: DocumentAsnComponent }, { path: 'documents', component: DocumentListComponent },
{path: 'tags', component: TagListComponent }, { path: 'view/:id', component: DocumentListComponent },
{path: 'documenttypes', component: DocumentTypeListComponent }, { path: 'documents/:id', component: DocumentDetailComponent },
{path: 'correspondents', component: CorrespondentListComponent }, { path: 'asn/:id', component: DocumentAsnComponent },
{path: 'logs', component: LogsComponent }, { path: 'tags', component: TagListComponent },
{path: 'settings', component: SettingsComponent, canDeactivate: [DirtyFormGuard] }, { path: 'documenttypes', component: DocumentTypeListComponent },
]}, { path: 'correspondents', component: CorrespondentListComponent },
{ path: 'logs', component: LogsComponent },
{
path: 'settings',
component: SettingsComponent,
canDeactivate: [DirtyFormGuard],
},
],
},
{path: '404', component: NotFoundComponent}, { path: '404', component: NotFoundComponent },
{path: '**', redirectTo: '/404', pathMatch: 'full'} { path: '**', redirectTo: '/404', pathMatch: 'full' },
]; ]
@NgModule({ @NgModule({
imports: [RouterModule.forRoot(routes, { relativeLinkResolution: 'legacy' })], imports: [RouterModule.forRoot(routes, { relativeLinkResolution: 'legacy' })],
exports: [RouterModule] exports: [RouterModule],
}) })
export class AppRoutingModule { } export class AppRoutingModule {}

View File

@ -1,3 +1,13 @@
<app-toasts></app-toasts> <app-toasts></app-toasts>
<router-outlet></router-outlet> <ngx-file-drop dropZoneClassName="main-dropzone" contentClassName="main-content" [disabled]="!dragDropEnabled"
(onFileDrop)="dropped($event)" (onFileOver)="fileOver()" (onFileLeave)="fileLeave()">
<ng-template ngx-file-drop-content-tmp>
<div class="global-dropzone-overlay fade" [class.show]="fileIsOver" [class.hide]="hidden">
<h2 i18n>Drop files to begin upload</h2>
</div>
<div [class.inert]="fileIsOver">
<router-outlet></router-outlet>
</div>
</ng-template>
</ngx-file-drop>

View File

@ -1,35 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
RouterTestingModule
],
declarations: [
AppComponent
],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have as title 'paperless-ui'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('paperless-ui');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.content span').textContent).toContain('paperless-ui app is running!');
});
});

View File

@ -1,25 +1,36 @@
import { SettingsService, SETTINGS_KEYS } from './services/settings.service'; import { SettingsService, SETTINGS_KEYS } from './services/settings.service'
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core'
import { Router } from '@angular/router'; import { Router } from '@angular/router'
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs'
import { ConsumerStatusService } from './services/consumer-status.service'; import { ConsumerStatusService } from './services/consumer-status.service'
import { ToastService } from './services/toast.service'; import { ToastService } from './services/toast.service'
import { NgxFileDropEntry } from 'ngx-file-drop'
import { UploadDocumentsService } from './services/upload-documents.service'
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'] styleUrls: ['./app.component.scss'],
}) })
export class AppComponent implements OnInit, OnDestroy { export class AppComponent implements OnInit, OnDestroy {
newDocumentSubscription: Subscription
successSubscription: Subscription
failedSubscription: Subscription
newDocumentSubscription: Subscription; private fileLeaveTimeoutID: any
successSubscription: Subscription; fileIsOver: boolean = false
failedSubscription: Subscription; hidden: boolean = true
constructor (private settings: SettingsService, private consumerStatusService: ConsumerStatusService, private toastService: ToastService, private router: Router) { constructor(
let anyWindow = (window as any) private settings: SettingsService,
anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js'; private consumerStatusService: ConsumerStatusService,
this.settings.updateDarkModeSettings() private toastService: ToastService,
private router: Router,
private uploadDocumentsService: UploadDocumentsService
) {
let anyWindow = window as any
anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js'
this.settings.updateAppearanceSettings()
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@ -36,7 +47,12 @@ export class AppComponent implements OnInit, OnDestroy {
} }
private showNotification(key) { private showNotification(key) {
if (this.router.url == '/dashboard' && this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD)) { if (
this.router.url == '/dashboard' &&
this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
)
) {
return false return false
} }
return this.settings.get(key) return this.settings.get(key)
@ -45,26 +61,82 @@ export class AppComponent implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
this.consumerStatusService.connect() this.consumerStatusService.connect()
this.successSubscription = this.consumerStatusService
.onDocumentConsumptionFinished()
.subscribe((status) => {
if (
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)
) {
this.toastService.show({
title: $localize`Document added`,
delay: 10000,
content: $localize`Document ${status.filename} was added to paperless.`,
actionName: $localize`Open document`,
action: () => {
this.router.navigate(['documents', status.documentId])
},
})
}
})
this.successSubscription = this.consumerStatusService.onDocumentConsumptionFinished().subscribe(status => { this.failedSubscription = this.consumerStatusService
if (this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)) { .onDocumentConsumptionFailed()
this.toastService.show({title: $localize`Document added`, delay: 10000, content: $localize`Document ${status.filename} was added to paperless.`, actionName: $localize`Open document`, action: () => { .subscribe((status) => {
this.router.navigate(['documents', status.documentId]) if (
}}) this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)
} ) {
}) this.toastService.showError(
$localize`Could not add ${status.filename}\: ${status.message}`
)
}
})
this.failedSubscription = this.consumerStatusService.onDocumentConsumptionFailed().subscribe(status => { this.newDocumentSubscription = this.consumerStatusService
if (this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)) { .onDocumentDetected()
this.toastService.showError($localize`Could not add ${status.filename}\: ${status.message}`) .subscribe((status) => {
} if (
}) this.showNotification(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
this.newDocumentSubscription = this.consumerStatusService.onDocumentDetected().subscribe(status => { )
if (this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT)) { ) {
this.toastService.show({title: $localize`New document detected`, delay: 5000, content: $localize`Document ${status.filename} is being processed by paperless.`}) this.toastService.show({
} title: $localize`New document detected`,
}) delay: 5000,
content: $localize`Document ${status.filename} is being processed by paperless.`,
})
}
})
} }
public get dragDropEnabled(): boolean {
return !this.router.url.includes('dashboard')
}
public fileOver() {
// allows transition
setTimeout(() => {
this.fileIsOver = true
}, 1)
this.hidden = false
// stop fileLeave timeout
clearTimeout(this.fileLeaveTimeoutID)
}
public fileLeave(immediate: boolean = false) {
const ms = immediate ? 0 : 500
this.fileLeaveTimeoutID = setTimeout(() => {
this.fileIsOver = false
// await transition completed
setTimeout(() => {
this.hidden = true
}, 150)
}, ms)
}
public dropped(files: NgxFileDropEntry[]) {
this.fileLeave(true)
this.uploadDocumentsService.uploadFiles(files)
this.toastService.showInfo($localize`Initiating upload...`, 3000)
}
} }

View File

@ -1,86 +1,94 @@
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser'
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core'
import { AppRoutingModule } from './app-routing.module'
import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'
import { AppComponent } from './app.component'; import {
import { NgbDateAdapter, NgbDateParserFormatter, NgbModule } from '@ng-bootstrap/ng-bootstrap'; NgbDateAdapter,
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; NgbDateParserFormatter,
import { DocumentListComponent } from './components/document-list/document-list.component'; NgbModule,
import { DocumentDetailComponent } from './components/document-detail/document-detail.component'; } from '@ng-bootstrap/ng-bootstrap'
import { DashboardComponent } from './components/dashboard/dashboard.component'; import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'
import { TagListComponent } from './components/manage/tag-list/tag-list.component'; import { DocumentListComponent } from './components/document-list/document-list.component'
import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component'; import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
import { LogsComponent } from './components/manage/logs/logs.component'; import { DashboardComponent } from './components/dashboard/dashboard.component'
import { SettingsComponent } from './components/manage/settings/settings.component'; import { TagListComponent } from './components/manage/tag-list/tag-list.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component'
import { DatePipe, registerLocaleData } from '@angular/common'; import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component'
import { NotFoundComponent } from './components/not-found/not-found.component'; import { LogsComponent } from './components/manage/logs/logs.component'
import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component'; import { SettingsComponent } from './components/manage/settings/settings.component'
import { ConfirmDialogComponent } from './components/common/confirm-dialog/confirm-dialog.component'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { CorrespondentEditDialogComponent } from './components/manage/correspondent-list/correspondent-edit-dialog/correspondent-edit-dialog.component'; import { DatePipe, registerLocaleData } from '@angular/common'
import { TagEditDialogComponent } from './components/manage/tag-list/tag-edit-dialog/tag-edit-dialog.component'; import { NotFoundComponent } from './components/not-found/not-found.component'
import { DocumentTypeEditDialogComponent } from './components/manage/document-type-list/document-type-edit-dialog/document-type-edit-dialog.component'; import { ConfirmDialogComponent } from './components/common/confirm-dialog/confirm-dialog.component'
import { TagComponent } from './components/common/tag/tag.component'; import { CorrespondentEditDialogComponent } from './components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { PageHeaderComponent } from './components/common/page-header/page-header.component'; import { TagEditDialogComponent } from './components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { AppFrameComponent } from './components/app-frame/app-frame.component'; import { DocumentTypeEditDialogComponent } from './components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { ToastsComponent } from './components/common/toasts/toasts.component'; import { TagComponent } from './components/common/tag/tag.component'
import { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component'; import { PageHeaderComponent } from './components/common/page-header/page-header.component'
import { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.component'; import { AppFrameComponent } from './components/app-frame/app-frame.component'
import { ToggleableDropdownButtonComponent } from './components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'; import { ToastsComponent } from './components/common/toasts/toasts.component'
import { DateDropdownComponent } from './components/common/date-dropdown/date-dropdown.component'; import { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component'
import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component'; import { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.component'
import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component'; import { ToggleableDropdownButtonComponent } from './components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component'; import { DateDropdownComponent } from './components/common/date-dropdown/date-dropdown.component'
import { NgxFileDropModule } from 'ngx-file-drop'; import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component'
import { TextComponent } from './components/common/input/text/text.component'; import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component'
import { SelectComponent } from './components/common/input/select/select.component'; import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component'
import { CheckComponent } from './components/common/input/check/check.component'; import { NgxFileDropModule } from 'ngx-file-drop'
import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component'; import { TextComponent } from './components/common/input/text/text.component'
import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { SelectComponent } from './components/common/input/select/select.component'
import { TagsComponent } from './components/common/input/tags/tags.component'; import { CheckComponent } from './components/common/input/check/check.component'
import { SortableDirective } from './directives/sortable.directive'; import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component'
import { CookieService } from 'ngx-cookie-service'; import { TagsComponent } from './components/common/input/tags/tags.component'
import { CsrfInterceptor } from './interceptors/csrf.interceptor'; import { SortableDirective } from './directives/sortable.directive'
import { SavedViewWidgetComponent } from './components/dashboard/widgets/saved-view-widget/saved-view-widget.component'; import { CookieService } from 'ngx-cookie-service'
import { StatisticsWidgetComponent } from './components/dashboard/widgets/statistics-widget/statistics-widget.component'; import { CsrfInterceptor } from './interceptors/csrf.interceptor'
import { UploadFileWidgetComponent } from './components/dashboard/widgets/upload-file-widget/upload-file-widget.component'; import { SavedViewWidgetComponent } from './components/dashboard/widgets/saved-view-widget/saved-view-widget.component'
import { WidgetFrameComponent } from './components/dashboard/widgets/widget-frame/widget-frame.component'; import { StatisticsWidgetComponent } from './components/dashboard/widgets/statistics-widget/statistics-widget.component'
import { PdfViewerModule } from 'ng2-pdf-viewer'; import { UploadFileWidgetComponent } from './components/dashboard/widgets/upload-file-widget/upload-file-widget.component'
import { WelcomeWidgetComponent } from './components/dashboard/widgets/welcome-widget/welcome-widget.component'; import { WidgetFrameComponent } from './components/dashboard/widgets/widget-frame/widget-frame.component'
import { YesNoPipe } from './pipes/yes-no.pipe'; import { PdfViewerModule } from 'ng2-pdf-viewer'
import { FileSizePipe } from './pipes/file-size.pipe'; import { WelcomeWidgetComponent } from './components/dashboard/widgets/welcome-widget/welcome-widget.component'
import { FilterPipe } from './pipes/filter.pipe'; import { YesNoPipe } from './pipes/yes-no.pipe'
import { DocumentTitlePipe } from './pipes/document-title.pipe'; import { FileSizePipe } from './pipes/file-size.pipe'
import { MetadataCollapseComponent } from './components/document-detail/metadata-collapse/metadata-collapse.component'; import { FilterPipe } from './pipes/filter.pipe'
import { SelectDialogComponent } from './components/common/select-dialog/select-dialog.component'; import { DocumentTitlePipe } from './pipes/document-title.pipe'
import { NgSelectModule } from '@ng-select/ng-select'; import { MetadataCollapseComponent } from './components/document-detail/metadata-collapse/metadata-collapse.component'
import { NumberComponent } from './components/common/input/number/number.component'; import { SelectDialogComponent } from './components/common/select-dialog/select-dialog.component'
import { SafePipe } from './pipes/safe.pipe'; import { NgSelectModule } from '@ng-select/ng-select'
import { CustomDatePipe } from './pipes/custom-date.pipe'; import { NumberComponent } from './components/common/input/number/number.component'
import { DateComponent } from './components/common/input/date/date.component'; import { SafeUrlPipe } from './pipes/safeurl.pipe'
import { ISODateTimeAdapter } from './utils/ngb-iso-date-time-adapter'; import { SafeHtmlPipe } from './pipes/safehtml.pipe'
import { LocalizedDateParserFormatter } from './utils/ngb-date-parser-formatter'; import { CustomDatePipe } from './pipes/custom-date.pipe'
import { ApiVersionInterceptor } from './interceptors/api-version.interceptor'; import { DateComponent } from './components/common/input/date/date.component'
import { ColorSliderModule } from 'ngx-color/slider'; import { ISODateTimeAdapter } from './utils/ngb-iso-date-time-adapter'
import { ColorComponent } from './components/common/input/color/color.component'; import { LocalizedDateParserFormatter } from './utils/ngb-date-parser-formatter'
import { DocumentAsnComponent } from './components/document-asn/document-asn.component'; import { ApiVersionInterceptor } from './interceptors/api-version.interceptor'
import { ColorSliderModule } from 'ngx-color/slider'
import localeCs from '@angular/common/locales/cs'; import { ColorComponent } from './components/common/input/color/color.component'
import localeDa from '@angular/common/locales/da'; import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
import localeDe from '@angular/common/locales/de';
import localeEnGb from '@angular/common/locales/en-GB';
import localeEs from '@angular/common/locales/es';
import localeFr from '@angular/common/locales/fr';
import localeIt from '@angular/common/locales/it';
import localeLb from '@angular/common/locales/lb';
import localeNl from '@angular/common/locales/nl';
import localePl from '@angular/common/locales/pl';
import localePt from '@angular/common/locales/pt';
import localeSv from '@angular/common/locales/sv';
import localeRo from '@angular/common/locales/ro';
import localeRu from '@angular/common/locales/ru';
import localeBe from '@angular/common/locales/be'
import localeCs from '@angular/common/locales/cs'
import localeDa from '@angular/common/locales/da'
import localeDe from '@angular/common/locales/de'
import localeEnGb from '@angular/common/locales/en-GB'
import localeEs from '@angular/common/locales/es'
import localeFr from '@angular/common/locales/fr'
import localeIt from '@angular/common/locales/it'
import localeLb from '@angular/common/locales/lb'
import localeNl from '@angular/common/locales/nl'
import localePl from '@angular/common/locales/pl'
import localePt from '@angular/common/locales/pt'
import localeRo from '@angular/common/locales/ro'
import localeRu from '@angular/common/locales/ru'
import localeSl from '@angular/common/locales/sl'
import localeSr from '@angular/common/locales/sr'
import localeSv from '@angular/common/locales/sv'
import localeTr from '@angular/common/locales/tr'
import localeZh from '@angular/common/locales/zh'
registerLocaleData(localeBe)
registerLocaleData(localeCs) registerLocaleData(localeCs)
registerLocaleData(localeDa) registerLocaleData(localeDa)
registerLocaleData(localeDe) registerLocaleData(localeDe)
@ -91,11 +99,15 @@ registerLocaleData(localeIt)
registerLocaleData(localeLb) registerLocaleData(localeLb)
registerLocaleData(localeNl) registerLocaleData(localeNl)
registerLocaleData(localePl) registerLocaleData(localePl)
registerLocaleData(localePt, "pt-BR") registerLocaleData(localePt, 'pt-BR')
registerLocaleData(localePt, "pt-PT") registerLocaleData(localePt, 'pt-PT')
registerLocaleData(localeRo) registerLocaleData(localeRo)
registerLocaleData(localeRu) registerLocaleData(localeRu)
registerLocaleData(localeSl)
registerLocaleData(localeSr)
registerLocaleData(localeSv) registerLocaleData(localeSv)
registerLocaleData(localeTr)
registerLocaleData(localeZh)
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -104,8 +116,8 @@ registerLocaleData(localeSv)
DocumentDetailComponent, DocumentDetailComponent,
DashboardComponent, DashboardComponent,
TagListComponent, TagListComponent,
CorrespondentListComponent,
DocumentTypeListComponent, DocumentTypeListComponent,
CorrespondentListComponent,
LogsComponent, LogsComponent,
SettingsComponent, SettingsComponent,
NotFoundComponent, NotFoundComponent,
@ -142,11 +154,12 @@ registerLocaleData(localeSv)
MetadataCollapseComponent, MetadataCollapseComponent,
SelectDialogComponent, SelectDialogComponent,
NumberComponent, NumberComponent,
SafePipe, SafeUrlPipe,
SafeHtmlPipe,
CustomDatePipe, CustomDatePipe,
DateComponent, DateComponent,
ColorComponent, ColorComponent,
DocumentAsnComponent DocumentAsnComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,
@ -156,27 +169,28 @@ registerLocaleData(localeSv)
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
NgxFileDropModule, NgxFileDropModule,
InfiniteScrollModule,
PdfViewerModule, PdfViewerModule,
NgSelectModule, NgSelectModule,
ColorSliderModule ColorSliderModule,
], ],
providers: [ providers: [
DatePipe, DatePipe,
CookieService, { CookieService,
{
provide: HTTP_INTERCEPTORS, provide: HTTP_INTERCEPTORS,
useClass: CsrfInterceptor, useClass: CsrfInterceptor,
multi: true multi: true,
},{ },
{
provide: HTTP_INTERCEPTORS, provide: HTTP_INTERCEPTORS,
useClass: ApiVersionInterceptor, useClass: ApiVersionInterceptor,
multi: true multi: true,
}, },
FilterPipe, FilterPipe,
DocumentTitlePipe, DocumentTitlePipe,
{provide: NgbDateAdapter, useClass: ISODateTimeAdapter}, { provide: NgbDateAdapter, useClass: ISODateTimeAdapter },
{provide: NgbDateParserFormatter, useClass: LocalizedDateParserFormatter} { provide: NgbDateParserFormatter, useClass: LocalizedDateParserFormatter },
], ],
bootstrap: [AppComponent] bootstrap: [AppComponent],
}) })
export class AppModule { } export class AppModule {}

View File

@ -12,7 +12,7 @@
</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"> <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">
<form (ngSubmit)="search()" class="form-inline flex-grow-1"> <form (ngSubmit)="search()" class="form-inline flex-grow-1">
<svg width="1em" height="1em"> <svg width="1em" height="1em" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#search"/> <use xlink:href="assets/bootstrap-icons.svg#search"/>
</svg> </svg>
<input class="form-control form-control-sm" type="text" placeholder="Search documents" aria-label="Search" <input class="form-control form-control-sm" type="text" placeholder="Search documents" aria-label="Search"
@ -25,7 +25,7 @@
<span *ngIf="displayName" class="navbar-text small me-2 text-light d-none d-sm-inline"> <span *ngIf="displayName" class="navbar-text small me-2 text-light d-none d-sm-inline">
{{displayName}} {{displayName}}
</span> </span>
<svg width="1.3em" height="1.3em"> <svg width="1.3em" height="1.3em" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#person-circle"/> <use xlink:href="assets/bootstrap-icons.svg#person-circle"/>
</svg> </svg>
</button> </button>
@ -62,7 +62,7 @@
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" routerLink="documents" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" (click)="closeMenu()"> <a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()">
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#files"/> <use xlink:href="assets/bootstrap-icons.svg#files"/>
</svg>&nbsp;<ng-container i18n>Documents</ng-container> </svg>&nbsp;<ng-container i18n>Documents</ng-container>
@ -92,7 +92,7 @@
<svg class="sidebaricon" fill="currentColor"> <svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#file-text"/> <use xlink:href="assets/bootstrap-icons.svg#file-text"/>
</svg>&nbsp;{{d.title | documentTitle}} </svg>&nbsp;{{d.title | documentTitle}}
<span class="close bg-light" (click)="closeDocument(d); $event.preventDefault()"> <span class="close" (click)="closeDocument(d); $event.preventDefault()">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-x" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-x" viewBox="0 0 16 16">
<use xlink:href="assets/bootstrap-icons.svg#x"/> <use xlink:href="assets/bootstrap-icons.svg#x"/>
</svg> </svg>
@ -169,22 +169,47 @@
</li> </li>
<li class="nav-item"> <li class="nav-item">
<div class="d-flex w-100 flex-wrap"> <div class="d-flex w-100 flex-wrap">
<a class="nav-link pe-0 pb-0" target="_blank" rel="noopener noreferrer" href="https://github.com/paperless-ngx/paperless-ngx"> <a class="nav-link pe-2 pb-1" target="_blank" rel="noopener noreferrer" href="https://github.com/paperless-ngx/paperless-ngx">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="sidebaricon bi bi-github" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="sidebaricon" viewBox="0 0 16 16">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/> <use xlink:href="assets/bootstrap-icons.svg#github" />
</svg>&nbsp;<ng-container i18n>GitHub</ng-container> </svg>&nbsp;<ng-container i18n>GitHub</ng-container>
</a> </a>
<a class="nav-link-additional small text-muted ms-3" target="_blank" rel="noopener noreferrer" href="https://github.com/paperless-ngx/paperless-ngx/discussions/categories/feature-requests" title="Suggest an idea"> <a class="nav-link-additional small text-muted ms-3" target="_blank" rel="noopener noreferrer" href="https://github.com/paperless-ngx/paperless-ngx/discussions/categories/feature-requests" title="Suggest an idea">
<svg xmlns="http://www.w3.org/2000/svg" width="1.3em" height="1.3em" fill="currentColor" class="bi bi-lightbulb pe-1" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="1.1em" height="1.1em" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M2 6a6 6 0 1 1 10.174 4.31c-.203.196-.359.4-.453.619l-.762 1.769A.5.5 0 0 1 10.5 13a.5.5 0 0 1 0 1 .5.5 0 0 1 0 1l-.224.447a1 1 0 0 1-.894.553H6.618a1 1 0 0 1-.894-.553L5.5 15a.5.5 0 0 1 0-1 .5.5 0 0 1 0-1 .5.5 0 0 1-.46-.302l-.761-1.77a1.964 1.964 0 0 0-.453-.618A5.984 5.984 0 0 1 2 6zm6-5a5 5 0 0 0-3.479 8.592c.263.254.514.564.676.941L5.83 12h4.342l.632-1.467c.162-.377.413-.687.676-.941A5 5 0 0 0 8 1z"/> <use xlink:href="assets/bootstrap-icons.svg#lightbulb" />
</svg> </svg>
<ng-container i18n>Suggest an idea</ng-container> <ng-container i18n>Suggest an idea</ng-container>
</a> </a>
</div> </div>
</li> </li>
<li class="nav-item mt-2"> <li class="nav-item mt-2">
<div class="px-3 py-2 text-muted small"> <div class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap">
{{versionString}} <div class="me-3">{{ versionString }}</div>
<div *ngIf="appRemoteVersion" class="version-check">
<ng-template #updateAvailablePopContent>
<span class="small">Paperless-ngx v{{ appRemoteVersion.version }} <ng-container i18n>is available.</ng-container><br/><ng-container i18n>Click to view.</ng-container></span>
</ng-template>
<ng-template #updateCheckingNotEnabledPopContent>
<span class="small"><ng-container i18n>Checking for updates is disabled.</ng-container><br/><ng-container i18n>Click for more information.</ng-container></span>
</ng-template>
<ng-container *ngIf="appRemoteVersion.feature_is_set; else updateCheckNotSet">
<a *ngIf="appRemoteVersion.update_available" class="small text-decoration-none" target="_blank" rel="noopener noreferrer" href="https://github.com/paperless-ngx/paperless-ngx/releases"
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave" container="body">
<svg fill="currentColor" class="me-1" width="1.2em" height="1.2em" style="vertical-align: text-top;" viewBox="0 0 16 16">
<use xlink:href="assets/bootstrap-icons.svg#info-circle" />
</svg>
<ng-container *ngIf="appRemoteVersion?.update_available" i18n>Update available</ng-container>
</a>
</ng-container>
<ng-template #updateCheckNotSet>
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer" href="https://paperless-ngx.readthedocs.io/en/latest/configuration.html#update-checking"
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter:mouseleave" container="body">
<svg fill="currentColor" class="me-1" width="1.2em" height="1.2em" style="vertical-align: text-top;" viewBox="0 0 16 16">
<use xlink:href="assets/bootstrap-icons.svg#info-circle" />
</svg>
</a>
</ng-template>
</div>
</div> </div>
</li> </li>
</ul> </ul>

View File

@ -1,4 +1,3 @@
@import "/src/theme";
/* /*
* Sidebar * Sidebar
*/ */
@ -35,22 +34,24 @@
.sidebar .nav-link { .sidebar .nav-link {
font-weight: 500; font-weight: 500;
color: #333;
}
.sidebar .nav-link .sidebaricon { &:hover, &.active, &:focus {
margin-right: 4px; color: var(--bs-primary);
color: #999; }
}
.sidebar .nav-link.active { &:focus-visible {
color: $primary; outline: none;
font-weight: bold; background-color: var(--bs-body-bg);
} }
.sidebar .nav-link.active .sidebaricon, &.active {
.sidebar .nav-link:hover .sidebaricon { font-weight: bold;
color: inherit; }
.sidebaricon {
margin-right: 4px;
color: inherit;
}
} }
.sidebar-heading { .sidebar-heading {
@ -172,10 +173,29 @@
} }
&:focus { &:focus {
background-color: #fff; background-color: rgba(0, 0, 0, 0.3);
color: #212529; color: var(--bs-light);
flex-grow: 1; flex-grow: 1;
padding-left: 0.5rem; padding-left: 0.5rem;
} }
} }
} }
.version-check {
animation: pulse 2s ease-in-out 0s 1;
}
@keyframes pulse {
0% {
opacity: 0;
}
25% {
opacity: 100%;
}
75% {
opacity: 0;
}
100% {
opacity: 100%;
}
}

View File

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AppFrameComponent } from './app-frame.component';
describe('AppFrameComponent', () => {
let component: AppFrameComponent;
let fixture: ComponentFixture<AppFrameComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ AppFrameComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(AppFrameComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,36 +1,53 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core'
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms'
import { ActivatedRoute, Router, Params } from '@angular/router'; import { ActivatedRoute, Router, Params } from '@angular/router'
import { from, Observable, Subscription, BehaviorSubject } from 'rxjs'; import { from, Observable, Subscription, BehaviorSubject } from 'rxjs'
import { debounceTime, distinctUntilChanged, map, switchMap, first } from 'rxjs/operators'; import {
import { PaperlessDocument } from 'src/app/data/paperless-document'; debounceTime,
import { OpenDocumentsService } from 'src/app/services/open-documents.service'; distinctUntilChanged,
import { SavedViewService } from 'src/app/services/rest/saved-view.service'; map,
import { SearchService } from 'src/app/services/rest/search.service'; switchMap,
import { environment } from 'src/environments/environment'; first,
import { DocumentDetailComponent } from '../document-detail/document-detail.component'; } from 'rxjs/operators'
import { Meta } from '@angular/platform-browser'; import { PaperlessDocument } from 'src/app/data/paperless-document'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'; import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'; 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 { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { Meta } from '@angular/platform-browser'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
import {
RemoteVersionService,
AppRemoteVersion,
} from 'src/app/services/rest/remote-version.service'
@Component({ @Component({
selector: 'app-app-frame', selector: 'app-app-frame',
templateUrl: './app-frame.component.html', templateUrl: './app-frame.component.html',
styleUrls: ['./app-frame.component.scss'] styleUrls: ['./app-frame.component.scss'],
}) })
export class AppFrameComponent { export class AppFrameComponent {
constructor(
constructor (
public router: Router, public router: Router,
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private openDocumentsService: OpenDocumentsService, private openDocumentsService: OpenDocumentsService,
private searchService: SearchService, private searchService: SearchService,
public savedViewService: SavedViewService, public savedViewService: SavedViewService,
private list: DocumentListViewService, private list: DocumentListViewService,
private meta: Meta private meta: Meta,
) { } private remoteVersionService: RemoteVersionService
) {
this.remoteVersionService
.checkForUpdates()
.subscribe((appRemoteVersion: AppRemoteVersion) => {
this.appRemoteVersion = appRemoteVersion
})
}
versionString = `${environment.appTitle} ${environment.version}` versionString = `${environment.appTitle} ${environment.version}`
appRemoteVersion
isMenuCollapsed: boolean = true isMenuCollapsed: boolean = true
@ -48,14 +65,14 @@ export class AppFrameComponent {
text$.pipe( text$.pipe(
debounceTime(200), debounceTime(200),
distinctUntilChanged(), distinctUntilChanged(),
map(term => { map((term) => {
if (term.lastIndexOf(' ') != -1) { if (term.lastIndexOf(' ') != -1) {
return term.substring(term.lastIndexOf(' ') + 1) return term.substring(term.lastIndexOf(' ') + 1)
} else { } else {
return term return term
} }
}), }),
switchMap(term => switchMap((term) =>
term.length < 2 ? from([[]]) : this.searchService.autocomplete(term) term.length < 2 ? from([[]]) : this.searchService.autocomplete(term)
) )
) )
@ -66,49 +83,63 @@ export class AppFrameComponent {
let lastSpaceIndex = currentSearch.lastIndexOf(' ') let lastSpaceIndex = currentSearch.lastIndexOf(' ')
if (lastSpaceIndex != -1) { if (lastSpaceIndex != -1) {
currentSearch = currentSearch.substring(0, lastSpaceIndex + 1) currentSearch = currentSearch.substring(0, lastSpaceIndex + 1)
currentSearch += event.item + " " currentSearch += event.item + ' '
} else { } else {
currentSearch = event.item + " " currentSearch = event.item + ' '
} }
this.searchField.patchValue(currentSearch) this.searchField.patchValue(currentSearch)
} }
search() { search() {
this.closeMenu() this.closeMenu()
this.list.quickFilter([{rule_type: FILTER_FULLTEXT_QUERY, value: this.searchField.value}]) this.list.quickFilter([
{
rule_type: FILTER_FULLTEXT_QUERY,
value: (this.searchField.value as string).trim(),
},
])
} }
closeDocument(d: PaperlessDocument) { closeDocument(d: PaperlessDocument) {
this.openDocumentsService.closeDocument(d).pipe(first()).subscribe(confirmed => { this.openDocumentsService
if (confirmed) { .closeDocument(d)
this.closeMenu() .pipe(first())
let route = this.activatedRoute.snapshot .subscribe((confirmed) => {
while (route.firstChild) { if (confirmed) {
route = route.firstChild this.closeMenu()
let route = this.activatedRoute.snapshot
while (route.firstChild) {
route = route.firstChild
}
if (
route.component == DocumentDetailComponent &&
route.params['id'] == d.id
) {
this.router.navigate([''])
}
} }
if (route.component == DocumentDetailComponent && route.params['id'] == d.id) { })
this.router.navigate([""])
}
}
})
} }
closeAll() { closeAll() {
// user may need to confirm losing unsaved changes // user may need to confirm losing unsaved changes
this.openDocumentsService.closeAll().pipe(first()).subscribe(confirmed => { this.openDocumentsService
if (confirmed) { .closeAll()
this.closeMenu() .pipe(first())
.subscribe((confirmed) => {
if (confirmed) {
this.closeMenu()
// TODO: is there a better way to do this? // TODO: is there a better way to do this?
let route = this.activatedRoute let route = this.activatedRoute
while (route.firstChild) { while (route.firstChild) {
route = route.firstChild route = route.firstChild
}
if (route.component === DocumentDetailComponent) {
this.router.navigate([''])
}
} }
if (route.component === DocumentDetailComponent) { })
this.router.navigate([""])
}
}
})
} }
get displayName() { get displayName() {
@ -123,5 +154,4 @@ export class AppFrameComponent {
return null return null
} }
} }
} }

View File

@ -8,9 +8,12 @@
<p *ngIf="message">{{message}}</p> <p *ngIf="message">{{message}}</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-dark" (click)="cancel()" [disabled]="!buttonsEnabled" i18n>Cancel</button> <button type="button" class="btn btn-outline-secondary" (click)="cancel()" [disabled]="!buttonsEnabled" i18n>
<span class="d-inline-block" style="padding-bottom: 1px;" >Cancel</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled"> <button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}} {{btnCaption}}
<span *ngIf="!confirmButtonEnabled"> ({{seconds}})</span> <ngb-progressbar *ngIf="!confirmButtonEnabled" style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
<span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span>
</button> </button>
</div> </div>

View File

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ConfirmDialogComponent } from './confirm-dialog.component';
describe('ConfirmDialogComponent', () => {
let component: ConfirmDialogComponent;
let fixture: ComponentFixture<ConfirmDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ConfirmDialogComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ConfirmDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,15 +1,14 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Component, EventEmitter, Input, Output } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject } from 'rxjs'; import { interval, Subject, switchMap, take } from 'rxjs'
@Component({ @Component({
selector: 'app-confirm-dialog', selector: 'app-confirm-dialog',
templateUrl: './confirm-dialog.component.html', templateUrl: './confirm-dialog.component.html',
styleUrls: ['./confirm-dialog.component.scss'] styleUrls: ['./confirm-dialog.component.scss'],
}) })
export class ConfirmDialogComponent { export class ConfirmDialogComponent {
constructor(public activeModal: NgbActiveModal) {}
constructor(public activeModal: NgbActiveModal) { }
@Output() @Output()
public confirmClicked = new EventEmitter() public confirmClicked = new EventEmitter()
@ -24,7 +23,7 @@ export class ConfirmDialogComponent {
message message
@Input() @Input()
btnClass = "btn-primary" btnClass = 'btn-primary'
@Input() @Input()
btnCaption = $localize`Confirm` btnCaption = $localize`Confirm`
@ -34,19 +33,28 @@ export class ConfirmDialogComponent {
confirmButtonEnabled = true confirmButtonEnabled = true
seconds = 0 seconds = 0
secondsTotal = 0
confirmSubject: Subject<boolean> confirmSubject: Subject<boolean>
delayConfirm(seconds: number) { delayConfirm(seconds: number) {
this.confirmButtonEnabled = false const refreshInterval = 0.15 // s
this.secondsTotal = seconds
this.seconds = seconds this.seconds = seconds
setTimeout(() => {
if (this.seconds <= 1) { interval(refreshInterval * 1000)
this.confirmButtonEnabled = true .pipe(
} else { take(this.secondsTotal / refreshInterval + 2) // need 2 more for animation to complete after 0
this.delayConfirm(seconds - 1) )
} .subscribe((count) => {
}, 1000) this.seconds = Math.max(
0,
this.secondsTotal - refreshInterval * (count + 1)
)
this.confirmButtonEnabled =
this.secondsTotal - refreshInterval * count < 0
})
} }
cancel() { cancel() {

View File

@ -20,8 +20,8 @@
</div> </div>
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" <input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
[(ngModel)]="dateAfter" ngbDatepicker #dateAfterPicker="ngbDatepicker"> maxlength="10" [(ngModel)]="dateAfter" ngbDatepicker #dateAfterPicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="dateAfterPicker.toggle()" type="button"> <button class="btn btn-outline-secondary" (click)="dateAfterPicker.toggle()" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16">
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/> <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
@ -43,8 +43,8 @@
</div> </div>
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" <input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
[(ngModel)]="dateBefore" ngbDatepicker #dateBeforePicker="ngbDatepicker"> maxlength="10" [(ngModel)]="dateBefore" ngbDatepicker #dateBeforePicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="dateBeforePicker.toggle()" type="button"> <button class="btn btn-outline-secondary" (click)="dateBeforePicker.toggle()" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16">
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/> <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>

View File

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DateDropdownComponent } from './date-dropdown.component';
describe('DateDropdownComponent', () => {
let component: DateDropdownComponent;
let fixture: ComponentFixture<DateDropdownComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ DateDropdownComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(DateDropdownComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,10 +1,17 @@
import { formatDate } from '@angular/common'; import { formatDate } from '@angular/common'
import { Component, EventEmitter, Input, Output, OnInit, OnDestroy } from '@angular/core'; import {
import { NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap'; Component,
import { Subject, Subscription } from 'rxjs'; EventEmitter,
import { debounceTime } from 'rxjs/operators'; Input,
import { SettingsService } from 'src/app/services/settings.service'; Output,
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'; OnInit,
OnDestroy,
} from '@angular/core'
import { NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap'
import { Subject, Subscription } from 'rxjs'
import { debounceTime } from 'rxjs/operators'
import { SettingsService } from 'src/app/services/settings.service'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
export interface DateSelection { export interface DateSelection {
before?: string before?: string
@ -20,21 +27,18 @@ const LAST_YEAR = 3
selector: 'app-date-dropdown', selector: 'app-date-dropdown',
templateUrl: './date-dropdown.component.html', templateUrl: './date-dropdown.component.html',
styleUrls: ['./date-dropdown.component.scss'], styleUrls: ['./date-dropdown.component.scss'],
providers: [ providers: [{ provide: NgbDateAdapter, useClass: ISODateAdapter }],
{provide: NgbDateAdapter, useClass: ISODateAdapter},
]
}) })
export class DateDropdownComponent implements OnInit, OnDestroy { export class DateDropdownComponent implements OnInit, OnDestroy {
constructor(settings: SettingsService) { constructor(settings: SettingsService) {
this.datePlaceHolder = settings.getLocalizedDateInputFormat() this.datePlaceHolder = settings.getLocalizedDateInputFormat()
} }
quickFilters = [ quickFilters = [
{id: LAST_7_DAYS, name: $localize`Last 7 days`}, { id: LAST_7_DAYS, name: $localize`Last 7 days` },
{id: LAST_MONTH, name: $localize`Last month`}, { id: LAST_MONTH, name: $localize`Last month` },
{id: LAST_3_MONTHS, name: $localize`Last 3 months`}, { id: LAST_3_MONTHS, name: $localize`Last 3 months` },
{id: LAST_YEAR, name: $localize`Last year`} { id: LAST_YEAR, name: $localize`Last year` },
] ]
datePlaceHolder: string datePlaceHolder: string
@ -62,9 +66,7 @@ export class DateDropdownComponent implements OnInit, OnDestroy {
private sub: Subscription private sub: Subscription
ngOnInit() { ngOnInit() {
this.sub = this.datesSetDebounce$.pipe( this.sub = this.datesSetDebounce$.pipe(debounceTime(400)).subscribe(() => {
debounceTime(400)
).subscribe(() => {
this.onChange() this.onChange()
}) })
} }
@ -81,11 +83,11 @@ export class DateDropdownComponent implements OnInit, OnDestroy {
switch (qf) { switch (qf) {
case LAST_7_DAYS: case LAST_7_DAYS:
date.setDate(date.getDate() - 7) date.setDate(date.getDate() - 7)
break; break
case LAST_MONTH: case LAST_MONTH:
date.setMonth(date.getMonth() - 1) date.setMonth(date.getMonth() - 1)
break; break
case LAST_3_MONTHS: case LAST_3_MONTHS:
date.setMonth(date.getMonth() - 3) date.setMonth(date.getMonth() - 3)
@ -94,20 +96,22 @@ export class DateDropdownComponent implements OnInit, OnDestroy {
case LAST_YEAR: case LAST_YEAR:
date.setFullYear(date.getFullYear() - 1) date.setFullYear(date.getFullYear() - 1)
break break
}
} this.dateAfter = formatDate(date, 'yyyy-MM-dd', 'en-us', 'UTC')
this.dateAfter = formatDate(date, 'yyyy-MM-dd', "en-us", "UTC")
this.onChange() this.onChange()
} }
onChange() { onChange() {
this.dateAfterChange.emit(this.dateAfter) this.dateAfterChange.emit(this.dateAfter)
this.dateBeforeChange.emit(this.dateBefore) this.dateBeforeChange.emit(this.dateBefore)
this.datesSet.emit({after: this.dateAfter, before: this.dateBefore}) this.datesSet.emit({ after: this.dateAfter, before: this.dateBefore })
} }
onChangeDebounce() { onChangeDebounce() {
this.datesSetDebounce$.next({after: this.dateAfter, before: this.dateBefore}) this.datesSetDebounce$.next({
after: this.dateAfter,
before: this.dateBefore,
})
} }
clearBefore() { clearBefore() {
@ -120,4 +124,10 @@ export class DateDropdownComponent implements OnInit, OnDestroy {
this.onChange() this.onChange()
} }
// prevent chars other than numbers and separators
onKeyPress(event: KeyboardEvent) {
if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) {
event.preventDefault()
}
}
} }

View File

@ -11,7 +11,7 @@
<app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive" novalidate></app-input-check> <app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive" novalidate></app-input-check>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-dark" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button> <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
</div> </div>
</form> </form>

View File

@ -1,19 +1,22 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms'; import { FormControl, FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'; import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'; import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'; import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { ToastService } from 'src/app/services/toast.service'; import { ToastService } from 'src/app/services/toast.service'
@Component({ @Component({
selector: 'app-correspondent-edit-dialog', selector: 'app-correspondent-edit-dialog',
templateUrl: './correspondent-edit-dialog.component.html', templateUrl: './correspondent-edit-dialog.component.html',
styleUrls: ['./correspondent-edit-dialog.component.scss'] styleUrls: ['./correspondent-edit-dialog.component.scss'],
}) })
export class CorrespondentEditDialogComponent extends EditDialogComponent<PaperlessCorrespondent> { export class CorrespondentEditDialogComponent extends EditDialogComponent<PaperlessCorrespondent> {
constructor(
constructor(service: CorrespondentService, activeModal: NgbActiveModal, toastService: ToastService) { service: CorrespondentService,
activeModal: NgbActiveModal,
toastService: ToastService
) {
super(service, activeModal, toastService) super(service, activeModal, toastService)
} }
@ -29,9 +32,8 @@ export class CorrespondentEditDialogComponent extends EditDialogComponent<Paperl
return new FormGroup({ return new FormGroup({
name: new FormControl(''), name: new FormControl(''),
matching_algorithm: new FormControl(1), matching_algorithm: new FormControl(1),
match: new FormControl(""), match: new FormControl(''),
is_insensitive: new FormControl(true) is_insensitive: new FormControl(true),
}) })
} }
} }

View File

@ -13,7 +13,7 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-dark" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button> <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
</div> </div>
</form> </form>

View File

@ -1,19 +1,22 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms'; import { FormControl, FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'; import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'; import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'; import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { ToastService } from 'src/app/services/toast.service'; import { ToastService } from 'src/app/services/toast.service'
@Component({ @Component({
selector: 'app-document-type-edit-dialog', selector: 'app-document-type-edit-dialog',
templateUrl: './document-type-edit-dialog.component.html', templateUrl: './document-type-edit-dialog.component.html',
styleUrls: ['./document-type-edit-dialog.component.scss'] styleUrls: ['./document-type-edit-dialog.component.scss'],
}) })
export class DocumentTypeEditDialogComponent extends EditDialogComponent<PaperlessDocumentType> { export class DocumentTypeEditDialogComponent extends EditDialogComponent<PaperlessDocumentType> {
constructor(
constructor(service: DocumentTypeService, activeModal: NgbActiveModal, toastService: ToastService) { service: DocumentTypeService,
activeModal: NgbActiveModal,
toastService: ToastService
) {
super(service, activeModal, toastService) super(service, activeModal, toastService)
} }
@ -29,9 +32,8 @@ export class DocumentTypeEditDialogComponent extends EditDialogComponent<Paperle
return new FormGroup({ return new FormGroup({
name: new FormControl(''), name: new FormControl(''),
matching_algorithm: new FormControl(1), matching_algorithm: new FormControl(1),
match: new FormControl(""), match: new FormControl(''),
is_insensitive: new FormControl(true) is_insensitive: new FormControl(true),
}) })
} }
} }

View File

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EditDialogComponent } from './edit-dialog.component';
describe('EditDialogComponent', () => {
let component: EditDialogComponent;
let fixture: ComponentFixture<EditDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ EditDialogComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(EditDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,20 +1,22 @@
import { Directive, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { Directive, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { FormGroup } from '@angular/forms'; import { FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { Observable } from 'rxjs'; import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators'
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'; import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
import { ObjectWithId } from 'src/app/data/object-with-id'; import { ObjectWithId } from 'src/app/data/object-with-id'
import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service'; import { AbstractPaperlessService } from 'src/app/services/rest/abstract-paperless-service'
import { ToastService } from 'src/app/services/toast.service'; import { ToastService } from 'src/app/services/toast.service'
@Directive() @Directive()
export abstract class EditDialogComponent<T extends ObjectWithId> implements OnInit { export abstract class EditDialogComponent<T extends ObjectWithId>
implements OnInit
{
constructor( constructor(
private service: AbstractPaperlessService<T>, private service: AbstractPaperlessService<T>,
private activeModal: NgbActiveModal, private activeModal: NgbActiveModal,
private toastService: ToastService) { } private toastService: ToastService
) {}
@Input() @Input()
dialogMode: string = 'create' dialogMode: string = 'create'
@ -43,7 +45,7 @@ export abstract class EditDialogComponent<T extends ObjectWithId> implements OnI
// wait to enable close button so it doesnt steal focus from input since its the first clickable element in the DOM // wait to enable close button so it doesnt steal focus from input since its the first clickable element in the DOM
setTimeout(() => { setTimeout(() => {
this.closeEnabled = true this.closeEnabled = true
}); })
} }
getCreateTitle() { getCreateTitle() {
@ -65,7 +67,7 @@ export abstract class EditDialogComponent<T extends ObjectWithId> implements OnI
case 'edit': case 'edit':
return this.getEditTitle() return this.getEditTitle()
default: default:
break; break
} }
} }
@ -78,25 +80,31 @@ export abstract class EditDialogComponent<T extends ObjectWithId> implements OnI
} }
save() { save() {
var newObject = Object.assign(Object.assign({}, this.object), this.objectForm.value) var newObject = Object.assign(
Object.assign({}, this.object),
this.objectForm.value
)
var serverResponse: Observable<T> var serverResponse: Observable<T>
switch (this.dialogMode) { switch (this.dialogMode) {
case 'create': case 'create':
serverResponse = this.service.create(newObject) serverResponse = this.service.create(newObject)
break; break
case 'edit': case 'edit':
serverResponse = this.service.update(newObject) serverResponse = this.service.update(newObject)
default: default:
break; break
} }
this.networkActive = true this.networkActive = true
serverResponse.subscribe(result => { serverResponse.subscribe(
this.activeModal.close() (result) => {
this.success.emit(result) this.activeModal.close()
}, error => { this.success.emit(result)
this.error = error.error },
this.networkActive = false (error) => {
}) this.error = error.error
this.networkActive = false
}
)
} }
cancel() { cancel() {

View File

@ -15,7 +15,7 @@
<app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive"></app-input-check> <app-input-check *ngIf="patternRequired" i18n-title title="Case insensitive" formControlName="is_insensitive"></app-input-check>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-dark" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button> <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
</div> </div>
</form> </form>

View File

@ -0,0 +1,42 @@
import { Component } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { PaperlessTag } from 'src/app/data/paperless-tag'
import { TagService } from 'src/app/services/rest/tag.service'
import { ToastService } from 'src/app/services/toast.service'
import { randomColor } from 'src/app/utils/color'
@Component({
selector: 'app-tag-edit-dialog',
templateUrl: './tag-edit-dialog.component.html',
styleUrls: ['./tag-edit-dialog.component.scss'],
})
export class TagEditDialogComponent extends EditDialogComponent<PaperlessTag> {
constructor(
service: TagService,
activeModal: NgbActiveModal,
toastService: ToastService
) {
super(service, activeModal, toastService)
}
getCreateTitle() {
return $localize`Create new tag`
}
getEditTitle() {
return $localize`Edit tag`
}
getForm(): FormGroup {
return new FormGroup({
name: new FormControl(''),
color: new FormControl(randomColor()),
is_inbox_tag: new FormControl(false),
matching_algorithm: new FormControl(1),
match: new FormControl(''),
is_insensitive: new FormControl(true),
})
}
}

View File

@ -6,7 +6,7 @@
<div class="d-none d-sm-inline">&nbsp;{{title}}</div> <div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<ng-container *ngIf="!editing && selectionModel.selectionSize() > 0"> <ng-container *ngIf="!editing && selectionModel.selectionSize() > 0">
<div *ngIf="multiple" class="position-absolute top-0 start-100 translate-middle badge bg-secondary border border-light text-light rounded-pill"> <div *ngIf="multiple" class="position-absolute top-0 start-100 translate-middle badge bg-secondary border border-light text-light rounded-pill">
{{selectionModel.selectionSize()}}<span class="visually-hidden">selected</span> {{selectionModel.totalCount}}<span class="visually-hidden">selected</span>
</div> </div>
<div *ngIf="!multiple" class="position-absolute top-0 start-100 p-2 translate-middle badge bg-secondary border border-light rounded-circle"> <div *ngIf="!multiple" class="position-absolute top-0 start-100 p-2 translate-middle badge bg-secondary border border-light rounded-circle">
<span class="visually-hidden">selected</span> <span class="visually-hidden">selected</span>
@ -18,10 +18,10 @@
<div *ngIf="!editing && multiple" class="list-group-item d-flex"> <div *ngIf="!editing && multiple" class="list-group-item d-flex">
<div class="btn-group btn-group-xs btn-group-toggle flex-fill" ngbRadioGroup [(ngModel)]="selectionModel.logicalOperator" (change)="selectionModel.toggleOperator()" [disabled]="!operatorToggleEnabled"> <div class="btn-group btn-group-xs btn-group-toggle flex-fill" ngbRadioGroup [(ngModel)]="selectionModel.logicalOperator" (change)="selectionModel.toggleOperator()" [disabled]="!operatorToggleEnabled">
<label ngbButtonLabel class="btn btn-outline-primary"> <label ngbButtonLabel class="btn btn-outline-primary">
<input ngbButton type="radio" class="btn-check" name="logicalOperator" value="and"> All <input ngbButton type="radio" class="btn-check" name="logicalOperator" value="and" i18n> All
</label> </label>
<label ngbButtonLabel class="btn btn-outline-primary"> <label ngbButtonLabel class="btn btn-outline-primary">
<input ngbButton type="radio" class="btn-check" name="logicalOperator" value="or"> Any <input ngbButton type="radio" class="btn-check" name="logicalOperator" value="or" i18n> Any
</label> </label>
</div> </div>
</div> </div>

View File

@ -1,5 +1,3 @@
@import "/src/theme";
.badge-corner { .badge-corner {
position: absolute; position: absolute;
top: -8px; top: -8px;
@ -42,7 +40,7 @@
filter: brightness(0.5); filter: brightness(0.5);
&.active { &.active {
background-color: lighten($primary, 30%); background-color: var(--pngx-primary-lighten-30);
} }
} }
@ -60,4 +58,4 @@ small > svg {
.show .btn-outline-primary { .show .btn-outline-primary {
color: #fff; color: #fff;
} }

View File

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FilterableDropodownComponent } from './filterable-dropdown.component';
describe('FilterableDropodownComponent', () => {
let component: FilterableDropodownComponent;
let fixture: ComponentFixture<FilterableDropodownComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ FilterableDropodownComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(FilterableDropodownComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,17 +1,23 @@
import { Component, EventEmitter, Input, Output, ElementRef, ViewChild } from '@angular/core'; import {
import { FilterPipe } from 'src/app/pipes/filter.pipe'; Component,
EventEmitter,
Input,
Output,
ElementRef,
ViewChild,
} from '@angular/core'
import { FilterPipe } from 'src/app/pipes/filter.pipe'
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
import { ToggleableItemState } from './toggleable-dropdown-button/toggleable-dropdown-button.component'; import { ToggleableItemState } from './toggleable-dropdown-button/toggleable-dropdown-button.component'
import { MatchingModel } from 'src/app/data/matching-model'; import { MatchingModel } from 'src/app/data/matching-model'
import { Subject } from 'rxjs'; import { Subject } from 'rxjs'
export interface ChangedItems { export interface ChangedItems {
itemsToAdd: MatchingModel[], itemsToAdd: MatchingModel[]
itemsToRemove: MatchingModel[] itemsToRemove: MatchingModel[]
} }
export class FilterableDropdownSelectionModel { export class FilterableDropdownSelectionModel {
changed = new Subject<FilterableDropdownSelectionModel>() changed = new Subject<FilterableDropdownSelectionModel>()
multiple = false multiple = false
@ -22,14 +28,20 @@ export class FilterableDropdownSelectionModel {
get itemsSorted(): MatchingModel[] { get itemsSorted(): MatchingModel[] {
// TODO: this is getting called very often // TODO: this is getting called very often
return this.items.sort((a,b) => { return this.items.sort((a, b) => {
if (a.id == null && b.id != null) { if (a.id == null && b.id != null) {
return -1 return -1
} else if (a.id != null && b.id == null) { } else if (a.id != null && b.id == null) {
return 1 return 1
} else if (this.getNonTemporary(a.id) == ToggleableItemState.NotSelected && this.getNonTemporary(b.id) != ToggleableItemState.NotSelected) { } else if (
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
this.getNonTemporary(b.id) != ToggleableItemState.NotSelected
) {
return 1 return 1
} else if (this.getNonTemporary(a.id) != ToggleableItemState.NotSelected && this.getNonTemporary(b.id) == ToggleableItemState.NotSelected) { } else if (
this.getNonTemporary(a.id) != ToggleableItemState.NotSelected &&
this.getNonTemporary(b.id) == ToggleableItemState.NotSelected
) {
return -1 return -1
} else { } else {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
@ -42,11 +54,17 @@ export class FilterableDropdownSelectionModel {
private temporarySelectionStates = new Map<number, ToggleableItemState>() private temporarySelectionStates = new Map<number, ToggleableItemState>()
getSelectedItems() { getSelectedItems() {
return this.items.filter(i => this.temporarySelectionStates.get(i.id) == ToggleableItemState.Selected) return this.items.filter(
(i) =>
this.temporarySelectionStates.get(i.id) == ToggleableItemState.Selected
)
} }
getExcludedItems() { getExcludedItems() {
return this.items.filter(i => this.temporarySelectionStates.get(i.id) == ToggleableItemState.Excluded) return this.items.filter(
(i) =>
this.temporarySelectionStates.get(i.id) == ToggleableItemState.Excluded
)
} }
set(id: number, state: ToggleableItemState, fireEvent = true) { set(id: number, state: ToggleableItemState, fireEvent = true) {
@ -62,9 +80,16 @@ export class FilterableDropdownSelectionModel {
toggle(id: number, fireEvent = true) { toggle(id: number, fireEvent = true) {
let state = this.temporarySelectionStates.get(id) let state = this.temporarySelectionStates.get(id)
if (state == null || (state != ToggleableItemState.Selected && state != ToggleableItemState.Excluded)) { if (
state == null ||
(state != ToggleableItemState.Selected &&
state != ToggleableItemState.Excluded)
) {
this.temporarySelectionStates.set(id, ToggleableItemState.Selected) this.temporarySelectionStates.set(id, ToggleableItemState.Selected)
} else if (state == ToggleableItemState.Selected || state == ToggleableItemState.Excluded) { } else if (
state == ToggleableItemState.Selected ||
state == ToggleableItemState.Excluded
) {
this.temporarySelectionStates.delete(id) this.temporarySelectionStates.delete(id)
} }
@ -91,7 +116,7 @@ export class FilterableDropdownSelectionModel {
} }
} }
exclude(id: number, fireEvent:boolean = true) { exclude(id: number, fireEvent: boolean = true) {
let state = this.temporarySelectionStates.get(id) let state = this.temporarySelectionStates.get(id)
if (state == null || state != ToggleableItemState.Excluded) { if (state == null || state != ToggleableItemState.Excluded) {
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded) this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
@ -130,13 +155,19 @@ export class FilterableDropdownSelectionModel {
} }
get(id: number) { get(id: number) {
return this.temporarySelectionStates.get(id) || ToggleableItemState.NotSelected return (
this.temporarySelectionStates.get(id) || ToggleableItemState.NotSelected
)
} }
selectionSize() { selectionSize() {
return this.getSelectedItems().length return this.getSelectedItems().length
} }
get totalCount() {
return this.getSelectedItems().length + this.getExcludedItems().length
}
clear(fireEvent = true) { clear(fireEvent = true) {
this.temporarySelectionStates.clear() this.temporarySelectionStates.clear()
this.temporaryLogicalOperator = this._logicalOperator = 'and' this.temporaryLogicalOperator = this._logicalOperator = 'and'
@ -146,9 +177,19 @@ export class FilterableDropdownSelectionModel {
} }
isDirty() { isDirty() {
if (!Array.from(this.temporarySelectionStates.keys()).every(id => this.temporarySelectionStates.get(id) == this.selectionStates.get(id))) { if (
!Array.from(this.temporarySelectionStates.keys()).every(
(id) =>
this.temporarySelectionStates.get(id) == this.selectionStates.get(id)
)
) {
return true return true
} else if (!Array.from(this.selectionStates.keys()).every(id => this.selectionStates.get(id) == this.temporarySelectionStates.get(id))) { } else if (
!Array.from(this.selectionStates.keys()).every(
(id) =>
this.selectionStates.get(id) == this.temporarySelectionStates.get(id)
)
) {
return true return true
} else if (this.temporaryLogicalOperator !== this._logicalOperator) { } else if (this.temporaryLogicalOperator !== this._logicalOperator) {
return true return true
@ -158,7 +199,10 @@ export class FilterableDropdownSelectionModel {
} }
isNoneSelected() { isNoneSelected() {
return this.selectionSize() == 1 && this.get(null) == ToggleableItemState.Selected return (
this.selectionSize() == 1 &&
this.get(null) == ToggleableItemState.Selected
)
} }
init(map) { init(map) {
@ -183,8 +227,17 @@ export class FilterableDropdownSelectionModel {
diff(): ChangedItems { diff(): ChangedItems {
return { return {
itemsToAdd: this.items.filter(item => this.temporarySelectionStates.get(item.id) == ToggleableItemState.Selected && this.selectionStates.get(item.id) != ToggleableItemState.Selected), itemsToAdd: this.items.filter(
itemsToRemove: this.items.filter(item => !this.temporarySelectionStates.has(item.id) && this.selectionStates.has(item.id)), (item) =>
this.temporarySelectionStates.get(item.id) ==
ToggleableItemState.Selected &&
this.selectionStates.get(item.id) != ToggleableItemState.Selected
),
itemsToRemove: this.items.filter(
(item) =>
!this.temporarySelectionStates.has(item.id) &&
this.selectionStates.has(item.id)
),
} }
} }
} }
@ -192,10 +245,9 @@ export class FilterableDropdownSelectionModel {
@Component({ @Component({
selector: 'app-filterable-dropdown', selector: 'app-filterable-dropdown',
templateUrl: './filterable-dropdown.component.html', templateUrl: './filterable-dropdown.component.html',
styleUrls: ['./filterable-dropdown.component.scss'] styleUrls: ['./filterable-dropdown.component.scss'],
}) })
export class FilterableDropdownComponent { export class FilterableDropdownComponent {
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef @ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
@ViewChild('dropdown') dropdown: NgbDropdown @ViewChild('dropdown') dropdown: NgbDropdown
@ -207,7 +259,7 @@ export class FilterableDropdownComponent {
this._selectionModel.items = Array.from(items) this._selectionModel.items = Array.from(items)
this._selectionModel.items.unshift({ this._selectionModel.items.unshift({
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`, name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
id: null id: null,
}) })
} }
} }
@ -225,7 +277,7 @@ export class FilterableDropdownComponent {
model.items = this.selectionModel.items model.items = this.selectionModel.items
model.multiple = this.selectionModel.multiple model.multiple = this.selectionModel.multiple
} }
model.changed.subscribe(updatedModel => { model.changed.subscribe((updatedModel) => {
this.selectionModelChange.next(updatedModel) this.selectionModelChange.next(updatedModel)
}) })
this._selectionModel = model this._selectionModel = model
@ -251,7 +303,7 @@ export class FilterableDropdownComponent {
title: string title: string
@Input() @Input()
filterPlaceholder: string = "" filterPlaceholder: string = ''
@Input() @Input()
icon: string icon: string
@ -272,14 +324,17 @@ export class FilterableDropdownComponent {
open = new EventEmitter() open = new EventEmitter()
get operatorToggleEnabled(): boolean { get operatorToggleEnabled(): boolean {
return this.selectionModel.selectionSize() > 1 && this.selectionModel.getExcludedItems().length == 0 return (
this.selectionModel.selectionSize() > 1 &&
this.selectionModel.getExcludedItems().length == 0
)
} }
modelIsDirty: boolean = false modelIsDirty: boolean = false
constructor(private filterPipe: FilterPipe) { constructor(private filterPipe: FilterPipe) {
this.selectionModel = new FilterableDropdownSelectionModel() this.selectionModel = new FilterableDropdownSelectionModel()
this.selectionModelChange.subscribe(updatedModel => { this.selectionModelChange.subscribe((updatedModel) => {
this.modelIsDirty = updatedModel.isDirty() this.modelIsDirty = updatedModel.isDirty()
}) })
} }
@ -296,12 +351,12 @@ export class FilterableDropdownComponent {
dropdownOpenChange(open: boolean): void { dropdownOpenChange(open: boolean): void {
if (open) { if (open) {
setTimeout(() => { setTimeout(() => {
this.listFilterTextInput.nativeElement.focus(); this.listFilterTextInput.nativeElement.focus()
}, 0) }, 0)
if (this.editing) { if (this.editing) {
this.selectionModel.reset() this.selectionModel.reset()
} }
this.open.next() this.open.next(this)
} else { } else {
this.filterText = '' this.filterText = ''
if (this.applyOnClose && this.selectionModel.isDirty()) { if (this.applyOnClose && this.selectionModel.isDirty()) {

View File

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ToggleableDropdownButtonComponent } from './toggleable-dropdown-button.component';
describe('ToggleableDropdownButtonComponent', () => {
let component: ToggleableDropdownButtonComponent;
let fixture: ComponentFixture<ToggleableDropdownButtonComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ToggleableDropdownButtonComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ToggleableDropdownButtonComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,20 +1,19 @@
import { Component, EventEmitter, Input, Output, OnInit } from '@angular/core'; import { Component, EventEmitter, Input, Output, OnInit } from '@angular/core'
import { MatchingModel } from 'src/app/data/matching-model'; import { MatchingModel } from 'src/app/data/matching-model'
export enum ToggleableItemState { export enum ToggleableItemState {
NotSelected = 0, NotSelected = 0,
Selected = 1, Selected = 1,
PartiallySelected = 2, PartiallySelected = 2,
Excluded = 3 Excluded = 3,
} }
@Component({ @Component({
selector: 'app-toggleable-dropdown-button', selector: 'app-toggleable-dropdown-button',
templateUrl: './toggleable-dropdown-button.component.html', templateUrl: './toggleable-dropdown-button.component.html',
styleUrls: ['./toggleable-dropdown-button.component.scss'] styleUrls: ['./toggleable-dropdown-button.component.scss'],
}) })
export class ToggleableDropdownButtonComponent { export class ToggleableDropdownButtonComponent {
@Input() @Input()
item: MatchingModel item: MatchingModel

View File

@ -1,30 +1,29 @@
import { Directive, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; import { Directive, ElementRef, Input, OnInit, ViewChild } from '@angular/core'
import { ControlValueAccessor } from '@angular/forms'; import { ControlValueAccessor } from '@angular/forms'
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid'
@Directive() @Directive()
export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor { export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
@ViewChild('inputField')
@ViewChild("inputField")
inputField: ElementRef inputField: ElementRef
constructor() { } constructor() {}
onChange = (newValue: T) => {}; onChange = (newValue: T) => {}
onTouched = () => {}; onTouched = () => {}
writeValue(newValue: any): void { writeValue(newValue: any): void {
this.value = newValue this.value = newValue
} }
registerOnChange(fn: any): void { registerOnChange(fn: any): void {
this.onChange = fn; this.onChange = fn
} }
registerOnTouched(fn: any): void { registerOnTouched(fn: any): void {
this.onTouched = fn; this.onTouched = fn
} }
setDisabledState?(isDisabled: boolean): void { setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled; this.disabled = isDisabled
} }
focus() { focus() {
@ -37,7 +36,7 @@ export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
title: string title: string
@Input() @Input()
disabled = false; disabled = false
@Input() @Input()
error: string error: string

View File

@ -1,25 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CheckComponent } from './check.component';
describe('CheckComponent', () => {
let component: CheckComponent;
let fixture: ComponentFixture<CheckComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ CheckComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(CheckComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,22 +1,22 @@
import { Component, forwardRef, Input, OnInit } from '@angular/core'; import { Component, forwardRef, Input, OnInit } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid'
import { AbstractInputComponent } from '../abstract-input'; import { AbstractInputComponent } from '../abstract-input'
@Component({ @Component({
providers: [{ providers: [
provide: NG_VALUE_ACCESSOR, {
useExisting: forwardRef(() => CheckComponent), provide: NG_VALUE_ACCESSOR,
multi: true useExisting: forwardRef(() => CheckComponent),
}], multi: true,
},
],
selector: 'app-input-check', selector: 'app-input-check',
templateUrl: './check.component.html', templateUrl: './check.component.html',
styleUrls: ['./check.component.scss'] styleUrls: ['./check.component.scss'],
}) })
export class CheckComponent extends AbstractInputComponent<boolean> { export class CheckComponent extends AbstractInputComponent<boolean> {
constructor() { constructor() {
super() super()
} }
} }

View File

@ -1,5 +1,5 @@
<div class="mb-3"> <div class="mb-3">
<label [for]="inputId">{{title}}</label> <label *ngIf="title" [for]="inputId">{{title}}</label>
<div class="input-group" [class.is-invalid]="error"> <div class="input-group" [class.is-invalid]="error">
<span class="input-group-text" [style.background-color]="value">&nbsp;&nbsp;&nbsp;</span> <span class="input-group-text" [style.background-color]="value">&nbsp;&nbsp;&nbsp;</span>

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