Merge remote-tracking branch 'origin/dev'

This commit is contained in:
Trenton Holmes 2023-12-05 18:37:39 -08:00
commit 6cfe92bed1
190 changed files with 28622 additions and 15672 deletions

1
.env
View File

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

View File

@ -643,7 +643,7 @@ jobs:
git push origin ${{ needs.publish-release.outputs.version }}-changelog
-
name: Create Pull Request
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
script: |
const { repo, owner } = context.repo;

View File

@ -19,9 +19,13 @@ concurrency:
jobs:
cleanup-images:
name: Cleanup Image Tags for paperless-ngx
name: Cleanup Image Tags for ${{ matrix.primary-name }}
if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
primary-name: ["paperless-ngx", "paperless-ngx/builder/cache/app"]
env:
# Requires a personal access token with the OAuth scope delete:packages
TOKEN: ${{ secrets.GHA_CONTAINER_DELETE_TOKEN }}
@ -29,12 +33,12 @@ jobs:
-
name: Clean temporary images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@v0.3.0
uses: stumpylog/image-cleaner-action/ephemeral@v0.4.0
with:
token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}"
is_org: "true"
package_name: "paperless-ngx"
package_name: "${{ matrix.primary-name }}"
scheme: "branch"
repo_name: "paperless-ngx"
match_regex: "feature-"
@ -49,18 +53,7 @@ jobs:
strategy:
fail-fast: false
matrix:
include:
- primary-name: "paperless-ngx"
- primary-name: "paperless-ngx/builder/cache/app"
# TODO: Remove the above and replace with the below
# - primary-name: "builder/qpdf"
# - primary-name: "builder/cache/qpdf"
# - primary-name: "builder/pikepdf"
# - primary-name: "builder/cache/pikepdf"
# - primary-name: "builder/jbig2enc"
# - primary-name: "builder/cache/jbig2enc"
# - primary-name: "builder/psycopg2"
# - primary-name: "builder/cache/psycopg2"
primary-name: ["paperless-ngx", "paperless-ngx/builder/cache/app"]
env:
# Requires a personal access token with the OAuth scope delete:packages
TOKEN: ${{ secrets.GHA_CONTAINER_DELETE_TOKEN }}
@ -68,7 +61,7 @@ jobs:
-
name: Clean untagged images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@v0.3.0
uses: stumpylog/image-cleaner-action/untagged@v0.4.0
with:
token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}"

33
.github/workflows/crowdin.yml vendored Normal file
View File

@ -0,0 +1,33 @@
name: Crowdin Action
on:
workflow_dispatch:
schedule:
- cron: '2 */12 * * *'
push:
paths: [
'src/locale/**',
'src-ui/src/locale/**'
]
branches: [ dev ]
jobs:
synchronize-with-crowdin:
name: Crowdin Sync
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: crowdin action
uses: crowdin/github-action@v1
with:
upload_translations: false
download_translations: true
crowdin_branch_name: 'dev'
localization_branch_name: l10n_dev
pull_request_labels: 'skip-changelog, translation'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@ -1,10 +1,6 @@
name: Project Automations
on:
issues:
types:
- opened
- reopened
pull_request_target: #_target allows access to secrets
types:
- opened
@ -16,25 +12,7 @@ on:
permissions:
contents: read
env:
todo: Todo
done: Done
in_progress: In Progress
jobs:
issue_opened_or_reopened:
name: issue_opened_or_reopened
runs-on: ubuntu-22.04
if: github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'reopened')
steps:
- name: Add issue to project and set status to ${{ env.todo }}
uses: leonsteinhaeuser/project-beta-automations@v2.2.1
with:
gh_token: ${{ secrets.GH_TOKEN }}
organization: paperless-ngx
project_id: 2
resource_node_id: ${{ github.event.issue.node_id }}
status_value: ${{ env.todo }} # Target status
pr_opened_or_reopened:
name: pr_opened_or_reopened
runs-on: ubuntu-22.04
@ -43,14 +21,6 @@ jobs:
pull-requests: write
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
steps:
- name: Add PR to project and set status to "Needs Review"
uses: leonsteinhaeuser/project-beta-automations@v2.2.1
with:
gh_token: ${{ secrets.GH_TOKEN }}
organization: paperless-ngx
project_id: 2
resource_node_id: ${{ github.event.pull_request.node_id }}
status_value: "Needs Review" # Target status
- name: Label PR with release-drafter
uses: release-drafter/release-drafter@v5
env:

