mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-25 10:49:30 -05:00
Merge remote-tracking branch 'origin/dev'
This commit is contained in:
commit
6cfe92bed1
1
.env
1
.env
@ -1,2 +1 @@
|
||||
COMPOSE_PROJECT_NAME=paperless
|
||||
export PROMPT="(pipenv-projectname)$P$G"
|
||||
|
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -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;
|
||||
|
25
.github/workflows/cleanup-tags.yml
vendored
25
.github/workflows/cleanup-tags.yml
vendored
@ -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
33
.github/workflows/crowdin.yml
vendored
Normal 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 }}
|
30
.github/workflows/project-actions.yml
vendored
30
.github/workflows/project-actions.yml
vendored
@ -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
173
Pipfile.lock
generated
@ -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"
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
||||
|
@ -2,6 +2,10 @@
|
||||
|
||||
## paperless-ngx 2.0.1
|
||||
|
||||
### Please Note
|
||||
|
||||
Exports generated in Paperless-ngx v2.0.0–2.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.0–2.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))
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
||||
|
@ -1,7 +1,8 @@
|
||||
{
|
||||
"root": true,
|
||||
"ignorePatterns": [
|
||||
"projects/**/*"
|
||||
"projects/**/*",
|
||||
"/src/app/components/common/pdf-viewer/**"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
|
@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -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 }) => {
|
||||
|
@ -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
588
src-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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> <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> <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>
|
||||
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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"/>
|
||||
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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([]),
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -131,6 +131,7 @@ describe('EditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should interpolate object permissions', () => {
|
||||
component.getMatchingAlgorithms() // coverage
|
||||
component.object = tag
|
||||
component.dialogMode = EditDialogMode.EDIT
|
||||
component.ngOnInit()
|
||||
|
@ -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: {
|
||||
|
@ -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>
|
||||
|
@ -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),
|
||||
|
@ -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> <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>
|
@ -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);
|
||||
}
|
@ -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)
|
||||
})
|
||||
})
|
@ -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()
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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'
|
||||
|
@ -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}}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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 |
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
@ -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
@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
17
src-ui/src/app/components/common/pdf-viewer/typings.ts
Normal file
17
src-ui/src/app/components/common/pdf-viewer/typings.ts
Normal 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'
|
@ -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)
|
||||
})
|
||||
}
|
@ -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)
|
||||
|
@ -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: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>
|
@ -0,0 +1,9 @@
|
||||
::ng-deep {
|
||||
.accordion-body .mb-3 {
|
||||
margin: 0 !important; // hack-ish, for animation
|
||||
}
|
||||
}
|
||||
|
||||
.copied-badge {
|
||||
right: 8em;
|
||||
}
|
@ -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)
|
||||
})
|
||||
})
|
@ -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)
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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', () => {
|
||||
|
@ -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(
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
||||
|
7
src-ui/src/app/data/user-profile.ts
Normal file
7
src-ui/src/app/data/user-profile.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface PaperlessUserProfile {
|
||||
email?: string
|
||||
password?: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
auth_token?: string
|
||||
}
|
54
src-ui/src/app/services/profile.service.spec.ts
Normal file
54
src-ui/src/app/services/profile.service.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
34
src-ui/src/app/services/profile.service.ts
Normal file
34
src-ui/src/app/services/profile.service.ts
Normal 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/`,
|
||||
{}
|
||||
)
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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')
|
||||
}
|
||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user