173
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "d7ef8db734997cda7c11971f2ddb66bf1918f4232b0956a9bf604c41763ce461"
"sha256": "b395058a24154f74cb1f2d685d51de3f1028ecb48389fac9971209e258a15543"
},
"pipfile-spec": 6,
"requires": {},
@ -24,11 +24,11 @@
},
"anyio": {
"hashes": [
"sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
"sha256:f7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a"
"sha256:56a415fbc462291813a94528a779597226619c8e78af7de0507333f700011e5f",
"sha256:5a0bec7085176715be77df87fc66d6c9d70626bd752fcc85f57cdbee5b3760da"
],
"markers": "python_version >= '3.8'",
"version": "==4.0.0"
"version": "==4.1.0"
},
"asgiref": {
"hashes": [
@ -164,11 +164,11 @@
},
"certifi": {
"hashes": [
"sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082",
"sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"
"sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1",
"sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"
],
"markers": "python_version >= '3.6'",
"version": "==2023.7.22"
"version": "==2023.11.17"
},
"cffi": {
"hashes": [
@ -540,11 +540,11 @@
},
"exceptiongroup": {
"hashes": [
"sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9",
"sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"
"sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14",
"sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"
],
"markers": "python_version < '3.11'",
"version": "==1.1.3"
"version": "==1.2.0"
},
"filelock": {
"hashes": [
@ -566,12 +566,12 @@
},
"gotenberg-client": {
"hashes": [
"sha256:4508ecb913ef2d553dd2ceb78e32cee001000ba08c910ba1f9ace38350d1589e",
"sha256:7a3f8a02caee768391373b3610c6ec25a853cccf391ed6b5d5a1292c3ed15e7e"
"sha256:3026726d1a47f41e9d43f18c95e530ff64f506e2ec436f116a088da27c7430da",
"sha256:c2555f7401faa48213a7cbe29c5e4a68316a003a6953753bc58d1e2b19873771"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==0.3.0"
"version": "==0.4.0"
},
"gunicorn": {
"hashes": [
@ -753,11 +753,11 @@
"http2"
],
"hashes": [
"sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a",
"sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0"
"sha256:8b8fcaa0c8ea7b05edd69a094e63a2094c4efcb48129fb757361bc423c0ad9e8",
"sha256:a05d3d052d9b2dfce0e3896636467f8a5342fb2b902c819428e1ac65413ca118"
],
"markers": "python_version >= '3.9'",
"version": "==0.25.1"
"version": "==0.25.2"
},
"humanize": {
"hashes": [
@ -777,11 +777,11 @@
},
"idna": {
"hashes": [
"sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4",
"sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"
"sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca",
"sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"
],
"markers": "python_version >= '3.5'",
"version": "==3.4"
"version": "==3.6"
},
"imap-tools": {
"hashes": [
@ -1849,7 +1849,7 @@
"sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0",
"sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"
],
"markers": "python_version < '3.11'",
"markers": "python_version < '3.10'",
"version": "==4.8.0"
},
"tzdata": {
@ -2300,11 +2300,11 @@
"develop": {
"anyio": {
"hashes": [
"sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f",
"sha256:f7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a"
"sha256:56a415fbc462291813a94528a779597226619c8e78af7de0507333f700011e5f",
"sha256:5a0bec7085176715be77df87fc66d6c9d70626bd752fcc85f57cdbee5b3760da"
],
"markers": "python_version >= '3.8'",
"version": "==4.0.0"
"version": "==4.1.0"
},
"asgiref": {
"hashes": [
@ -2371,11 +2371,11 @@
},
"certifi": {
"hashes": [
"sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082",
"sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"
"sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1",
"sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"
],
"markers": "python_version >= '3.6'",
"version": "==2023.7.22"
"version": "==2023.11.17"
},
"cffi": {
"hashes": [
@ -2671,11 +2671,11 @@
},
"exceptiongroup": {
"hashes": [
"sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9",
"sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"
"sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14",
"sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"
],
"markers": "python_version < '3.11'",
"version": "==1.1.3"
"version": "==1.2.0"
},
"execnet": {
"hashes": [
@ -2739,11 +2739,11 @@
"http2"
],
"hashes": [
"sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a",
"sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0"
"sha256:8b8fcaa0c8ea7b05edd69a094e63a2094c4efcb48129fb757361bc423c0ad9e8",
"sha256:a05d3d052d9b2dfce0e3896636467f8a5342fb2b902c819428e1ac65413ca118"
],
"markers": "python_version >= '3.9'",
"version": "==0.25.1"
"markers": "python_version >= '3.8'",
"version": "==0.25.2"
},
"hyperlink": {
"hashes": [
@ -2762,11 +2762,11 @@
},
"idna": {
"hashes": [
"sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4",
"sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"
"sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca",
"sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"
],
"markers": "python_version >= '3.5'",
"version": "==3.4"
"version": "==3.6"
},
"imagehash": {
"hashes": [
@ -2778,11 +2778,11 @@
},
"importlib-metadata": {
"hashes": [
"sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb",
"sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"
"sha256:7fc841f8b8332803464e5dc1c63a2e59121f46ca186c0e2e182e80bf8c1319f7",
"sha256:d97503976bb81f40a193d41ee6570868479c69d5068651eb039c40d850c59d67"
],
"markers": "python_version < '3.10'",
"version": "==6.8.0"
"version": "==7.0.0"
},
"incremental": {
"hashes": [
@ -2907,20 +2907,20 @@
},
"mkdocs-material": {
"hashes": [
"sha256:8b20f6851bddeef37dced903893cd176cf13a21a482e97705a103c45f06ce9b9",
"sha256:f0c101453e8bc12b040e8b64ca39a405d950d8402609b1378cc2b98976e74b5f"
"sha256:a511d3ff48fa8718b033e7e37d17abd9cc1de0fdf0244a625ca2ae2387e2416d",
"sha256:dbc78a4fea97b74319a6aa9a2f0be575a6028be6958f813ba367188f7b8428f6"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==9.4.8"
"version": "==9.4.14"
},
"mkdocs-material-extensions": {
"hashes": [
"sha256:0297cc48ba68a9fdd1ef3780a3b41b534b0d0df1d1181a44676fda5f464eeadc",
"sha256:f0446091503acb110a7cab9349cbc90eeac51b58d1caa92a704a81ca1e24ddbd"
"sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443",
"sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"
],
"markers": "python_version >= '3.8'",
"version": "==1.3"
"version": "==1.3.1"
},
"mypy-extensions": {
"hashes": [
@ -3064,11 +3064,11 @@
},
"platformdirs": {
"hashes": [
"sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3",
"sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"
"sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380",
"sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"
],
"markers": "python_version >= '3.7'",
"version": "==3.11.0"
"markers": "python_version >= '3.8'",
"version": "==4.1.0"
},
"pluggy": {
"hashes": [
@ -3112,19 +3112,19 @@
},
"pygments": {
"hashes": [
"sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692",
"sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"
"sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c",
"sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"
],
"markers": "python_version >= '3.7'",
"version": "==2.16.1"
"version": "==2.17.2"
},
"pymdown-extensions": {
"hashes": [
"sha256:bc46f11749ecd4d6b71cf62396104b4a200bad3498cb0f5dad1b8502fe461a35",
"sha256:cfc28d6a09d19448bcbf8eee3ce098c7d17ff99f7bd3069db4819af181212037"
"sha256:1b60f1e462adbec5a1ed79dac91f666c9c0d241fa294de1989f29d20096cfd0b",
"sha256:1f0ca8bb5beff091315f793ee17683bc1390731f6ac4c5eb01e27464b80fe879"
],
"markers": "python_version >= '3.8'",
"version": "==10.4"
"version": "==10.5"
},
"pyopenssl": {
"hashes": [
@ -3162,30 +3162,30 @@
},
"pytest-env": {
"hashes": [
"sha256:1efb8acce1f6431196150f3b30673443ff05a6fabff64539a9495cd2248adf9e",
"sha256:2b71b37c6810f28bec790a7b373c777af87352b3a359b3de0edb9d24df5cf8b3"
"sha256:aada77e6d09fcfb04540a6e462c58533c37df35fa853da78707b17ec04d17dfc",
"sha256:fcd7dc23bb71efd3d35632bde1bbe5ee8c8dc4489d6617fb010674880d96216b"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==1.1.1"
"version": "==1.1.3"
},
"pytest-httpx": {
"hashes": [
"sha256:b489c5a7bb847551943eaee601bc35053b35dc4f5961c944305120f14a1d770a",
"sha256:ca372b94c569c0aca2f06240f6f78cc223dfbc3ab97b5700d4e14c9a73eab17a"
"sha256:24f6f53d507ab483bea8f89b975a1a111fb613ccab4d86e570be8991776e8bcc",
"sha256:a33c4e8df415cc1232b3664869b6a8b8061c4c223335aca0b237cefbc01ba0eb"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==0.26.0"
"version": "==0.27.0"
},
"pytest-rerunfailures": {
"hashes": [
"sha256:784f462fa87fe9bdf781d0027d856b47a4bfe6c12af108f6bd887057a917b48e",
"sha256:9a1afd04e21b8177faf08a9bbbf44de7a0fe3fc29f8ddbe83b9684bd5f8f92a9"
"sha256:34919cb3fcb1f8e5d4b940aa75ccdea9661bade925091873b7c6fa5548333069",
"sha256:e132dbe420bc476f544b96e7036edd0a69707574209b6677263c950d19b09199"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==12.0"
"version": "==13.0"
},
"pytest-sugar": {
"hashes": [
@ -3197,19 +3197,18 @@
},
"pytest-xdist": {
"hashes": [
"sha256:3a94a931dd9e268e0b871a877d09fe2efb6175c2c23d60d56a6001359002b832",
"sha256:e513118bf787677a427e025606f55e95937565e06dfaac8d87f55301e57ae607"
"sha256:cbb36f3d67e0c478baa57fa4edc8843887e0f6cfc42d677530a36d7472b32d8a",
"sha256:d075629c7e00b611df89f490a5063944bee7a4362a5ff11c7cc7824a03dfce24"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==3.4.0"
"version": "==3.5.0"
},
"python-dateutil": {
"hashes": [
"sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86",
"sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"
],
"index": "pypi",
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.8.2"
},
@ -3297,6 +3296,7 @@
"sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d",
"sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"
],
"markers": "python_version >= '3.6'",
"version": "==6.0.1"
},
"pyyaml-env-tag": {
@ -3411,27 +3411,27 @@
},
"ruff": {
"hashes": [
"sha256:171276c1df6c07fa0597fb946139ced1c2978f4f0b8254f201281729981f3c17",
"sha256:17ef33cd0bb7316ca65649fc748acc1406dfa4da96a3d0cde6d52f2e866c7b39",
"sha256:32d47fc69261c21a4c48916f16ca272bf2f273eb635d91c65d5cd548bf1f3d96",
"sha256:5cbec0ef2ae1748fb194f420fb03fb2c25c3258c86129af7172ff8f198f125ab",
"sha256:721f4b9d3b4161df8dc9f09aa8562e39d14e55a4dbaa451a8e55bdc9590e20f4",
"sha256:82bfcb9927e88c1ed50f49ac6c9728dab3ea451212693fe40d08d314663e412f",
"sha256:9b97fd6da44d6cceb188147b68db69a5741fbc736465b5cea3928fdac0bc1aeb",
"sha256:a00a7ec893f665ed60008c70fe9eeb58d210e6b4d83ec6654a9904871f982a2a",
"sha256:a4894dddb476597a0ba4473d72a23151b8b3b0b5f958f2cf4d3f1c572cdb7af7",
"sha256:a8c11206b47f283cbda399a654fd0178d7a389e631f19f51da15cbe631480c5b",
"sha256:aafb9d2b671ed934998e881e2c0f5845a4295e84e719359c71c39a5363cccc91",
"sha256:b2c205827b3f8c13b4a432e9585750b93fd907986fe1aec62b2a02cf4401eee6",
"sha256:bb408e3a2ad8f6881d0f2e7ad70cddb3ed9f200eb3517a91a245bbe27101d379",
"sha256:c21fe20ee7d76206d290a76271c1af7a5096bc4c73ab9383ed2ad35f852a0087",
"sha256:f20dc5e5905ddb407060ca27267c7174f532375c08076d1a953cf7bb016f5a24",
"sha256:f80c73bba6bc69e4fdc73b3991db0b546ce641bdcd5b07210b8ad6f64c79f1ab",
"sha256:fa29e67b3284b9a79b1a85ee66e293a94ac6b7bb068b307a8a373c3d343aa8ec"
"sha256:0683b7bfbb95e6df3c7c04fe9d78f631f8e8ba4868dfc932d43d690698057e2e",
"sha256:1ea109bdb23c2a4413f397ebd8ac32cb498bee234d4191ae1a310af760e5d287",
"sha256:276a89bcb149b3d8c1b11d91aa81898fe698900ed553a08129b38d9d6570e717",
"sha256:290ecab680dce94affebefe0bbca2322a6277e83d4f29234627e0f8f6b4fa9ce",
"sha256:416dfd0bd45d1a2baa3b1b07b1b9758e7d993c256d3e51dc6e03a5e7901c7d80",
"sha256:45b38c3f8788a65e6a2cab02e0f7adfa88872696839d9882c13b7e2f35d64c5f",
"sha256:4af95fd1d3b001fc41325064336db36e3d27d2004cdb6d21fd617d45a172dd96",
"sha256:69a4bed13bc1d5dabf3902522b5a2aadfebe28226c6269694283c3b0cecb45fd",
"sha256:6b05e3b123f93bb4146a761b7a7d57af8cb7384ccb2502d29d736eaade0db519",
"sha256:6c64cb67b2025b1ac6d58e5ffca8f7b3f7fd921f35e78198411237e4f0db8e73",
"sha256:7f80496854fdc65b6659c271d2c26e90d4d401e6a4a31908e7e334fab4645aac",
"sha256:8b0c2de9dd9daf5e07624c24add25c3a490dbf74b0e9bca4145c632457b3b42a",
"sha256:90c958fe950735041f1c80d21b42184f1072cc3975d05e736e8d66fc377119ea",
"sha256:9dcc6bb2f4df59cb5b4b40ff14be7d57012179d69c6565c1da0d1f013d29951b",
"sha256:de02ca331f2143195a712983a57137c5ec0f10acc4aa81f7c1f86519e52b92a1",
"sha256:df2bb4bb6bbe921f6b4f5b6fdd8d8468c940731cb9406f274ae8c5ed7a78c478",
"sha256:dffd699d07abf54833e5f6cc50b85a6ff043715da8788c4a79bcd4ab4734d306"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==0.1.5"
"version": "==0.1.7"
},
"scipy": {
"hashes": [
@ -3584,7 +3584,6 @@
"sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44",
"sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==3.0.0"
},

View File

@ -1,8 +1,6 @@
commit_message: '[ci skip]'
pull_request_labels: [
"skip-changelog",
"translation"
]
project_id_env: CROWDIN_PROJECT_ID
api_token_env: CROWDIN_PERSONAL_TOKEN
preserve_hierarchy: true
files:
- source: /src/locale/en_US/LC_MESSAGES/django.po
translation: /src/locale/%locale_with_underscore%/LC_MESSAGES/django.po

View File

@ -6,7 +6,7 @@
version: "3.7"
services:
gotenberg:
image: docker.io/gotenberg/gotenberg:7.8
image: docker.io/gotenberg/gotenberg:7.10
hostname: gotenberg
container_name: gotenberg
network_mode: host
@ -17,6 +17,8 @@ services:
- "gotenberg"
- "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*"
- "--log-level=warn"
- "--log-format=text"
tika:
image: ghcr.io/paperless-ngx/tika:latest
hostname: tika

View File

@ -83,7 +83,7 @@ services:
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:7.8
image: docker.io/gotenberg/gotenberg:7.10
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.

View File

@ -77,7 +77,7 @@ services:
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:7.8
image: docker.io/gotenberg/gotenberg:7.10
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not

View File

@ -65,7 +65,7 @@ services:
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:7.8
image: docker.io/gotenberg/gotenberg:7.10
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not

View File

@ -34,7 +34,7 @@ Options available to docker installations:
Paperless uses 4 volumes:
- `paperless_media`: This is where your documents are stored.
- `paperless_data`: This is where auxillary data is stored. This
- `paperless_data`: This is where auxiliary data is stored. This
folder also contains the SQLite database, if you use it.
- `paperless_pgdata`: Exists only if you use PostgreSQL and
contains the database.
@ -408,7 +408,7 @@ that don't match a document anymore get removed as well.
### Managing the Automatic matching algorithm
The _Auto_ matching algorithm requires a trained neural network to work.
This network needs to be updated whenever somethings in your data
This network needs to be updated whenever something in your data
changes. The docker image takes care of that automatically with the task
scheduler. You can manually renew the classifier by invoking the
following management command:
@ -597,7 +597,7 @@ This tool does a fuzzy match over document content, looking for
those which look close according to a given ratio.
At this time, other metadata (such as correspondent or type) is not
take into account by the detection.
taken into account by the detection.
```
document_fuzzy_match [--ratio] [--processes N]

View File

@ -510,7 +510,7 @@ existing tables) with:
## Barcodes {#barcodes}
Paperless is able to utilize barcodes for automatically preforming some tasks.
Paperless is able to utilize barcodes for automatically performing some tasks.
At this time, the library utilized for detection of barcodes supports the following types:
@ -566,7 +566,7 @@ collating two separate scans into one document, reordering the pages as necessar
Suppose you have a double-sided document with 6 pages (3 sheets of paper). First,
put the stack into your ADF as normal, ensuring that page 1 is scanned first. Your ADF
will now scan pages 1, 3, and 5. Then you (or your the scanner, if it supports it) upload
will now scan pages 1, 3, and 5. Then you (or your scanner, if it supports it) upload
the scan into the correct sub-directory of the consume folder (`double-sided` by default;
keep in mind that Paperless will _not_ automatically create the directory for you.)
Paperless will then process the scan and move it into an internal staging area.

View File

@ -21,6 +21,7 @@ The API provides the following main endpoints:
- `/api/groups/`: Full CRUD support.
- `/api/share_links/`: Full CRUD support.
- `/api/custom_fields/`: Full CRUD support.
- `/api/profile/`: GET, PATCH
All of these endpoints except for the logging endpoint allow you to
fetch (and edit and delete where appropriate) individual objects by
@ -157,6 +158,10 @@ The REST api provides three different forms of authentication.
3. Token authentication
You can create (or re-create) an API token by opening the "My Profile"
link in the user dropdown found in the web UI and clicking the circular
arrow button.
Paperless also offers an endpoint to acquire authentication tokens.
POST a username and password as a form or json string to
@ -168,7 +173,7 @@ The REST api provides three different forms of authentication.
Authorization: Token <token>
```
Tokens can be managed and revoked in the paperless admin.
Tokens can also be managed in the Django admin.
## Searching for documents

View File

@ -2,6 +2,10 @@
## paperless-ngx 2.0.1
### Please Note
Exports generated in Paperless-ngx v2.0.02.0.1 will **not** contain consumption templates or custom fields, we recommend users upgrade to at least v2.1.
### Bug Fixes
- Fix: Increase field the length for consumption template source [@stumpylog](https://github.com/stumpylog) ([#4719](https://github.com/paperless-ngx/paperless-ngx/pull/4719))
@ -22,6 +26,10 @@
## paperless-ngx 2.0.0
### Please Note
Exports generated in Paperless-ngx v2.0.02.0.1 will **not** contain consumption templates or custom fields, we recommend users upgrade to at least v2.1.
### Breaking Changes
- Breaking: Rename the environment variable for self-signed email certificates [@stumpylog](https://github.com/stumpylog) ([#4346](https://github.com/paperless-ngx/paperless-ngx/pull/4346))

View File

@ -9,7 +9,7 @@ following way:
- `main` always represents the latest release and will only see
changes when a new release is made.
- `dev` contains the code that will be in the next release.
- `feature-X` contain bigger changes that will be in some release, but
- `feature-X` contains bigger changes that will be in some release, but
not necessarily the next one.
When making functional changes to Paperless-ngx, _always_ make your changes

View File

@ -87,7 +87,7 @@ follow the [Docker Compose instructions](https://docs.paperless-ngx.com/setup/#i
space compared to a bare metal installation, docker comes with close to
zero overhead, even on Raspberry Pi.
If you decide to got with the bare metal route, be aware that some of
If you decide to go with the bare metal route, be aware that some of
the python requirements do not have precompiled packages for ARM /
ARM64. Installation of these will require additional development
libraries and compilation will take a long time.

View File

@ -283,6 +283,7 @@ Consumption templates can assign:
- Tags, correspondent, document types
- Document owner
- View and / or edit permissions to users or groups
- Custom fields. Note that no value for the field will be set
### Consumption template permissions
@ -342,6 +343,7 @@ The following custom field types are supported:
- `Integer`: integer number e.g. 12
- `Number`: float number e.g. 12.3456
- `Monetary`: float number with exactly two decimals, e.g. 12.30
- `Document Link`: reference(s) to other document(s), displayed as links
## Share Links

View File

@ -1,7 +1,8 @@
{
"root": true,
"ignorePatterns": [
"projects/**/*"
"projects/**/*",
"/src/app/components/common/pdf-viewer/**"
],
"overrides": [
{

View File

@ -65,7 +65,7 @@
"src/assets",
"src/manifest.webmanifest",
{
"glob": "pdf.worker.min.js",
"glob": "{pdf.worker.min.js,pdf.min.js}",
"input": "node_modules/pdfjs-dist/build/",
"output": "/assets/js/"
}
@ -75,7 +75,8 @@
],
"scripts": [],
"allowedCommonJsDependencies": [
"ng2-pdf-viewer"
"pdfjs-dist",
"pdfjs-dist/web/pdf_viewer"
],
"vendorChunk": true,
"extractLicenses": false,
@ -109,7 +110,7 @@
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
"maximumError": "30kb"
}
]
},

View File

@ -79,7 +79,7 @@ test('should show a mobile preview', async ({ page }) => {
await page.setViewportSize({ width: 400, height: 1000 })
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
await page.getByRole('tab', { name: 'Preview' }).click()
await page.waitForSelector('pdf-viewer')
await page.waitForSelector('pngx-pdf-viewer')
})
test('should show a list of notes', async ({ page }) => {

View File

@ -7,6 +7,7 @@ module.exports = {
'abstract-name-filter-service',
'abstract-paperless-service',
],
coveragePathIgnorePatterns: ['/src/app/components/common/pdf-viewer/*'],
transformIgnorePatterns: [`<rootDir>/node_modules/(?!.*\\.mjs$|lodash-es)`],
moduleNameMapper: {
'^src/(.*)': '<rootDir>/src/$1',

File diff suppressed because it is too large Load Diff

588
src-ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -27,11 +27,11 @@
"bootstrap": "^5.3.2",
"file-saver": "^2.0.5",
"mime-names": "^1.0.0",
"ng2-pdf-viewer": "^10.0.0",
"ngx-color": "^9.0.0",
"ngx-cookie-service": "^16.0.1",
"ngx-file-drop": "^16.0.0",
"ngx-ui-tour-ng-bootstrap": "^13.0.6",
"pdfjs-dist": "^3.11.174",
"rxjs": "^7.8.1",
"tslib": "^2.6.2",
"uuid": "^9.0.1",
@ -47,20 +47,20 @@
"@angular-eslint/template-parser": "16.2.0",
"@angular/cli": "~16.2.9",
"@angular/compiler-cli": "~16.2.3",
"@playwright/test": "^1.39.0",
"@types/jest": "^29.5.7",
"@types/node": "^20.8.10",
"@typescript-eslint/eslint-plugin": "^6.9.1",
"@typescript-eslint/parser": "^6.9.1",
"@playwright/test": "^1.40.1",
"@types/jest": "^29.5.10",
"@types/node": "^20.10.2",
"@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/parser": "^6.13.1",
"concurrently": "^8.2.2",
"eslint": "^8.52.0",
"eslint": "^8.55.0",
"jest": "29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-preset-angular": "^13.1.1",
"jest-preset-angular": "^13.1.4",
"jest-websocket-mock": "^2.5.0",
"patch-package": "^8.0.0",
"ts-node": "~10.9.1",
"typescript": "^5.1.6",
"wait-on": "^7.0.1"
"wait-on": "^7.2.0"
}
}

View File

@ -51,7 +51,6 @@ import { SavedViewWidgetComponent } from './components/dashboard/widgets/saved-v
import { StatisticsWidgetComponent } from './components/dashboard/widgets/statistics-widget/statistics-widget.component'
import { UploadFileWidgetComponent } from './components/dashboard/widgets/upload-file-widget/upload-file-widget.component'
import { WidgetFrameComponent } from './components/dashboard/widgets/widget-frame/widget-frame.component'
import { PdfViewerModule } from 'ng2-pdf-viewer'
import { WelcomeWidgetComponent } from './components/dashboard/widgets/welcome-widget/welcome-widget.component'
import { YesNoPipe } from './pipes/yes-no.pipe'
import { FileSizePipe } from './pipes/file-size.pipe'
@ -105,6 +104,9 @@ import { FileDropComponent } from './components/file-drop/file-drop.component'
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
import { PdfViewerComponent } from './components/common/pdf-viewer/pdf-viewer.component'
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
import localeAf from '@angular/common/locales/af'
import localeAr from '@angular/common/locales/ar'
@ -256,6 +258,9 @@ function initializeApp(settings: SettingsService) {
CustomFieldsComponent,
CustomFieldEditDialogComponent,
CustomFieldsDropdownComponent,
ProfileEditDialogComponent,
PdfViewerComponent,
DocumentLinkComponent,
],
imports: [
BrowserModule,
@ -265,7 +270,6 @@ function initializeApp(settings: SettingsService) {
FormsModule,
ReactiveFormsModule,
NgxFileDropModule,
PdfViewerModule,
NgSelectModule,
ColorSliderModule,
TourNgBootstrapModule,

View File

@ -1,14 +1,19 @@
<pngx-page-header title="Logs" i18n-title>
<div class="form-check form-switch" (click)="toggleAutoRefresh()">
<input class="form-check-input" type="checkbox" role="switch" id="autoRefreshSwitch" [attr.checked]="autoRefreshInterval">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div>
</pngx-page-header>
<ul ngbNav #nav="ngbNav" [(activeId)]="activeLog" (activeIdChange)="reloadLogs()" class="nav-tabs">
<li *ngFor="let logFile of logFiles" [ngbNavItem]="logFile">
<a ngbNavLink>{{logFile}}.log</a>
<a ngbNavLink>
{{logFile}}.log
</a>
</li>
<div *ngIf="isLoading && !logFiles.length" class="pb-2">
<div *ngIf="isLoading || !logFiles.length" class="ps-2 d-flex align-items-center">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
<ng-container *ngIf="!logFiles.length" i18n>Loading...</ng-container>
</div>
</ul>

View File

@ -1,4 +1,9 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { LogService } from 'src/app/services/rest/log.service'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { LogsComponent } from './logs.component'
@ -26,6 +31,7 @@ describe('LogsComponent', () => {
let fixture: ComponentFixture<LogsComponent>
let logService: LogService
let logSpy
let reloadSpy
beforeEach(async () => {
TestBed.configureTestingModule({
@ -42,7 +48,9 @@ describe('LogsComponent', () => {
})
fixture = TestBed.createComponent(LogsComponent)
component = fixture.componentInstance
reloadSpy = jest.spyOn(component, 'reloadLogs')
window.HTMLElement.prototype.scroll = function () {} // mock scroll
jest.useFakeTimers()
fixture.detectChanges()
})
@ -68,4 +76,14 @@ describe('LogsComponent', () => {
component.reloadLogs()
expect(component.logs).toHaveLength(0)
})
it('should auto refresh, allow toggle', () => {
jest.advanceTimersByTime(6000)
expect(reloadSpy).toHaveBeenCalledTimes(2)
component.toggleAutoRefresh()
expect(component.autoRefreshInterval).toBeNull()
jest.advanceTimersByTime(6000)
expect(reloadSpy).toHaveBeenCalledTimes(2)
})
})

View File

@ -27,6 +27,8 @@ export class LogsComponent implements OnInit, AfterViewChecked, OnDestroy {
public isLoading: boolean = false
public autoRefreshInterval: any
@ViewChild('logContainer') logContainer: ElementRef
ngOnInit(): void {
@ -41,6 +43,7 @@ export class LogsComponent implements OnInit, AfterViewChecked, OnDestroy {
this.activeLog = this.logFiles[0]
this.reloadLogs()
}
this.toggleAutoRefresh()
})
}
@ -91,4 +94,15 @@ export class LogsComponent implements OnInit, AfterViewChecked, OnDestroy {
behavior: 'auto',
})
}
toggleAutoRefresh(): void {
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval)
this.autoRefreshInterval = null
} else {
this.autoRefreshInterval = setInterval(() => {
this.reloadLogs()
}, 5000)
}
}
}

View File

@ -416,7 +416,7 @@ export class SettingsComponent
)
this.settings.set(
SETTINGS_KEYS.THEME_COLOR,
this.settingsForm.value.themeColor.toString()
this.settingsForm.value.themeColor
)
this.settings.set(
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER,

View File

@ -1,5 +1,5 @@
<pngx-page-header title="File Tasks" i18n-title>
<div class="btn-toolbar col col-md-auto">
<div class="btn-toolbar col col-md-auto align-items-center">
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#x"/>
@ -10,15 +10,10 @@
<use xlink:href="assets/bootstrap-icons.svg#check2-all"/>
</svg>&nbsp;<ng-container i18n>{{dismissButtonText}}</ng-container>
</button>
<button class="btn btn-sm btn-outline-primary" (click)="tasksService.reload()">
<svg *ngIf="!tasksService.loading" class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-clockwise"/>
</svg>
<ng-container *ngIf="tasksService.loading">
<div class="spinner-border spinner-border-sm fw-normal" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</ng-container>&nbsp;<ng-container i18n>Refresh</ng-container>
</button>
<div class="form-check form-switch mb-0" (click)="toggleAutoRefresh()">
<input class="form-check-input" type="checkbox" role="switch" id="autoRefreshSwitch" [attr.checked]="autoRefreshInterval">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div>
</div>
</pngx-page-header>

View File

@ -112,6 +112,7 @@ describe('TasksComponent', () => {
let modalService: NgbModal
let router: Router
let httpTestingController: HttpTestingController
let reloadSpy
beforeEach(async () => {
TestBed.configureTestingModule({
@ -141,11 +142,13 @@ describe('TasksComponent', () => {
}).compileComponents()
tasksService = TestBed.inject(TasksService)
reloadSpy = jest.spyOn(tasksService, 'reload')
httpTestingController = TestBed.inject(HttpTestingController)
modalService = TestBed.inject(NgbModal)
router = TestBed.inject(Router)
fixture = TestBed.createComponent(TasksComponent)
component = fixture.componentInstance
jest.useFakeTimers()
fixture.detectChanges()
httpTestingController
.expectOne(`${environment.apiBaseUrl}tasks/`)
@ -164,7 +167,7 @@ describe('TasksComponent', () => {
`Failed${currentTasksLength}`
)
expect(
fixture.debugElement.queryAll(By.css('input[type="checkbox"]'))
fixture.debugElement.queryAll(By.css('table input[type="checkbox"]'))
).toHaveLength(currentTasksLength + 1)
currentTasksLength = tasks.filter(
@ -245,7 +248,7 @@ describe('TasksComponent', () => {
it('should support toggle all tasks', () => {
const toggleCheck = fixture.debugElement.query(
By.css('input[type=checkbox]')
By.css('table input[type=checkbox]')
)
toggleCheck.nativeElement.dispatchEvent(new MouseEvent('click'))
fixture.detectChanges()
@ -269,4 +272,15 @@ describe('TasksComponent', () => {
tasks[3].related_document,
])
})
it('should auto refresh, allow toggle', () => {
expect(reloadSpy).toHaveBeenCalledTimes(1)
jest.advanceTimersByTime(5000)
expect(reloadSpy).toHaveBeenCalledTimes(2)
component.toggleAutoRefresh()
expect(component.autoRefreshInterval).toBeNull()
jest.advanceTimersByTime(6000)
expect(reloadSpy).toHaveBeenCalledTimes(2)
})
})

View File

@ -23,6 +23,8 @@ export class TasksComponent
public pageSize: number = 25
public page: number = 1
public autoRefreshInterval: any
get dismissButtonText(): string {
return this.selectedTasks.size > 0
? $localize`Dismiss selected`
@ -39,6 +41,7 @@ export class TasksComponent
ngOnInit() {
this.tasksService.reload()
this.toggleAutoRefresh()
}
ngOnDestroy() {
@ -135,4 +138,15 @@ export class TasksComponent
return $localize`failed`
}
}
toggleAutoRefresh(): void {
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval)
this.autoRefreshInterval = null
} else {
this.autoRefreshInterval = setInterval(() => {
this.tasksService.reload()
}, 5000)
}
}
}

View File

@ -89,7 +89,7 @@ export class UsersAndGroupsComponent
$localize`Password has been changed, you will be logged out momentarily.`
)
setTimeout(() => {
window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/`
window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
}, 2500)
} else {
this.toastService.showInfo(

View File

@ -39,6 +39,11 @@
<p class="small mb-0 px-3 text-muted" i18n>Logged in as {{this.settingsService.displayName}}</p>
<div class="dropdown-divider"></div>
</div>
<button ngbDropdownItem class="nav-link" (click)="editProfile()">
<svg class="sidebaricon me-2" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#person"/>
</svg><ng-container i18n>My Profile</ng-container>
</button>
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }">
<svg class="sidebaricon me-2" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#gear"/>

View File

@ -9,7 +9,7 @@ import {
fakeAsync,
tick,
} from '@angular/core/testing'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { BrowserModule } from '@angular/platform-browser'
import { RouterTestingModule } from '@angular/router/testing'
import { SettingsService } from 'src/app/services/settings.service'
@ -32,6 +32,7 @@ import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
const saved_views = [
{
@ -86,6 +87,7 @@ describe('AppFrameComponent', () => {
let documentListViewService: DocumentListViewService
let router: Router
let savedViewSpy
let modalService: NgbModal
beforeEach(async () => {
TestBed.configureTestingModule({
@ -98,6 +100,7 @@ describe('AppFrameComponent', () => {
FormsModule,
ReactiveFormsModule,
DragDropModule,
NgbModalModule,
],
providers: [
SettingsService,
@ -120,6 +123,7 @@ describe('AppFrameComponent', () => {
ToastService,
OpenDocumentsService,
SearchService,
NgbModal,
{
provide: ActivatedRoute,
useValue: {
@ -148,6 +152,7 @@ describe('AppFrameComponent', () => {
openDocumentsService = TestBed.inject(OpenDocumentsService)
searchService = TestBed.inject(SearchService)
documentListViewService = TestBed.inject(DocumentListViewService)
modalService = TestBed.inject(NgbModal)
router = TestBed.inject(Router)
jest
@ -363,4 +368,12 @@ describe('AppFrameComponent', () => {
>)
expect(toastSpy).toHaveBeenCalled()
})
it('should support edit profile', () => {
const modalSpy = jest.spyOn(modalService, 'open')
component.editProfile()
expect(modalSpy).toHaveBeenCalledWith(ProfileEditDialogComponent, {
backdrop: 'static',
})
})
})

View File

@ -39,6 +39,8 @@ import {
CdkDragDrop,
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
@Component({
selector: 'pngx-app-frame',
@ -69,6 +71,7 @@ export class AppFrameComponent
public settingsService: SettingsService,
public tasksService: TasksService,
private readonly toastService: ToastService,
private modalService: NgbModal,
permissionsService: PermissionsService
) {
super()
@ -121,6 +124,13 @@ export class AppFrameComponent
this.isMenuCollapsed = true
}
editProfile() {
this.modalService.open(ProfileEditDialogComponent, {
backdrop: 'static',
})
this.closeMenu()
}
get openDocuments(): PaperlessDocument[] {
return this.openDocumentsService.getOpenDocuments()
}

View File

@ -35,6 +35,7 @@
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
<pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
<pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
<pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select>
</div>
<div class="col">
<pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select>

View File

@ -20,6 +20,7 @@ import { TagsComponent } from '../../input/tags/tags.component'
import { TextComponent } from '../../input/text/text.component'
import { EditDialogMode } from '../edit-dialog.component'
import { ConsumptionTemplateEditDialogComponent } from './consumption-template-edit-dialog.component'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
describe('ConsumptionTemplateEditDialogComponent', () => {
let component: ConsumptionTemplateEditDialogComponent
@ -93,6 +94,15 @@ describe('ConsumptionTemplateEditDialogComponent', () => {
}),
},
},
{
provide: CustomFieldsService,
useValue: {
listAll: () =>
of({
results: [],
}),
},
},
],
imports: [
HttpClientTestingModule,

View File

@ -18,6 +18,8 @@ import { SettingsService } from 'src/app/services/settings.service'
import { EditDialogComponent } from '../edit-dialog.component'
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
import { PaperlessMailRule } from 'src/app/data/paperless-mail-rule'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { PaperlessCustomField } from 'src/app/data/paperless-custom-field'
export const DOCUMENT_SOURCE_OPTIONS = [
{
@ -45,6 +47,7 @@ export class ConsumptionTemplateEditDialogComponent extends EditDialogComponent<
documentTypes: PaperlessDocumentType[]
storagePaths: PaperlessStoragePath[]
mailRules: PaperlessMailRule[]
customFields: PaperlessCustomField[]
constructor(
service: ConsumptionTemplateService,
@ -54,7 +57,8 @@ export class ConsumptionTemplateEditDialogComponent extends EditDialogComponent<
storagePathService: StoragePathService,
mailRuleService: MailRuleService,
userService: UserService,
settingsService: SettingsService
settingsService: SettingsService,
customFieldsService: CustomFieldsService
) {
super(service, activeModal, userService, settingsService)
@ -77,6 +81,11 @@ export class ConsumptionTemplateEditDialogComponent extends EditDialogComponent<
.listAll()
.pipe(first())
.subscribe((result) => (this.mailRules = result.results))
customFieldsService
.listAll()
.pipe(first())
.subscribe((result) => (this.customFields = result.results))
}
getCreateTitle() {
@ -106,6 +115,7 @@ export class ConsumptionTemplateEditDialogComponent extends EditDialogComponent<
assign_view_groups: new FormControl([]),
assign_change_users: new FormControl([]),
assign_change_groups: new FormControl([]),
assign_custom_fields: new FormControl([]),
})
}

View File

@ -1,16 +1,16 @@
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text>
<pngx-input-select i18n-title title="Data type" [items]="getDataTypes()" formControlName="data_type"></pngx-input-select>
<small class="d-block mt-n2" *ngIf="typeFieldDisabled" i18n>Data type cannot be changed after a field is created</small>
</div>
<div class="modal-footer">
<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>
</div>
</form>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text>
<pngx-input-select i18n-title title="Data type" [items]="getDataTypes()" formControlName="data_type"></pngx-input-select>
<small class="d-block mt-n2" *ngIf="typeFieldDisabled" i18n>Data type cannot be changed after a field is created</small>
</div>
<div class="modal-footer">
<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>
</div>
</form>

View File

@ -131,6 +131,7 @@ describe('EditDialogComponent', () => {
})
it('should interpolate object permissions', () => {
component.getMatchingAlgorithms() // coverage
component.object = tag
component.dialogMode = EditDialogMode.EDIT
component.ngOnInit()

View File

@ -58,8 +58,8 @@ export abstract class EditDialogComponent<
objectForm: FormGroup = this.getForm()
ngOnInit(): void {
if (this.object != null) {
if (this.object['permissions']) {
if (this.object != null && this.dialogMode !== EditDialogMode.CREATE) {
if ((this.object as ObjectWithPermissions).permissions) {
this.object['set_permissions'] = this.object['permissions']
}
@ -69,6 +69,8 @@ export abstract class EditDialogComponent<
}
this.objectForm.patchValue(this.object)
} else {
// e.g. if name was set
this.objectForm.patchValue(this.object)
// defaults from settings
this.objectForm.patchValue({
permissions_form: {

View File

@ -21,7 +21,8 @@
<pngx-input-text i18n-title title="Filter to" formControlName="filter_to" [error]="error?.filter_to"></pngx-input-text>
<pngx-input-text i18n-title title="Filter subject" formControlName="filter_subject" [error]="error?.filter_subject"></pngx-input-text>
<pngx-input-text i18n-title title="Filter body" formControlName="filter_body" [error]="error?.filter_body"></pngx-input-text>
<pngx-input-text i18n-title title="Filter attachment filename" formControlName="filter_attachment_filename" i18n-hint hint="Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_attachment_filename"></pngx-input-text>
<pngx-input-text i18n-title title="Filter attachment filename includes" formControlName="filter_attachment_filename_include" i18n-hint hint="Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_attachment_filename_include"></pngx-input-text>
<pngx-input-text i18n-title title="Filter attachment filename excluding" formControlName="filter_attachment_filename_exclude" i18n-hint hint="Do not consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_attachment_filename_exclude"></pngx-input-text>
</div>
<div class="col-md-4">
<pngx-input-select i18n-title title="Action" [items]="actionOptions" formControlName="action" i18n-hint hint="Action is only performed when documents are consumed from the mail. Mails without attachments remain entirely untouched."></pngx-input-select>

View File

@ -158,7 +158,8 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<PaperlessMa
filter_to: new FormControl(null),
filter_subject: new FormControl(null),
filter_body: new FormControl(null),
filter_attachment_filename: new FormControl(null),
filter_attachment_filename_include: new FormControl(null),
filter_attachment_filename_exclude: new FormControl(null),
maximum_age: new FormControl(null),
attachment_type: new FormControl(MailFilterAttachmentType.Attachments),
consumption_scope: new FormControl(MailRuleConsumptionScope.Attachments),

View File

@ -0,0 +1,50 @@
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
<div class="row">
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
<label *ngIf="title" class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
<button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#x"/>
</svg>&nbsp;<ng-container i18n>Remove</ng-container>
</button>
</div>
<div [class.col-md-9]="horizontal">
<div>
<ng-select name="inputId" [(ngModel)]="selectedDocuments"
[disabled]="disabled"
[items]="foundDocuments$ | async"
placeholder="Search for documents"
[notFoundText]="notFoundText"
[multiple]="true"
bindValue="id"
[compareWith]="compareDocuments"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="loading"
[typeahead]="documentsInput$"
(change)="onChange(selectedDocuments)">
<ng-template ng-label-tmp let-document="item">
<div class="d-flex align-items-center">
<svg class="sidebaricon" fill="currentColor" xmlns="http://www.w3.org/2000/svg" (click)="unselect(document)">
<use xlink:href="assets/bootstrap-icons.svg#x"/>
</svg>
<a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();">
<svg class="sidebaricon-sm me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#file-text"/>
</svg><span>{{document.title}}</span>
</a>
</div>
</ng-template>
<ng-template ng-loadingspinner-tmp>
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</ng-template>
<ng-template ng-option-tmp let-document="item" let-index="index" let-search="searchTerm">
<div>{{document.title}} <small class="text-muted">({{document.created | customDate:'shortDate'}})</small></div>
</ng-template>
</ng-select>
</div>
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
</div>
</div>
</div>

View File

@ -0,0 +1,14 @@
::ng-deep .ng-select-container .ng-value-container .ng-value {
background-color: transparent !important;
border-color: transparent;
}
.sidebaricon {
cursor: pointer;
}
.badge {
font-size: .75rem;
// --bs-primary: var(--pngx-bg-alt);
// color: var(--pngx-primary-text-contrast);
}

View File

@ -0,0 +1,118 @@
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
FormsModule,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms'
import { NgSelectModule } from '@ng-select/ng-select'
import { of, throwError } from 'rxjs'
import { DocumentService } from 'src/app/services/rest/document.service'
import { DocumentLinkComponent } from './document-link.component'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
const documents = [
{
id: 1,
title: 'Document 1 foo',
},
{
id: 12,
title: 'Document 12 bar',
},
{
id: 23,
title: 'Document 23 bar',
},
]
describe('DocumentLinkComponent', () => {
let component: DocumentLinkComponent
let fixture: ComponentFixture<DocumentLinkComponent>
let documentService: DocumentService
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [DocumentLinkComponent],
imports: [
HttpClientTestingModule,
NgSelectModule,
FormsModule,
ReactiveFormsModule,
],
})
documentService = TestBed.inject(DocumentService)
fixture = TestBed.createComponent(DocumentLinkComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should retrieve selected documents from APIs', () => {
const getSpy = jest.spyOn(documentService, 'getCachedMany')
getSpy.mockImplementation((ids) => {
return of(documents.filter((d) => ids.includes(d.id)))
})
component.writeValue([1])
expect(getSpy).toHaveBeenCalled()
})
it('should search API on select text input', () => {
const listSpy = jest.spyOn(documentService, 'listFiltered')
listSpy.mockImplementation(
(page, pageSize, sortField, sortReverse, filterRules, extraParams) => {
const docs = documents.filter((d) =>
d.title.includes(filterRules[0].value)
)
return of({
count: docs.length,
results: docs,
all: docs.map((d) => d.id),
})
}
)
component.documentsInput$.next('bar')
expect(listSpy).toHaveBeenCalledWith(
1,
null,
'created',
true,
[{ rule_type: FILTER_TITLE, value: 'bar' }],
{ truncate_content: true }
)
listSpy.mockReturnValueOnce(throwError(() => new Error()))
component.documentsInput$.next('foo')
})
it('should load values correctly', () => {
jest.spyOn(documentService, 'getCachedMany').mockImplementation((ids) => {
return of(documents.filter((d) => ids.includes(d.id)))
})
component.writeValue([12, 23])
expect(component.value).toEqual([12, 23])
expect(component.selectedDocuments).toEqual([documents[1], documents[2]])
component.writeValue(null)
expect(component.value).toEqual([])
expect(component.selectedDocuments).toEqual([])
component.writeValue([])
expect(component.value).toEqual([])
expect(component.selectedDocuments).toEqual([])
})
it('should support unselect', () => {
const getSpy = jest.spyOn(documentService, 'getCachedMany')
getSpy.mockImplementation((ids) => {
return of(documents.filter((d) => ids.includes(d.id)))
})
component.writeValue([12, 23])
component.unselect({ id: 23 })
fixture.detectChanges()
expect(component.selectedDocuments).toEqual([documents[1]])
})
it('should use correct compare, trackBy functions', () => {
expect(component.compareDocuments(documents[0], { id: 1 })).toBeTruthy()
expect(component.compareDocuments(documents[0], { id: 2 })).toBeFalsy()
expect(component.trackByFn(documents[1])).toEqual(12)
})
})

View File

@ -0,0 +1,120 @@
import { Component, forwardRef, OnInit, Input, OnDestroy } from '@angular/core'
import { NG_VALUE_ACCESSOR } from '@angular/forms'
import {
Subject,
Observable,
takeUntil,
concat,
of,
distinctUntilChanged,
tap,
switchMap,
map,
catchError,
} from 'rxjs'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { PaperlessDocument } from 'src/app/data/paperless-document'
import { DocumentService } from 'src/app/services/rest/document.service'
import { AbstractInputComponent } from '../abstract-input'
@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DocumentLinkComponent),
multi: true,
},
],
selector: 'pngx-input-document-link',
templateUrl: './document-link.component.html',
styleUrls: ['./document-link.component.scss'],
})
export class DocumentLinkComponent
extends AbstractInputComponent<any[]>
implements OnInit, OnDestroy
{
documentsInput$ = new Subject<string>()
foundDocuments$: Observable<PaperlessDocument[]>
loading = false
selectedDocuments: PaperlessDocument[] = []
private unsubscribeNotifier: Subject<any> = new Subject()
@Input()
notFoundText: string = $localize`No documents found`
constructor(private documentsService: DocumentService) {
super()
}
ngOnInit() {
this.loadDocs()
}
writeValue(documentIDs: number[]): void {
if (!documentIDs || documentIDs.length === 0) {
this.selectedDocuments = []
super.writeValue([])
} else {
this.loading = true
this.documentsService
.getCachedMany(documentIDs)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((documents) => {
this.loading = false
this.selectedDocuments = documents
super.writeValue(documentIDs)
})
}
}
private loadDocs() {
this.foundDocuments$ = concat(
of([]), // default items
this.documentsInput$.pipe(
distinctUntilChanged(),
takeUntil(this.unsubscribeNotifier),
tap(() => (this.loading = true)),
switchMap((title) =>
this.documentsService
.listFiltered(
1,
null,
'created',
true,
[{ rule_type: FILTER_TITLE, value: title }],
{ truncate_content: true }
)
.pipe(
map((results) => results.results),
catchError(() => of([])), // empty on error
tap(() => (this.loading = false))
)
)
)
)
}
unselect(document: PaperlessDocument): void {
this.selectedDocuments = this.selectedDocuments.filter(
(d) => d.id !== document.id
)
this.onChange(this.selectedDocuments.map((d) => d.id))
}
compareDocuments(
document: PaperlessDocument,
selectedDocument: PaperlessDocument
) {
return document.id === selectedDocument.id
}
trackByFn(item: PaperlessDocument) {
return item.id
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next(true)
this.unsubscribeNotifier.complete()
}
}

View File

@ -1,8 +1,15 @@
<div class="mb-3">
<label class="form-label" [for]="inputId">{{title}}</label>
<input #inputField type="password" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)">
<small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
<div class="input-group" [class.is-invalid]="error">
<input #inputField [type]="showReveal && textVisible ? 'text' : 'password'" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (focus)="onFocus()" (focusout)="onFocusOut()" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete">
<button *ngIf="showReveal" type="button" class="btn btn-outline-secondary" (click)="toggleVisibility()" i18n-title title="Show password" [disabled]="disabled || disableRevealToggle">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#eye" />
</svg>
</button>
</div>
<div class="invalid-feedback">
{{error}}
</div>
<small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
</div>

View File

@ -5,6 +5,7 @@ import {
NG_VALUE_ACCESSOR,
} from '@angular/forms'
import { PasswordComponent } from './password.component'
import { By } from '@angular/platform-browser'
describe('PasswordComponent', () => {
let component: PasswordComponent
@ -33,4 +34,26 @@ describe('PasswordComponent', () => {
// fixture.detectChanges()
// expect(component.value).toEqual('foo')
})
it('should support toggling field visibility', () => {
expect(input.type).toEqual('password')
component.showReveal = true
fixture.detectChanges()
fixture.debugElement.query(By.css('button')).triggerEventHandler('click')
fixture.detectChanges()
expect(input.type).toEqual('text')
})
it('should empty field if password is obfuscated on focus', () => {
component.value = '*********'
component.onFocus()
expect(component.value).toEqual('')
component.onFocusOut()
expect(component.value).toEqual('**********')
})
it('should disable toggle button if no real password', () => {
component.value = '*********'
expect(component.disableRevealToggle).toBeTruthy()
})
})

View File

@ -1,4 +1,4 @@
import { Component, forwardRef } from '@angular/core'
import { Component, Input, forwardRef } from '@angular/core'
import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { AbstractInputComponent } from '../abstract-input'
@ -15,7 +15,32 @@ import { AbstractInputComponent } from '../abstract-input'
styleUrls: ['./password.component.scss'],
})
export class PasswordComponent extends AbstractInputComponent<string> {
constructor() {
super()
@Input()
showReveal: boolean = false
@Input()
autocomplete: string
public textVisible: boolean = false
public toggleVisibility(): void {
this.textVisible = !this.textVisible
}
public onFocus() {
if (this.value?.replace(/\*/g, '').length === 0) {
this.writeValue('')
}
}
public onFocusOut() {
if (this.value?.length === 0) {
this.writeValue('**********')
this.onChange(this.value)
}
}
get disableRevealToggle(): boolean {
return this.value?.replace(/\*/g, '').length === 0
}
}

View File

@ -1,4 +1,4 @@
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="suggestions">
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0">
<div class="row">
<div class="d-flex align-items-center" [class.col-md-3]="horizontal">
<label class="form-label" [class.mb-md-0]="horizontal" for="tags" i18n>{{title}}</label>

View File

@ -32,11 +32,9 @@ import { CheckComponent } from '../check/check.component'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { TextComponent } from '../text/text.component'
import { ColorComponent } from '../color/color.component'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsFormComponent } from '../permissions/permissions-form/permissions-form.component'
import { SelectComponent } from '../select/select.component'
import { ColorSliderModule } from 'ngx-color/slider'
import { By } from '@angular/platform-browser'
import { SettingsService } from 'src/app/services/settings.service'
const tags: PaperlessTag[] = [
{
@ -63,8 +61,8 @@ const tags: PaperlessTag[] = [
describe('TagsComponent', () => {
let component: TagsComponent
let fixture: ComponentFixture<TagsComponent>
let input: HTMLInputElement
let modalService: NgbModal
let settingsService: SettingsService
beforeEach(async () => {
TestBed.configureTestingModule({
@ -110,6 +108,7 @@ describe('TagsComponent', () => {
}).compileComponents()
modalService = TestBed.inject(NgbModal)
settingsService = TestBed.inject(SettingsService)
fixture = TestBed.createComponent(TagsComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
@ -139,6 +138,7 @@ describe('TagsComponent', () => {
})
it('should support create new using last search term and open a modal', () => {
settingsService.currentUser = { id: 1 }
let activeInstances: NgbModalRef[]
modalService.activeInstances.subscribe((v) => (activeInstances = v))
component.select.searchTerm = 'foobar'

View File

@ -9,7 +9,7 @@
</button>
</div>
<div class="position-relative" [class.col-md-9]="horizontal">
<input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled">
<input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete">
<small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
<div class="invalid-feedback position-absolute top-100">
{{error}}

View File

@ -1,4 +1,4 @@
import { Component, forwardRef } from '@angular/core'
import { Component, Input, forwardRef } from '@angular/core'
import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { AbstractInputComponent } from '../abstract-input'
@ -15,6 +15,9 @@ import { AbstractInputComponent } from '../abstract-input'
styleUrls: ['./text.component.scss'],
})
export class TextComponent extends AbstractInputComponent<string> {
@Input()
autocomplete: string
constructor() {
super()
}

View File

@ -1,4 +1,4 @@
<svg [class]="getClasses()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" [attr.height]="height">
<svg [class]="getClasses()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" [attr.style]="'height:'+height">
<path class="leaf" d="M140,713.7c-3.4-16.4-10.3-49.1-11.2-49.1c-145.7-87.1-128.4-238-80.2-324.2C59,449,251.2,524,139.1,656.8 c-0.9,1.7,5.2,22.4,10.3,41.4c22.4-37.9,56-83.6,54.3-87.9C65.9,273.9,496.9,248.1,586.6,39.4c40.5,201.8-20.7,513.9-367.2,593.2 c-1.7,0.9-62.9,108.6-65.5,109.5c0-1.7-25.9-0.9-22.4-9.5C133.1,727.4,136.6,720.6,140,713.7L140,713.7z M135.7,632.6 c44-50.9-7.8-137.9-38.8-166.4C149.5,556.7,146,609.3,135.7,632.6L135.7,632.6z" transform="translate(0)" style="fill:#17541f"/>
<g class="text" style="fill:#000">
<path d="M1022.3,428.7c-17.8-19.9-42.7-29.8-74.7-29.8c-22.3,0-42.4,5.7-60.5,17.3c-18.1,11.6-32.3,27.5-42.5,47.8 s-15.3,42.9-15.3,67.8c0,24.9,5.1,47.5,15.3,67.8c10.3,20.3,24.4,36.2,42.5,47.8c18.1,11.5,38.3,17.3,60.5,17.3 c32,0,56.9-9.9,74.7-29.8v20.4v0.2h84.5V408.3h-84.5V428.7z M1010.5,575c-10.2,11.7-23.6,17.6-40.2,17.6s-29.9-5.9-40-17.6 s-15.1-26.1-15.1-43.3c0-17.1,5-31.6,15.1-43.3s23.4-17.6,40-17.6c16.6,0,30,5.9,40.2,17.6s15.3,26.1,15.3,43.3 S1020.7,563.3,1010.5,575z" transform="translate(0)"/>

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@ -24,13 +24,13 @@ describe('LogoComponent', () => {
})
it('should support setting height', () => {
expect(fixture.debugElement.query(By.css('svg')).attributes.height).toEqual(
'6em'
expect(fixture.debugElement.query(By.css('svg')).attributes.style).toEqual(
'height:6em'
)
component.height = '10em'
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('svg')).attributes.height).toEqual(
'10em'
expect(fixture.debugElement.query(By.css('svg')).attributes.style).toEqual(
'height:10em'
)
})
})

View File

@ -0,0 +1,3 @@
<div #pdfViewerContainer class="pngx-pdf-viewer-container">
<div class="pdfViewer"></div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,599 @@
/**
* This file is taken and modified from https://github.com/VadimDez/ng2-pdf-viewer/blob/10.0.0/src/app/pdf-viewer/pdf-viewer.component.ts
* Created by vadimdez on 21/06/16.
*/
import {
Component,
Input,
Output,
ElementRef,
EventEmitter,
OnChanges,
SimpleChanges,
OnInit,
OnDestroy,
ViewChild,
AfterViewChecked,
NgZone,
} from '@angular/core'
import { from, fromEvent, Subject } from 'rxjs'
import { debounceTime, filter, takeUntil } from 'rxjs/operators'
import * as PDFJS from 'pdfjs-dist'
import * as PDFJSViewer from 'pdfjs-dist/web/pdf_viewer'
import { createEventBus } from './utils/event-bus-utils'
import type {
PDFSource,
PDFPageProxy,
PDFProgressData,
PDFDocumentProxy,
PDFDocumentLoadingTask,
PDFViewerOptions,
ZoomScale,
} from './typings'
import { PDFSinglePageViewer } from 'pdfjs-dist/web/pdf_viewer'
PDFJS['verbosity'] = PDFJS.VerbosityLevel.ERRORS
// Yea this is a straight hack
declare global {
interface WeakKeyTypes {
symbol: Object
}
type WeakKey = WeakKeyTypes[keyof WeakKeyTypes]
}
export enum RenderTextMode {
DISABLED,
ENABLED,
ENHANCED,
}
@Component({
selector: 'pngx-pdf-viewer',
templateUrl: './pdf-viewer.component.html',
styleUrls: ['./pdf-viewer.component.scss'],
})
export class PdfViewerComponent
implements OnChanges, OnInit, OnDestroy, AfterViewChecked
{
static CSS_UNITS = 96.0 / 72.0
static BORDER_WIDTH = 9
@ViewChild('pdfViewerContainer')
pdfViewerContainer!: ElementRef<HTMLDivElement>
public eventBus!: PDFJSViewer.EventBus
public pdfLinkService!: PDFJSViewer.PDFLinkService
public pdfViewer!: PDFJSViewer.PDFViewer | PDFSinglePageViewer
private isVisible = false
private _cMapsUrl =
typeof PDFJS !== 'undefined'
? `https://unpkg.com/pdfjs-dist@${(PDFJS as any).version}/cmaps/`
: null
private _imageResourcesPath =
typeof PDFJS !== 'undefined'
? `https://unpkg.com/pdfjs-dist@${(PDFJS as any).version}/web/images/`
: undefined
private _renderText = true
private _renderTextMode: RenderTextMode = RenderTextMode.ENABLED
private _stickToPage = false
private _originalSize = true
private _pdf: PDFDocumentProxy | undefined
private _page = 1
private _zoom = 1
private _zoomScale: ZoomScale = 'page-width'
private _rotation = 0
private _showAll = true
private _canAutoResize = true
private _fitToPage = false
private _externalLinkTarget = 'blank'
private _showBorders = false
private lastLoaded!: string | Uint8Array | PDFSource | null
private _latestScrolledPage!: number
private resizeTimeout: number | null = null
private pageScrollTimeout: number | null = null
private isInitialized = false
private loadingTask?: PDFDocumentLoadingTask | null
private destroy$ = new Subject<void>()
@Output('after-load-complete') afterLoadComplete =
new EventEmitter<PDFDocumentProxy>()
@Output('page-rendered') pageRendered = new EventEmitter<CustomEvent>()
@Output('pages-initialized') pageInitialized = new EventEmitter<CustomEvent>()
@Output('text-layer-rendered') textLayerRendered =
new EventEmitter<CustomEvent>()
@Output('error') onError = new EventEmitter<any>()
@Output('on-progress') onProgress = new EventEmitter<PDFProgressData>()
@Output() pageChange: EventEmitter<number> = new EventEmitter<number>(true)
@Input() src?: string | Uint8Array | PDFSource
@Input('c-maps-url')
set cMapsUrl(cMapsUrl: string) {
this._cMapsUrl = cMapsUrl
}
@Input('page')
set page(_page: number | string | any) {
_page = parseInt(_page, 10) || 1
const originalPage = _page
if (this._pdf) {
_page = this.getValidPageNumber(_page)
}
this._page = _page
if (originalPage !== _page) {
this.pageChange.emit(_page)
}
}
@Input('render-text')
set renderText(renderText: boolean) {
this._renderText = renderText
}
@Input('render-text-mode')
set renderTextMode(renderTextMode: RenderTextMode) {
this._renderTextMode = renderTextMode
}
@Input('original-size')
set originalSize(originalSize: boolean) {
this._originalSize = originalSize
}
@Input('show-all')
set showAll(value: boolean) {
this._showAll = value
}
@Input('stick-to-page')
set stickToPage(value: boolean) {
this._stickToPage = value
}
@Input('zoom')
set zoom(value: number) {
if (value <= 0) {
return
}
this._zoom = value
}
get zoom() {
return this._zoom
}
@Input('zoom-scale')
set zoomScale(value: ZoomScale) {
this._zoomScale = value
}
get zoomScale() {
return this._zoomScale
}
@Input('rotation')
set rotation(value: number) {
if (!(typeof value === 'number' && value % 90 === 0)) {
console.warn('Invalid pages rotation angle.')
return
}
this._rotation = value
}
@Input('external-link-target')
set externalLinkTarget(value: string) {
this._externalLinkTarget = value
}
@Input('autoresize')
set autoresize(value: boolean) {
this._canAutoResize = Boolean(value)
}
@Input('fit-to-page')
set fitToPage(value: boolean) {
this._fitToPage = Boolean(value)
}
@Input('show-borders')
set showBorders(value: boolean) {
this._showBorders = Boolean(value)
}
static getLinkTarget(type: string) {
switch (type) {
case 'blank':
return (PDFJSViewer as any).LinkTarget.BLANK
case 'none':
return (PDFJSViewer as any).LinkTarget.NONE
case 'self':
return (PDFJSViewer as any).LinkTarget.SELF
case 'parent':
return (PDFJSViewer as any).LinkTarget.PARENT
case 'top':
return (PDFJSViewer as any).LinkTarget.TOP
}
return null
}
constructor(
private element: ElementRef<HTMLElement>,
private ngZone: NgZone
) {
PDFJS.GlobalWorkerOptions['workerSrc'] = '/assets/js/pdf.worker.min.js'
}
ngAfterViewChecked(): void {
if (this.isInitialized) {
return
}
const offset = this.pdfViewerContainer.nativeElement.offsetParent
if (this.isVisible === true && offset == null) {
this.isVisible = false
return
}
if (this.isVisible === false && offset != null) {
this.isVisible = true
setTimeout(() => {
this.initialize()
this.ngOnChanges({ src: this.src } as any)
})
}
}
ngOnInit() {
this.initialize()
this.setupResizeListener()
}
ngOnDestroy() {
this.clear()
this.destroy$.next()
this.loadingTask = null
}
ngOnChanges(changes: SimpleChanges) {
if (!this.isVisible) {
return
}
if ('src' in changes) {
this.loadPDF()
} else if (this._pdf) {
if ('renderText' in changes || 'showAll' in changes) {
this.setupViewer()
this.resetPdfDocument()
}
if ('page' in changes) {
const { page } = changes
if (page.currentValue === this._latestScrolledPage) {
return
}
// New form of page changing: The viewer will now jump to the specified page when it is changed.
// This behavior is introduced by using the PDFSinglePageViewer
this.pdfViewer.scrollPageIntoView({ pageNumber: this._page })
}
this.update()
}
}
public updateSize() {
from(
this._pdf!.getPage(
this.pdfViewer.currentPageNumber
) as unknown as Promise<PDFPageProxy>
)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (page: PDFPageProxy) => {
const rotation = this._rotation + page.rotate
const viewportWidth =
(page as any).getViewport({
scale: this._zoom,
rotation,
}).width * PdfViewerComponent.CSS_UNITS
let scale = this._zoom
let stickToPage = true
// Scale the document when it shouldn't be in original size or doesn't fit into the viewport
if (
!this._originalSize ||
(this._fitToPage &&
viewportWidth > this.pdfViewerContainer.nativeElement.clientWidth)
) {
const viewPort = (page as any).getViewport({ scale: 1, rotation })
scale = this.getScale(viewPort.width, viewPort.height)
stickToPage = !this._stickToPage
}
setTimeout(() => {
this.pdfViewer.currentScale = scale
})
},
})
}
public clear() {
if (this.loadingTask && !this.loadingTask.destroyed) {
this.loadingTask.destroy()
}
if (this._pdf) {
this._latestScrolledPage = 0
this._pdf.destroy()
this._pdf = undefined
}
}
private getPDFLinkServiceConfig() {
const linkTarget = PdfViewerComponent.getLinkTarget(
this._externalLinkTarget
)
if (linkTarget) {
return { externalLinkTarget: linkTarget }
}
return {}
}
private initEventBus() {
this.eventBus = createEventBus(PDFJSViewer, this.destroy$)
fromEvent<CustomEvent>(this.eventBus, 'pagerendered')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
this.pageRendered.emit(event)
})
fromEvent<CustomEvent>(this.eventBus, 'pagesinit')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
this.pageInitialized.emit(event)
})
fromEvent(this.eventBus, 'pagechanging')
.pipe(takeUntil(this.destroy$))
.subscribe(({ pageNumber }: any) => {
if (this.pageScrollTimeout) {
clearTimeout(this.pageScrollTimeout)
}
this.pageScrollTimeout = window.setTimeout(() => {
this._latestScrolledPage = pageNumber
this.pageChange.emit(pageNumber)
}, 100)
})
fromEvent<CustomEvent>(this.eventBus, 'textlayerrendered')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
this.textLayerRendered.emit(event)
})
}
private initPDFServices() {
this.pdfLinkService = new PDFJSViewer.PDFLinkService({
eventBus: this.eventBus,
...this.getPDFLinkServiceConfig(),
})
}
private getPDFOptions(): PDFViewerOptions {
return {
eventBus: this.eventBus,
container: this.element.nativeElement.querySelector('div')!,
removePageBorders: !this._showBorders,
linkService: this.pdfLinkService,
textLayerMode: this._renderText
? this._renderTextMode
: RenderTextMode.DISABLED,
imageResourcesPath: this._imageResourcesPath,
}
}
private setupViewer() {
PDFJS['disableTextLayer'] = !this._renderText
this.initPDFServices()
if (this._showAll) {
this.pdfViewer = new PDFJSViewer.PDFViewer(this.getPDFOptions())
} else {
this.pdfViewer = new PDFJSViewer.PDFSinglePageViewer(this.getPDFOptions())
}
this.pdfLinkService.setViewer(this.pdfViewer)
this.pdfViewer._currentPageNumber = this._page
}
private getValidPageNumber(page: number): number {
if (page < 1) {
return 1
}
if (page > this._pdf!.numPages) {
return this._pdf!.numPages
}
return page
}
private getDocumentParams() {
const srcType = typeof this.src
if (!this._cMapsUrl) {
return this.src
}
const params: any = {
cMapUrl: this._cMapsUrl,
cMapPacked: true,
enableXfa: true,
}
if (srcType === 'string') {
params.url = this.src
} else if (srcType === 'object') {
if ((this.src as any).byteLength !== undefined) {
params.data = this.src
} else {
Object.assign(params, this.src)
}
}
return params
}
private loadPDF() {
if (!this.src) {
return
}
if (this.lastLoaded === this.src) {
this.update()
return
}
this.clear()
this.setupViewer()
this.loadingTask = PDFJS.getDocument(this.getDocumentParams())
this.loadingTask!.onProgress = (progressData: PDFProgressData) => {
this.onProgress.emit(progressData)
}
const src = this.src
from(this.loadingTask!.promise as Promise<PDFDocumentProxy>)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (pdf) => {
this._pdf = pdf
this.lastLoaded = src
this.afterLoadComplete.emit(pdf)
this.resetPdfDocument()
this.update()
},
error: (error) => {
this.lastLoaded = null
this.onError.emit(error)
},
})
}
private update() {
this.page = this._page
this.render()
}
private render() {
this._page = this.getValidPageNumber(this._page)
if (
this._rotation !== 0 ||
this.pdfViewer.pagesRotation !== this._rotation
) {
setTimeout(() => {
this.pdfViewer.pagesRotation = this._rotation
})
}
if (this._stickToPage) {
setTimeout(() => {
this.pdfViewer.currentPageNumber = this._page
})
}
this.updateSize()
}
private getScale(viewportWidth: number, viewportHeight: number) {
const borderSize = this._showBorders
? 2 * PdfViewerComponent.BORDER_WIDTH
: 0
const pdfContainerWidth =
this.pdfViewerContainer.nativeElement.clientWidth - borderSize
const pdfContainerHeight =
this.pdfViewerContainer.nativeElement.clientHeight - borderSize
if (
pdfContainerHeight === 0 ||
viewportHeight === 0 ||
pdfContainerWidth === 0 ||
viewportWidth === 0
) {
return 1
}
let ratio = 1
switch (this._zoomScale) {
case 'page-fit':
ratio = Math.min(
pdfContainerHeight / viewportHeight,
pdfContainerWidth / viewportWidth
)
break
case 'page-height':
ratio = pdfContainerHeight / viewportHeight
break
case 'page-width':
default:
ratio = pdfContainerWidth / viewportWidth
break
}
return (this._zoom * ratio) / PdfViewerComponent.CSS_UNITS
}
private resetPdfDocument() {
this.pdfLinkService.setDocument(this._pdf, null)
this.pdfViewer.setDocument(this._pdf!)
}
private initialize(): void {
if (!this.isVisible) {
return
}
this.isInitialized = true
this.initEventBus()
this.setupViewer()
}
private setupResizeListener(): void {
this.ngZone.runOutsideAngular(() => {
fromEvent(window, 'resize')
.pipe(
debounceTime(100),
filter(() => this._canAutoResize && !!this._pdf),
takeUntil(this.destroy$)
)
.subscribe(() => {
this.updateSize()
})
})
}
}

View File

@ -0,0 +1,17 @@
export type PDFPageProxy =
import('pdfjs-dist/types/src/display/api').PDFPageProxy
export type PDFSource =
import('pdfjs-dist/types/src/display/api').DocumentInitParameters
export type PDFDocumentProxy =
import('pdfjs-dist/types/src/display/api').PDFDocumentProxy
export type PDFDocumentLoadingTask =
import('pdfjs-dist/types/src/display/api').PDFDocumentLoadingTask
export type PDFViewerOptions =
import('pdfjs-dist/types/web/pdf_viewer').PDFViewerOptions
export interface PDFProgressData {
loaded: number
total: number
}
export type ZoomScale = 'page-height' | 'page-fit' | 'page-width'

View File

@ -0,0 +1,182 @@
/**
* This file is taken and modified from https://github.com/VadimDez/ng2-pdf-viewer/blob/10.0.0/src/app/pdf-viewer/utils/event-bus-utils.ts
* Created by vadimdez on 21/06/16.
*/
import { fromEvent, Subject } from 'rxjs'
import { takeUntil } from 'rxjs/operators'
import type { EventBus } from 'pdfjs-dist/web/pdf_viewer'
// interface EventBus {
// on(eventName: string, listener: Function): void;
// off(eventName: string, listener: Function): void;
// _listeners: any;
// dispatch(eventName: string, data: Object): void;
// _on(eventName: any, listener: any, options?: null): void;
// _off(eventName: any, listener: any, options?: null): void;
// }
export function createEventBus(pdfJsViewer: any, destroy$: Subject<void>) {
const globalEventBus: EventBus = new pdfJsViewer.EventBus()
attachDOMEventsToEventBus(globalEventBus, destroy$)
return globalEventBus
}
function attachDOMEventsToEventBus(
eventBus: EventBus,
destroy$: Subject<void>
): void {
fromEvent(eventBus, 'documentload')
.pipe(takeUntil(destroy$))
.subscribe(() => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('documentload', true, true, {})
window.dispatchEvent(event)
})
fromEvent(eventBus, 'pagerendered')
.pipe(takeUntil(destroy$))
.subscribe(({ pageNumber, cssTransform, source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('pagerendered', true, true, {
pageNumber,
cssTransform,
})
source.div.dispatchEvent(event)
})
fromEvent(eventBus, 'textlayerrendered')
.pipe(takeUntil(destroy$))
.subscribe(({ pageNumber, source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('textlayerrendered', true, true, { pageNumber })
source.textLayerDiv?.dispatchEvent(event)
})
fromEvent(eventBus, 'pagechanging')
.pipe(takeUntil(destroy$))
.subscribe(({ pageNumber, source }: any) => {
const event = document.createEvent('UIEvents') as any
event.initEvent('pagechanging', true, true)
/* tslint:disable:no-string-literal */
event['pageNumber'] = pageNumber
source.container.dispatchEvent(event)
})
fromEvent(eventBus, 'pagesinit')
.pipe(takeUntil(destroy$))
.subscribe(({ source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('pagesinit', true, true, null)
source.container.dispatchEvent(event)
})
fromEvent(eventBus, 'pagesloaded')
.pipe(takeUntil(destroy$))
.subscribe(({ pagesCount, source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('pagesloaded', true, true, { pagesCount })
source.container.dispatchEvent(event)
})
fromEvent(eventBus, 'scalechange')
.pipe(takeUntil(destroy$))
.subscribe(({ scale, presetValue, source }: any) => {
const event = document.createEvent('UIEvents') as any
event.initEvent('scalechange', true, true)
/* tslint:disable:no-string-literal */
event['scale'] = scale
/* tslint:disable:no-string-literal */
event['presetValue'] = presetValue
source.container.dispatchEvent(event)
})
fromEvent(eventBus, 'updateviewarea')
.pipe(takeUntil(destroy$))
.subscribe(({ location, source }: any) => {
const event = document.createEvent('UIEvents') as any
event.initEvent('updateviewarea', true, true)
event['location'] = location
source.container.dispatchEvent(event)
})
fromEvent(eventBus, 'find')
.pipe(takeUntil(destroy$))
.subscribe(
({
source,
type,
query,
phraseSearch,
caseSensitive,
highlightAll,
findPrevious,
}: any) => {
if (source === window) {
return // event comes from FirefoxCom, no need to replicate
}
const event = document.createEvent('CustomEvent')
event.initCustomEvent('find' + type, true, true, {
query,
phraseSearch,
caseSensitive,
highlightAll,
findPrevious,
})
window.dispatchEvent(event)
}
)
fromEvent(eventBus, 'attachmentsloaded')
.pipe(takeUntil(destroy$))
.subscribe(({ attachmentsCount, source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('attachmentsloaded', true, true, {
attachmentsCount,
})
source.container.dispatchEvent(event)
})
fromEvent(eventBus, 'sidebarviewchanged')
.pipe(takeUntil(destroy$))
.subscribe(({ view, source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('sidebarviewchanged', true, true, { view })
source.outerContainer.dispatchEvent(event)
})
fromEvent(eventBus, 'pagemode')
.pipe(takeUntil(destroy$))
.subscribe(({ mode, source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('pagemode', true, true, { mode })
source.pdfViewer.container.dispatchEvent(event)
})
fromEvent(eventBus, 'namedaction')
.pipe(takeUntil(destroy$))
.subscribe(({ action, source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('namedaction', true, true, { action })
source.pdfViewer.container.dispatchEvent(event)
})
fromEvent(eventBus, 'presentationmodechanged')
.pipe(takeUntil(destroy$))
.subscribe(({ active, switchInProgress }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('presentationmodechanged', true, true, {
active,
switchInProgress,
})
window.dispatchEvent(event)
})
fromEvent(eventBus, 'outlineloaded')
.pipe(takeUntil(destroy$))
.subscribe(({ outlineCount, source }: any) => {
const event = document.createEvent('CustomEvent')
event.initCustomEvent('outlineloaded', true, true, { outlineCount })
source.container.dispatchEvent(event)
})
}

View File

@ -80,7 +80,16 @@ describe('PermissionsDialogComponent', () => {
it('should return permissions', () => {
expect(component.permissions).toEqual({
owner: null,
set_permissions: null,
set_permissions: {
view: {
users: [],
groups: [],
},
change: {
users: [],
groups: [],
},
},
})
component.form.get('permissions_form').setValue(set_permissions)
expect(component.permissions).toEqual(set_permissions)

View File

@ -52,8 +52,17 @@ export class PermissionsDialogComponent {
get permissions() {
return {
owner: this.form.get('permissions_form').value?.owner ?? null,
set_permissions:
this.form.get('permissions_form').value?.set_permissions ?? null,
set_permissions: this.form.get('permissions_form').value
?.set_permissions ?? {
view: {
users: [],
groups: [],
},
change: {
users: [],
groups: [],
},
},
}
}

View File

@ -0,0 +1,56 @@
<form [formGroup]="form" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title" i18n>Edit Profile</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error?.email"></pngx-input-text>
<div ngbAccordion>
<div ngbAccordionItem="first" [collapsed]="!showEmailConfirm" class="border-0 bg-transparent">
<div ngbAccordionCollapse>
<div ngbAccordionBody class="p-0 pb-3">
<pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error?.email_confirm"></pngx-input-text>
</div>
</div>
</div>
</div>
<pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error?.password"></pngx-input-password>
<div ngbAccordion>
<div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm" class="border-0 bg-transparent">
<div ngbAccordionCollapse>
<div ngbAccordionBody class="p-0 pb-3">
<pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error?.password_confirm"></pngx-input-password>
</div>
</div>
</div>
</div>
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
<div class="mb-3">
<label class="form-label" i18n>API Auth Token</label>
<div class="position-relative">
<div class="input-group">
<input type="text" class="form-control" formControlName="auth_token" readonly>
<button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy">
<svg class="buttonicon-sm" fill="currentColor">
<use *ngIf="!copied" xlink:href="assets/bootstrap-icons.svg#clipboard-fill" />
<use *ngIf="copied" xlink:href="assets/bootstrap-icons.svg#clipboard-check-fill" />
</svg><span class="visually-hidden" i18n>Copy</span>
</button>
<button type="button" class="btn btn-outline-secondary" (click)="generateAuthToken()" i18n-title title="Regenerate auth token">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-repeat" />
</svg>
</button>
</div>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
</div>
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
</div>
</div>
<div class="modal-footer">
<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 || saveDisabled">Save</button>
</div>
</form>

View File

@ -0,0 +1,9 @@
::ng-deep {
.accordion-body .mb-3 {
margin: 0 !important; // hack-ish, for animation
}
}
.copied-badge {
right: 8em;
}

View File

@ -0,0 +1,222 @@
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ProfileEditDialogComponent } from './profile-edit-dialog.component'
import { ProfileService } from 'src/app/services/profile.service'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
NgbAccordionModule,
NgbActiveModal,
NgbModal,
NgbModalModule,
} from '@ng-bootstrap/ng-bootstrap'
import { HttpClientModule } from '@angular/common/http'
import { TextComponent } from '../input/text/text.component'
import { PasswordComponent } from '../input/password/password.component'
import { of, throwError } from 'rxjs'
import { ToastService } from 'src/app/services/toast.service'
import { Clipboard } from '@angular/cdk/clipboard'
const profile = {
email: 'foo@bar.com',
password: '*********',
first_name: 'foo',
last_name: 'bar',
auth_token: '123456789abcdef',
}
describe('ProfileEditDialogComponent', () => {
let component: ProfileEditDialogComponent
let fixture: ComponentFixture<ProfileEditDialogComponent>
let profileService: ProfileService
let toastService: ToastService
let clipboard: Clipboard
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [
ProfileEditDialogComponent,
TextComponent,
PasswordComponent,
],
providers: [NgbActiveModal],
imports: [
HttpClientModule,
ReactiveFormsModule,
FormsModule,
NgbModalModule,
NgbAccordionModule,
],
})
profileService = TestBed.inject(ProfileService)
toastService = TestBed.inject(ToastService)
clipboard = TestBed.inject(Clipboard)
fixture = TestBed.createComponent(ProfileEditDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should get profile on init, display in form', () => {
const getSpy = jest.spyOn(profileService, 'get')
getSpy.mockReturnValue(of(profile))
component.ngOnInit()
expect(getSpy).toHaveBeenCalled()
fixture.detectChanges()
expect(component.form.get('email').value).toEqual(profile.email)
})
it('should update profile on save, display error if needed', () => {
const newProfile = {
email: 'foo@bar2.com',
password: profile.password,
first_name: 'foo2',
last_name: profile.last_name,
auth_token: profile.auth_token,
}
const updateSpy = jest.spyOn(profileService, 'update')
const errorSpy = jest.spyOn(toastService, 'showError')
updateSpy.mockReturnValueOnce(throwError(() => new Error('failed to save')))
component.save()
expect(errorSpy).toHaveBeenCalled()
updateSpy.mockClear()
const infoSpy = jest.spyOn(toastService, 'showInfo')
component.form.patchValue(newProfile)
updateSpy.mockReturnValueOnce(of(newProfile))
component.save()
expect(updateSpy).toHaveBeenCalledWith(newProfile)
expect(infoSpy).toHaveBeenCalled()
})
it('should close on cancel', () => {
const closeSpy = jest.spyOn(component.activeModal, 'close')
component.cancel()
expect(closeSpy).toHaveBeenCalled()
})
it('should show additional confirmation field when email changes, warn with error & disable save', () => {
expect(component.form.get('email_confirm').enabled).toBeFalsy()
const getSpy = jest.spyOn(profileService, 'get')
getSpy.mockReturnValue(of(profile))
component.ngOnInit()
component.form.get('email').patchValue('foo@bar2.com')
component.onEmailKeyUp({ target: { value: 'foo@bar2.com' } } as any)
fixture.detectChanges()
expect(component.form.get('email_confirm').enabled).toBeTruthy()
expect(fixture.debugElement.nativeElement.textContent).toContain(
'Emails must match'
)
expect(component.saveDisabled).toBeTruthy()
component.form.get('email_confirm').patchValue('foo@bar2.com')
component.onEmailConfirmKeyUp({ target: { value: 'foo@bar2.com' } } as any)
fixture.detectChanges()
expect(fixture.debugElement.nativeElement.textContent).not.toContain(
'Emails must match'
)
expect(component.saveDisabled).toBeFalsy()
component.form.get('email').patchValue(profile.email)
fixture.detectChanges()
expect(component.form.get('email_confirm').enabled).toBeFalsy()
expect(fixture.debugElement.nativeElement.textContent).not.toContain(
'Emails must match'
)
expect(component.saveDisabled).toBeFalsy()
})
it('should show additional confirmation field when password changes, warn with error & disable save', () => {
expect(component.form.get('password_confirm').enabled).toBeFalsy()
const getSpy = jest.spyOn(profileService, 'get')
getSpy.mockReturnValue(of(profile))
component.ngOnInit()
component.form.get('password').patchValue('new*pass')
component.onPasswordKeyUp({
target: { value: 'new*pass', tagName: 'input' },
} as any)
component.onPasswordKeyUp({ target: { tagName: 'button' } } as any) // coverage
fixture.detectChanges()
expect(component.form.get('password_confirm').enabled).toBeTruthy()
expect(fixture.debugElement.nativeElement.textContent).toContain(
'Passwords must match'
)
expect(component.saveDisabled).toBeTruthy()
component.form.get('password_confirm').patchValue('new*pass')
component.onPasswordConfirmKeyUp({ target: { value: 'new*pass' } } as any)
fixture.detectChanges()
expect(fixture.debugElement.nativeElement.textContent).not.toContain(
'Passwords must match'
)
expect(component.saveDisabled).toBeFalsy()
component.form.get('password').patchValue(profile.password)
fixture.detectChanges()
expect(component.form.get('password_confirm').enabled).toBeFalsy()
expect(fixture.debugElement.nativeElement.textContent).not.toContain(
'Passwords must match'
)
expect(component.saveDisabled).toBeFalsy()
})
it('should logout on save if password changed', fakeAsync(() => {
const getSpy = jest.spyOn(profileService, 'get')
getSpy.mockReturnValue(of(profile))
component.ngOnInit()
component['newPassword'] = 'new*pass'
component.form.get('password').patchValue('new*pass')
component.form.get('password_confirm').patchValue('new*pass')
const updateSpy = jest.spyOn(profileService, 'update')
updateSpy.mockReturnValue(of(null))
Object.defineProperty(window, 'location', {
value: {
href: 'http://localhost/',
},
writable: true, // possibility to override
})
component.save()
expect(updateSpy).toHaveBeenCalled()
tick(2600)
expect(window.location.href).toContain('logout')
}))
it('should support auth token copy', fakeAsync(() => {
const getSpy = jest.spyOn(profileService, 'get')
getSpy.mockReturnValue(of(profile))
component.ngOnInit()
const copySpy = jest.spyOn(clipboard, 'copy')
component.copyAuthToken()
expect(copySpy).toHaveBeenCalledWith(profile.auth_token)
expect(component.copied).toBeTruthy()
tick(3000)
expect(component.copied).toBeFalsy()
}))
it('should support generate token, display error if needed', () => {
const getSpy = jest.spyOn(profileService, 'get')
getSpy.mockReturnValue(of(profile))
const generateSpy = jest.spyOn(profileService, 'generateAuthToken')
const errorSpy = jest.spyOn(toastService, 'showError')
generateSpy.mockReturnValueOnce(
throwError(() => new Error('failed to generate'))
)
component.generateAuthToken()
expect(errorSpy).toHaveBeenCalled()
generateSpy.mockClear()
const newToken = '789101112hijk'
generateSpy.mockReturnValueOnce(of(newToken))
component.generateAuthToken()
expect(generateSpy).toHaveBeenCalled()
expect(component.form.get('auth_token').value).not.toEqual(
profile.auth_token
)
expect(component.form.get('auth_token').value).toEqual(newToken)
})
})

View File

@ -0,0 +1,184 @@
import { Component, OnDestroy, OnInit } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { ProfileService } from 'src/app/services/profile.service'
import { ToastService } from 'src/app/services/toast.service'
import { Subject, takeUntil } from 'rxjs'
import { Clipboard } from '@angular/cdk/clipboard'
@Component({
selector: 'pngx-profile-edit-dialog',
templateUrl: './profile-edit-dialog.component.html',
styleUrls: ['./profile-edit-dialog.component.scss'],
})
export class ProfileEditDialogComponent implements OnInit, OnDestroy {
public networkActive: boolean = false
public error: any
private unsubscribeNotifier: Subject<any> = new Subject()
public form = new FormGroup({
email: new FormControl(''),
email_confirm: new FormControl({ value: null, disabled: true }),
password: new FormControl(null),
password_confirm: new FormControl({ value: null, disabled: true }),
first_name: new FormControl(''),
last_name: new FormControl(''),
auth_token: new FormControl(''),
})
private currentPassword: string
private newPassword: string
private passwordConfirm: string
public showPasswordConfirm: boolean = false
private currentEmail: string
private newEmail: string
private emailConfirm: string
public showEmailConfirm: boolean = false
public copied: boolean = false
constructor(
private profileService: ProfileService,
public activeModal: NgbActiveModal,
private toastService: ToastService,
private clipboard: Clipboard
) {}
ngOnInit(): void {
this.networkActive = true
this.profileService
.get()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((profile) => {
this.networkActive = false
this.form.patchValue(profile)
this.currentEmail = profile.email
this.form.get('email').valueChanges.subscribe((newEmail) => {
this.newEmail = newEmail
this.onEmailChange()
})
this.currentPassword = profile.password
this.form.get('password').valueChanges.subscribe((newPassword) => {
this.newPassword = newPassword
this.onPasswordChange()
})
})
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next(true)
this.unsubscribeNotifier.complete()
}
get saveDisabled(): boolean {
return this.error?.password_confirm || this.error?.email_confirm
}
onEmailKeyUp(event: KeyboardEvent): void {
this.newEmail = (event.target as HTMLInputElement)?.value
this.onEmailChange()
}
onEmailConfirmKeyUp(event: KeyboardEvent): void {
this.emailConfirm = (event.target as HTMLInputElement)?.value
this.onEmailChange()
}
onEmailChange(): void {
this.showEmailConfirm = this.currentEmail !== this.newEmail
if (this.showEmailConfirm) {
this.form.get('email_confirm').enable()
if (this.newEmail !== this.emailConfirm) {
if (!this.error) this.error = {}
this.error.email_confirm = $localize`Emails must match`
} else {
delete this.error?.email_confirm
}
} else {
this.form.get('email_confirm').disable()
delete this.error?.email_confirm
}
}
onPasswordKeyUp(event: KeyboardEvent): void {
if ((event.target as HTMLElement).tagName !== 'input') return // toggle button can trigger this handler
this.newPassword = (event.target as HTMLInputElement)?.value
this.onPasswordChange()
}
onPasswordConfirmKeyUp(event: KeyboardEvent): void {
this.passwordConfirm = (event.target as HTMLInputElement)?.value
this.onPasswordChange()
}
onPasswordChange(): void {
this.showPasswordConfirm = this.currentPassword !== this.newPassword
if (this.showPasswordConfirm) {
this.form.get('password_confirm').enable()
if (this.newPassword !== this.passwordConfirm) {
if (!this.error) this.error = {}
this.error.password_confirm = $localize`Passwords must match`
} else {
delete this.error?.password_confirm
}
} else {
this.form.get('password_confirm').disable()
delete this.error?.password_confirm
}
}
save(): void {
const passwordChanged = this.currentPassword !== this.newPassword
const profile = Object.assign({}, this.form.value)
this.networkActive = true
this.profileService
.update(profile)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.toastService.showInfo($localize`Profile updated successfully`)
if (passwordChanged) {
this.toastService.showInfo(
$localize`Password has been changed, you will be logged out momentarily.`
)
setTimeout(() => {
window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
}, 2500)
}
this.activeModal.close()
},
error: (error) => {
this.toastService.showError($localize`Error saving profile`, error)
this.networkActive = false
},
})
}
cancel(): void {
this.activeModal.close()
}
generateAuthToken(): void {
this.profileService.generateAuthToken().subscribe({
next: (token: string) => {
this.form.patchValue({ auth_token: token })
},
error: (error) => {
this.toastService.showError(
$localize`Error generating auth token`,
error
)
},
})
}
copyAuthToken(): void {
this.clipboard.copy(this.form.get('auth_token').value)
this.copied = true
setTimeout(() => {
this.copied = false
}, 3000)
}
}

View File

@ -1,4 +1,4 @@
<ngb-alert class="pe-3 text-primary-contrast" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
<ngb-alert class="pe-3" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
<h4 class="alert-heading"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
<p i18n>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</p>
<p i18n>More detail on how to use and configure Paperless-ngx is always available in the <a href="https://docs.paperless-ngx.com" target="_blank">documentation</a>.</p>

View File

@ -1,9 +1,20 @@
<pngx-page-header [(title)]="title">
<div class="input-group input-group-sm me-5 d-none d-md-flex" *ngIf="getContentType() === 'application/pdf' && !useNativePdfViewer">
<div class="input-group-text" i18n>Page</div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" />
<div class="input-group-text" i18n>of {{previewNumPages}}</div>
</div>
<ng-container *ngIf="getContentType() === 'application/pdf' && !useNativePdfViewer">
<div class="input-group input-group-sm me-2 d-none d-md-flex">
<div class="input-group-text" i18n>Page</div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" />
<div class="input-group-text" i18n>of {{previewNumPages}}</div>
</div>
<div class="input-group input-group-sm me-5 d-none d-md-flex">
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button>
<select class="form-select" (change)="onZoomSelect($event)">
<option *ngFor="let setting of zoomSettings" [value]="setting" [selected]="previewZoomSetting === setting">
{{ getZoomSettingTitle(setting) }}
</option>
</select>
<button class="btn btn-outline-secondary" (click)="increaseZoom()" i18n>+</button>
</div>
</ng-container>
<button type="button" class="btn btn-sm btn-outline-danger me-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
<svg class="buttonicon" fill="currentColor">
@ -120,6 +131,7 @@
<pngx-input-number *ngSwitchCase="PaperlessCustomFieldDataType.Monetary" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".01" [error]="getCustomFieldError(i)"></pngx-input-number>
<pngx-input-check *ngSwitchCase="PaperlessCustomFieldDataType.Boolean" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true"></pngx-input-check>
<pngx-input-url *ngSwitchCase="PaperlessCustomFieldDataType.Url" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-url>
<pngx-input-document-link *ngSwitchCase="PaperlessCustomFieldDataType.DocumentLink" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-document-link>
</div>
</ng-container>
</div>
@ -189,24 +201,7 @@
<li [ngbNavItem]="DocumentDetailNavIDs.Preview" class="d-md-none">
<a ngbNavLink i18n>Preview</a>
<ng-template ngbNavContent *ngIf="!pdfPreview.offsetParent">
<div class="position-relative">
<ng-container *ngIf="getContentType() === 'application/pdf'">
<div class="preview-sticky pdf-viewer-container" *ngIf="!useNativePdfViewer ; else nativePdfViewer">
<pdf-viewer [src]="{ url: previewUrl, password: password }" [original-size]="false" [show-borders]="true" [show-all]="true" [(page)]="previewCurrentPage" [render-text-mode]="2" (error)="onError($event)" (after-load-complete)="pdfPreviewLoaded($event)"></pdf-viewer>
</div>
<ng-template #nativePdfViewer>
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
</ng-template>
</ng-container>
<ng-container *ngIf="getContentType() === 'text/plain'">
<object [data]="previewUrl | safeUrl" type="text/plain" class="preview-sticky bg-light overflow-auto" width="100%"></object>
</ng-container>
<div *ngIf="requiresPassword" class="password-prompt">
<form>
<input autocomplete="" class="form-control" i18n-placeholder placeholder="Enter Password" type="password" (keyup)="onPasswordKeyUp($event)" />
</form>
</div>
</div>
<ng-container *ngTemplateOutlet="previewContent"></ng-container>
</ng-template>
</li>
@ -233,14 +228,7 @@
</div>
<div class="col-md-6 col-xl-8 mb-3 d-none d-md-block position-relative" #pdfPreview>
<ng-container *ngIf="getContentType() === 'application/pdf'">
<div class="preview-sticky pdf-viewer-container" *ngIf="!useNativePdfViewer ; else nativePdfViewer">
<pdf-viewer [src]="{ url: previewUrl, password: password }" [original-size]="false" [show-borders]="true" [show-all]="true" [(page)]="previewCurrentPage" [render-text-mode]="2" (error)="onError($event)" (after-load-complete)="pdfPreviewLoaded($event)"></pdf-viewer>
</div>
<ng-template #nativePdfViewer>
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
</ng-template>
</ng-container>
<ng-container *ngTemplateOutlet="previewContent"></ng-container>
<ng-container *ngIf="renderAsPlainText">
<div [innerText]="previewText" class="preview-sticky bg-light p-3 overflow-auto" width="100%"></div>
</ng-container>
@ -252,3 +240,39 @@
</div>
</div>
<ng-template #previewContent>
<div *ngIf="!metadata" class="w-100 h-100 d-flex align-items-center justify-content-center">
<div>
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</div>
</div>
<ng-container *ngIf="getContentType() === 'application/pdf'">
<div class="preview-sticky pdf-viewer-container" *ngIf="!useNativePdfViewer ; else nativePdfViewer">
<pngx-pdf-viewer
[src]="{ url: previewUrl, password: password }"
[original-size]="false"
[show-borders]="true"
[show-all]="true"
[(page)]="previewCurrentPage"
[zoom-scale]="previewZoomScale"
[zoom]="previewZoomSetting"
[render-text-mode]="2"
(error)="onError($event)"
(after-load-complete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer>
</div>
<ng-template #nativePdfViewer>
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
</ng-template>
</ng-container>
<ng-container *ngIf="renderAsPlainText">
<div [innerText]="previewText" class="preview-sticky bg-light p-3 overflow-auto" width="100%"></div>
</ng-container>
<div *ngIf="showPasswordField" class="password-prompt">
<form>
<input autocomplete="" autofocus="true" class="form-control" i18n-placeholder placeholder="Enter Password" type="password" (keyup)="onPasswordKeyUp($event)" />
</form>
</div>
</ng-template>

View File

@ -7,19 +7,14 @@
.pdf-viewer-container {
background-color: gray;
pdf-viewer {
pngx-pdf-viewer {
width: 100%;
height: 100%;
}
}
::ng-deep .ng2-pdf-viewer-container .page {
--page-margin: 1px 0 10px;
width: 100% !important;
}
::ng-deep .ng2-pdf-viewer-container .page:last-child {
--page-margin: 1px 0 20px;
::ng-deep .pngx-pdf-viewer-container .page {
--page-margin: 10px auto;
}
::ng-deep .ng-select-taggable {
@ -41,3 +36,11 @@
textarea.rtl {
direction: rtl;
}
.form-select {
padding-right: 2.5em;
}
.input-group .btn-outline-secondary {
border-color: var(--bs-border-color);
}

View File

@ -19,7 +19,6 @@ import {
NgbDateStruct,
} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { PdfViewerComponent } from 'ng2-pdf-viewer'
import { of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import {
@ -70,6 +69,7 @@ import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/shar
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import { PaperlessCustomFieldDataType } from 'src/app/data/paperless-custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { PdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
const doc: PaperlessDocument = {
id: 3,
@ -160,10 +160,10 @@ describe('DocumentDetailComponent', () => {
PermissionsFormComponent,
SafeHtmlPipe,
ConfirmDialogComponent,
PdfViewerComponent,
SafeUrlPipe,
ShareLinksDropdownComponent,
CustomFieldsDropdownComponent,
PdfViewerComponent,
],
providers: [
DocumentTitlePipe,
@ -263,6 +263,7 @@ describe('DocumentDetailComponent', () => {
toastService = TestBed.inject(ToastService)
documentListViewService = TestBed.inject(DocumentListViewService)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 1 }
customFieldsService = TestBed.inject(CustomFieldsService)
fixture = TestBed.createComponent(DocumentDetailComponent)
component = fixture.componentInstance
@ -682,6 +683,35 @@ describe('DocumentDetailComponent', () => {
expect(component.previewNumPages).toEqual(1000)
})
it('should support zoom controls', () => {
initNormally()
component.onZoomSelect({ target: { value: '1' } } as any) // from select
expect(component.previewZoomSetting).toEqual('1')
component.increaseZoom()
expect(component.previewZoomSetting).toEqual('1.5')
component.increaseZoom()
expect(component.previewZoomSetting).toEqual('2')
component.decreaseZoom()
expect(component.previewZoomSetting).toEqual('1.5')
component.onZoomSelect({ target: { value: '1' } } as any) // from select
component.decreaseZoom()
expect(component.previewZoomSetting).toEqual('.75')
component.onZoomSelect({ target: { value: 'page-fit' } } as any) // from select
expect(component.previewZoomScale).toEqual('page-fit')
expect(component.previewZoomSetting).toEqual('1')
component.increaseZoom()
expect(component.previewZoomSetting).toEqual('1.5')
expect(component.previewZoomScale).toEqual('page-width')
component.onZoomSelect({ target: { value: 'page-fit' } } as any) // from select
expect(component.previewZoomScale).toEqual('page-fit')
expect(component.previewZoomSetting).toEqual('1')
component.decreaseZoom()
expect(component.previewZoomSetting).toEqual('.5')
expect(component.previewZoomScale).toEqual('page-width')
})
it('should support updating notes dynamically', () => {
const notes = [
{
@ -805,7 +835,7 @@ describe('DocumentDetailComponent', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(false)
expect(component.useNativePdfViewer).toBeFalsy()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
})
it('should display native pdf viewer if enabled', () => {

View File

@ -21,7 +21,6 @@ import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { PDFDocumentProxy } from 'ng2-pdf-viewer'
import { ToastService } from 'src/app/services/toast.service'
import { TextComponent } from '../common/input/text/text.component'
import { SettingsService } from 'src/app/services/settings.service'
@ -69,6 +68,7 @@ import {
} from 'src/app/data/paperless-custom-field'
import { PaperlessCustomFieldInstance } from 'src/app/data/paperless-custom-field-instance'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { PDFDocumentProxy } from '../common/pdf-viewer/typings'
enum DocumentDetailNavIDs {
Details = 1,
@ -79,6 +79,18 @@ enum DocumentDetailNavIDs {
Permissions = 6,
}
enum ZoomSetting {
PageFit = 'page-fit',
PageWidth = 'page-width',
Quarter = '.25',
Half = '.5',
ThreeQuarters = '.75',
One = '1',
OneAndHalf = '1.5',
Two = '2',
Three = '3',
}
@Component({
selector: 'pngx-document-detail',
templateUrl: './document-detail.component.html',
@ -130,6 +142,8 @@ export class DocumentDetailComponent
previewCurrentPage: number = 1
previewNumPages: number = 1
previewZoomSetting: ZoomSetting = ZoomSetting.One
previewZoomScale: ZoomSetting = ZoomSetting.PageWidth
store: BehaviorSubject<any>
isDirty$: Observable<boolean>
@ -744,6 +758,54 @@ export class DocumentDetailComponent
}
}
onZoomSelect(event: Event) {
const setting = (event.target as HTMLSelectElement)?.value as ZoomSetting
if (ZoomSetting.PageFit === setting) {
this.previewZoomSetting = ZoomSetting.One
this.previewZoomScale = setting
} else {
this.previewZoomScale = ZoomSetting.PageWidth
this.previewZoomSetting = setting
}
}
get zoomSettings() {
return Object.values(ZoomSetting).filter(
(setting) => setting !== ZoomSetting.PageWidth
)
}
getZoomSettingTitle(setting: ZoomSetting): string {
switch (setting) {
case ZoomSetting.PageFit:
return $localize`Page Fit`
default:
return `${parseFloat(setting) * 100}%`
}
}
increaseZoom(): void {
let currentIndex = Object.values(ZoomSetting).indexOf(
this.previewZoomSetting
)
if (this.previewZoomScale === ZoomSetting.PageFit) currentIndex = 5
this.previewZoomScale = ZoomSetting.PageWidth
this.previewZoomSetting =
Object.values(ZoomSetting)[
Math.min(Object.values(ZoomSetting).length - 1, currentIndex + 1)
]
}
decreaseZoom(): void {
let currentIndex = Object.values(ZoomSetting).indexOf(
this.previewZoomSetting
)
if (this.previewZoomScale === ZoomSetting.PageFit) currentIndex = 4
this.previewZoomScale = ZoomSetting.PageWidth
this.previewZoomSetting =
Object.values(ZoomSetting)[Math.max(2, currentIndex - 1)]
}
get showPermissions(): boolean {
return (
this.permissionsService.currentUserCan(

View File

@ -84,7 +84,9 @@ describe('FileDropComponent', () => {
it('should support drag drop, initiate upload', fakeAsync(() => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
expect(component.fileIsOver).toBeFalsy()
component.onDragOver(new Event('dragover') as DragEvent)
const overEvent = new Event('dragover') as DragEvent
;(overEvent as any).dataTransfer = { types: ['Files'] }
component.onDragOver(overEvent)
tick(1)
fixture.detectChanges()
expect(component.fileIsOver).toBeTruthy()
@ -151,7 +153,9 @@ describe('FileDropComponent', () => {
const leaveSpy = jest.spyOn(component, 'onDragLeave')
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
settingsService.globalDropzoneEnabled = true
component.onDragOver(new Event('dragover') as DragEvent)
const overEvent = new Event('dragover') as DragEvent
;(overEvent as any).dataTransfer = { types: ['Files'] }
component.onDragOver(overEvent)
tick(1)
expect(component.hidden).toBeFalsy()
expect(component.fileIsOver).toBeTruthy()
@ -165,7 +169,9 @@ describe('FileDropComponent', () => {
const leaveSpy = jest.spyOn(component, 'onDragLeave')
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
settingsService.globalDropzoneEnabled = true
component.onDragOver(new Event('dragover') as DragEvent)
const overEvent = new Event('dragover') as DragEvent
;(overEvent as any).dataTransfer = { types: ['Files'] }
component.onDragOver(overEvent)
tick(1)
expect(component.hidden).toBeFalsy()
expect(component.fileIsOver).toBeTruthy()

View File

@ -38,8 +38,9 @@ export class FileDropComponent {
@ViewChild('ngxFileDrop') ngxFileDrop: NgxFileDropComponent
@HostListener('dragover', ['$event ']) onDragOver(event: DragEvent) {
if (!this.dragDropEnabled) return
@HostListener('dragover', ['$event']) onDragOver(event: DragEvent) {
if (!this.dragDropEnabled || !event.dataTransfer?.types?.includes('Files'))
return
event.preventDefault()
event.stopImmediatePropagation()
this.settings.globalDropzoneActive = true

View File

@ -38,4 +38,6 @@ export interface PaperlessConsumptionTemplate extends ObjectWithId {
assign_change_users?: number[] // [PaperlessUser.id]
assign_change_groups?: number[] // [PaperlessGroup.id]
assign_custom_fields?: number[] // [PaperlessCustomField.id]
}

View File

@ -8,6 +8,7 @@ export enum PaperlessCustomFieldDataType {
Integer = 'integer',
Float = 'float',
Monetary = 'monetary',
DocumentLink = 'documentlink',
}
export const DATA_TYPE_LABELS = [
@ -39,6 +40,10 @@ export const DATA_TYPE_LABELS = [
id: PaperlessCustomFieldDataType.Url,
name: $localize`Url`,
},
{
id: PaperlessCustomFieldDataType.DocumentLink,
name: $localize`Document Link`,
},
]
export interface PaperlessCustomField extends ObjectWithId {

View File

@ -49,7 +49,9 @@ export interface PaperlessMailRule extends ObjectWithPermissions {
filter_body: string
filter_attachment_filename: string
filter_attachment_filename_include: string
filter_attachment_filename_exclude: string
maximum_age: number

View File

@ -0,0 +1,7 @@
export interface PaperlessUserProfile {
email?: string
password?: string
first_name?: string
last_name?: string
auth_token?: string
}

View File

@ -0,0 +1,54 @@
import { TestBed } from '@angular/core/testing'
import { ProfileService } from './profile.service'
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import { environment } from 'src/environments/environment'
describe('ProfileService', () => {
let httpTestingController: HttpTestingController
let service: ProfileService
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ProfileService],
imports: [HttpClientTestingModule],
})
httpTestingController = TestBed.inject(HttpTestingController)
service = TestBed.inject(ProfileService)
})
afterEach(() => {
httpTestingController.verify()
})
it('calls get profile endpoint', () => {
service.get().subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}profile/`
)
expect(req.request.method).toEqual('GET')
})
it('calls patch on update', () => {
service.update({ email: 'foo@bar.com' }).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}profile/`
)
expect(req.request.method).toEqual('PATCH')
expect(req.request.body).toEqual({
email: 'foo@bar.com',
})
})
it('supports generating new auth token', () => {
service.generateAuthToken().subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}profile/generate_auth_token/`
)
expect(req.request.method).toEqual('POST')
})
})

View File

@ -0,0 +1,34 @@
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { PaperlessUserProfile } from '../data/user-profile'
import { environment } from 'src/environments/environment'
@Injectable({
providedIn: 'root',
})
export class ProfileService {
private endpoint = 'profile'
constructor(private http: HttpClient) {}
get(): Observable<PaperlessUserProfile> {
return this.http.get<PaperlessUserProfile>(
`${environment.apiBaseUrl}${this.endpoint}/`
)
}
update(profile: PaperlessUserProfile): Observable<PaperlessUserProfile> {
return this.http.patch<PaperlessUserProfile>(
`${environment.apiBaseUrl}${this.endpoint}/`,
profile
)
}
generateAuthToken(): Observable<string> {
return this.http.post<string>(
`${environment.apiBaseUrl}${this.endpoint}/generate_auth_token/`,
{}
)
}
}

View File

@ -23,7 +23,8 @@ const mail_rules = [
filter_to: null,
filter_subject: null,
filter_body: null,
filter_attachment_filename: null,
filter_attachment_filename_include: null,
filter_attachment_filename_exclude: null,
maximum_age: 30,
attachment_type: MailFilterAttachmentType.Everything,
action: MailAction.MarkRead,
@ -40,7 +41,8 @@ const mail_rules = [
filter_to: null,
filter_subject: null,
filter_body: null,
filter_attachment_filename: null,
filter_attachment_filename_include: null,
filter_attachment_filename_exclude: null,
maximum_age: 30,
attachment_type: MailFilterAttachmentType.Everything,
action: MailAction.Delete,
@ -57,7 +59,8 @@ const mail_rules = [
filter_to: null,
filter_subject: null,
filter_body: null,
filter_attachment_filename: null,
filter_attachment_filename_include: null,
filter_attachment_filename_exclude: null,
maximum_age: 30,
attachment_type: MailFilterAttachmentType.Everything,
action: MailAction.Flag,

View File

@ -143,6 +143,8 @@ export class SettingsService {
`${hsl.l * 100}%`
)
} else {
this._renderer.removeClass(this.document.body, 'primary-dark')
this._renderer.removeClass(this.document.body, 'primary-light')
document.documentElement.style.removeProperty('--pngx-primary')
document.documentElement.style.removeProperty('--pngx-primary-lightness')
}

View File

@ -5,7 +5,7 @@ export const environment = {
apiBaseUrl: document.baseURI + 'api/',
apiVersion: '3',
appTitle: 'Paperless-ngx',
version: '2.0.1',
version: '2.0.1-dev',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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