mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-08-24 01:06:17 +00:00
Compare commits
236 Commits
v2.11.2
...
feature-re
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ec12e71487 | ||
![]() |
62b470f691 | ||
![]() |
a2e4977201 | ||
![]() |
0fcd69b739 | ||
![]() |
af1c64e969 | ||
![]() |
85c661dff2 | ||
![]() |
3a7eee2c2e | ||
![]() |
bc4d3925cc | ||
![]() |
159344f033 | ||
![]() |
d8cfed5f5e | ||
![]() |
94fe7a9e3d | ||
![]() |
9c9b4effe2 | ||
![]() |
75f5007ede | ||
![]() |
d95baf4e6b | ||
![]() |
aac04e73b9 | ||
![]() |
3af3484a00 | ||
![]() |
90e68af6cf | ||
![]() |
eee08d389f | ||
![]() |
b3b0e95d2d | ||
![]() |
35907313e8 | ||
![]() |
d4a20c7e30 | ||
![]() |
3b1dffe0dc | ||
![]() |
fa0ab0de27 | ||
![]() |
3b2b4a9177 | ||
![]() |
3d030637ca | ||
![]() |
79092c27c5 | ||
![]() |
28fdb170bf | ||
![]() |
335c6c3820 | ||
![]() |
ad23cce2e6 | ||
![]() |
0d96cd03d5 | ||
![]() |
1888ee6a3f | ||
![]() |
605aa50b00 | ||
![]() |
149d770ad1 | ||
![]() |
b2e9f3195a | ||
![]() |
33e9990ed5 | ||
![]() |
e775b6346a | ||
![]() |
54e17f5b74 | ||
![]() |
ff1639d58b | ||
![]() |
7649903d3c | ||
![]() |
7a5d707fc0 | ||
![]() |
85e00aecb4 | ||
![]() |
53aa216a4a | ||
![]() |
2814cd110d | ||
![]() |
b9315b018a | ||
![]() |
27f7ba8cf8 | ||
![]() |
df9917b0f4 | ||
![]() |
86418f6e04 | ||
![]() |
69a6a12319 | ||
![]() |
b501d89846 | ||
![]() |
f6548e0e55 | ||
![]() |
a4b8bf1250 | ||
![]() |
1726aec989 | ||
![]() |
549312859e | ||
![]() |
544e9c4fe2 | ||
![]() |
0520db5e93 | ||
![]() |
1cb85d41f3 | ||
![]() |
fb94a5d377 | ||
![]() |
85e2081e40 | ||
![]() |
71e2565386 | ||
![]() |
82be90f7ff | ||
![]() |
f0ad073bb2 | ||
![]() |
61c804a6e3 | ||
![]() |
de95b296a0 | ||
![]() |
86a57838a8 | ||
![]() |
bddb9bfad8 | ||
![]() |
7098ec9bf5 | ||
![]() |
c2cfaaf8af | ||
![]() |
6292296876 | ||
![]() |
9f68e0f76a | ||
![]() |
4e849b545a | ||
![]() |
e43fee41cb | ||
![]() |
613f8a0065 | ||
![]() |
baf6484454 | ||
![]() |
9b84dc06b6 | ||
![]() |
cb617531bc | ||
![]() |
8e61a29137 | ||
![]() |
dfcecb3a5c | ||
![]() |
0a61b8e6fc | ||
![]() |
e78d758656 | ||
![]() |
073c42984a | ||
![]() |
2994f3a740 | ||
![]() |
2353f7c2db | ||
![]() |
dcc8d4046a | ||
![]() |
024b60638a | ||
![]() |
8dd355f6bf | ||
![]() |
cf3645c296 | ||
![]() |
facec317ef | ||
![]() |
95d1abd416 | ||
![]() |
7c11a37150 | ||
![]() |
e49ed58f1a | ||
![]() |
54293bedb1 | ||
![]() |
fc683e150a | ||
![]() |
b3487f1843 | ||
![]() |
f8d79b012f | ||
![]() |
2e3637d712 | ||
![]() |
74001bd0da | ||
![]() |
374a1ceb05 | ||
![]() |
59f726b2a2 | ||
![]() |
77bebc861d | ||
![]() |
85e57ede9b | ||
![]() |
a7424a7bfe | ||
![]() |
46b8e536a8 | ||
![]() |
2ab71137b9 | ||
![]() |
0b829cab32 | ||
![]() |
991c9b0ca4 | ||
![]() |
b9c1ba8a1d | ||
![]() |
c9e33a3401 | ||
![]() |
dd9b10bdf8 | ||
![]() |
546fd2740b | ||
![]() |
56e1365b4b | ||
![]() |
e6f59472e4 | ||
![]() |
5e687d9a93 | ||
![]() |
c92c3e224a | ||
![]() |
4adf20af1e | ||
![]() |
a9b7965dcf | ||
![]() |
d7ba6d98d3 | ||
![]() |
f6135f9ad0 | ||
![]() |
f06ff85b7d | ||
![]() |
1b7cacc877 | ||
![]() |
870d6ee782 | ||
![]() |
3aba68c09f | ||
![]() |
609fa9a212 | ||
![]() |
16069cde23 | ||
![]() |
a440c88b81 | ||
![]() |
97030a807f | ||
![]() |
4146b140d3 | ||
![]() |
fa6f013db5 | ||
![]() |
6192c15c4d | ||
![]() |
8aa35540b5 | ||
![]() |
e787055294 | ||
![]() |
045b62ca66 | ||
![]() |
3b7fdb2f37 | ||
![]() |
bd1f05df24 | ||
![]() |
eeeec498d4 | ||
![]() |
0af2b967e4 | ||
![]() |
4193401be7 | ||
![]() |
36df6fd3e5 | ||
![]() |
86a540e68e | ||
![]() |
fb3a881387 | ||
![]() |
8e555cce9e | ||
![]() |
4f8e59030e | ||
![]() |
5075d0bab0 | ||
![]() |
fb3a136b32 | ||
![]() |
66a8057e31 | ||
![]() |
cb6cf7f771 | ||
![]() |
9a7f95865f | ||
![]() |
0d1e0bc70e | ||
![]() |
bee963c23d | ||
![]() |
f1559b7108 | ||
![]() |
a64a182fc3 | ||
![]() |
aeb49898e5 | ||
![]() |
a2c8fcd46b | ||
![]() |
357ae92d88 | ||
![]() |
74330623b3 | ||
![]() |
3813dc2e18 | ||
![]() |
3df8be0bc7 | ||
![]() |
cc25cbc026 | ||
![]() |
e98d52830f | ||
![]() |
4903e4290d | ||
![]() |
e1ba1a1898 | ||
![]() |
b29c1e91d1 | ||
![]() |
a03c7701a5 | ||
![]() |
b8c9d1316c | ||
![]() |
a63ef26d38 | ||
![]() |
96b2884458 | ||
![]() |
8543202723 | ||
![]() |
a63904b7af | ||
![]() |
eb27bc9e7d | ||
![]() |
489b24ad65 | ||
![]() |
5349eb2302 | ||
![]() |
a8fd023398 | ||
![]() |
e34d48d913 | ||
![]() |
ee529c2276 | ||
![]() |
3d5e45c20a | ||
![]() |
b8283047ae | ||
![]() |
dad3a1ff28 | ||
![]() |
ce663398e6 | ||
![]() |
f5ec6de047 | ||
![]() |
807f788f92 | ||
![]() |
eaaaa575b8 | ||
![]() |
e21552e053 | ||
![]() |
6a7274c414 | ||
![]() |
35de04a2ce | ||
![]() |
a0c227fe55 | ||
![]() |
057ce29676 | ||
![]() |
982eeb0d24 | ||
![]() |
dfa25343a3 | ||
![]() |
dcfb4494c9 | ||
![]() |
5a1ef27224 | ||
![]() |
6f79ee9877 | ||
![]() |
bc0e420d67 | ||
![]() |
8e34756e6b | ||
![]() |
76951ea482 | ||
![]() |
b5e4aaa778 | ||
![]() |
39998cb34f | ||
![]() |
a771d2afd9 | ||
![]() |
dac3def6b9 | ||
![]() |
674b4a839c | ||
![]() |
037dcb6a11 | ||
![]() |
ad8c60d153 | ||
![]() |
3ea312e136 | ||
![]() |
d2a04743eb | ||
![]() |
b34f9c3b20 | ||
![]() |
fea7b0ec8c | ||
![]() |
0042e3eca4 | ||
![]() |
36db6f3d4b | ||
![]() |
3c633c2015 | ||
![]() |
dd8b51de67 | ||
![]() |
5fe846de1d | ||
![]() |
4711468598 | ||
![]() |
19dfaf1b94 | ||
![]() |
183ea24c9f | ||
![]() |
99693b6d30 | ||
![]() |
c0ad82b695 | ||
![]() |
fd36323d1c | ||
![]() |
4059c83a21 | ||
![]() |
b25c015516 | ||
![]() |
8fa52046e4 | ||
![]() |
15554322dd | ||
![]() |
0ee85aae21 | ||
![]() |
839fb34c8e | ||
![]() |
928580bf4f | ||
![]() |
9cca7aaa08 | ||
![]() |
38560cf13a | ||
![]() |
474ca08ef9 | ||
![]() |
d4fd529e49 | ||
![]() |
2260617447 | ||
![]() |
7c3ba3e518 | ||
![]() |
849e3a10ac | ||
![]() |
bdceeef3fb | ||
![]() |
8630e5f5b6 | ||
![]() |
2312eba5b6 | ||
![]() |
ad9d654886 | ||
![]() |
fa19a8975e | ||
![]() |
8987cd448f | ||
![]() |
a7536e3ebf |
14
.codecov.yml
14
.codecov.yml
@@ -14,6 +14,9 @@ flag_management:
|
|||||||
# codecov will only comment if coverage changes
|
# codecov will only comment if coverage changes
|
||||||
comment:
|
comment:
|
||||||
require_changes: true
|
require_changes: true
|
||||||
|
# https://docs.codecov.com/docs/javascript-bundle-analysis
|
||||||
|
require_bundle_changes: true
|
||||||
|
bundle_change_threshold: "50Kb"
|
||||||
coverage:
|
coverage:
|
||||||
status:
|
status:
|
||||||
project:
|
project:
|
||||||
@@ -22,7 +25,12 @@ coverage:
|
|||||||
threshold: 1%
|
threshold: 1%
|
||||||
patch:
|
patch:
|
||||||
default:
|
default:
|
||||||
# For the changed lines only, target 75% covered, but
|
# For the changed lines only, target 100% covered, but
|
||||||
# allow as low as 50%
|
# allow as low as 75%
|
||||||
target: 75%
|
target: 100%
|
||||||
threshold: 25%
|
threshold: 25%
|
||||||
|
# https://docs.codecov.com/docs/javascript-bundle-analysis
|
||||||
|
bundle_analysis:
|
||||||
|
# Fail if the bundle size increases by more than 1MB
|
||||||
|
warning_threshold: "1MB"
|
||||||
|
status: true
|
||||||
|
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -110,6 +110,8 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: I believe this issue is a bug that affects all users of Paperless-ngx, not something specific to my installation.
|
- label: I believe this issue is a bug that affects all users of Paperless-ngx, not something specific to my installation.
|
||||||
required: true
|
required: true
|
||||||
|
- label: This issue is not about the OCR or archive creation of a specific file(s). Otherwise, please see above regarding OCR tools.
|
||||||
|
required: true
|
||||||
- label: I have already searched for relevant existing issues and discussions before opening this report.
|
- label: I have already searched for relevant existing issues and discussions before opening this report.
|
||||||
required: true
|
required: true
|
||||||
- label: I have updated the title field above with a concise description.
|
- label: I have updated the title field above with a concise description.
|
||||||
|
42
.github/workflows/ci.yml
vendored
42
.github/workflows/ci.yml
vendored
@@ -16,9 +16,9 @@ on:
|
|||||||
env:
|
env:
|
||||||
# This is the version of pipenv all the steps will use
|
# This is the version of pipenv all the steps will use
|
||||||
# If changing this, change Dockerfile
|
# If changing this, change Dockerfile
|
||||||
DEFAULT_PIP_ENV_VERSION: "2024.0.1"
|
DEFAULT_PIP_ENV_VERSION: "2024.0.3"
|
||||||
# This is the default version of Python to use in most steps which aren't specific
|
# This is the default version of Python to use in most steps which aren't specific
|
||||||
DEFAULT_PYTHON_VERSION: "3.10"
|
DEFAULT_PYTHON_VERSION: "3.11"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pre-commit:
|
pre-commit:
|
||||||
@@ -100,7 +100,7 @@ jobs:
|
|||||||
- pre-commit
|
- pre-commit
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.9', '3.10', '3.11']
|
python-version: ['3.10', '3.11', '3.12']
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
@@ -260,7 +260,7 @@ jobs:
|
|||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
tests-coverage-upload:
|
tests-coverage-upload:
|
||||||
name: "Upload Coverage"
|
name: "Upload to Codecov"
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs:
|
needs:
|
||||||
- tests-backend
|
- tests-backend
|
||||||
@@ -306,6 +306,30 @@ jobs:
|
|||||||
# future expansion
|
# future expansion
|
||||||
flags: backend
|
flags: backend
|
||||||
directory: src/
|
directory: src/
|
||||||
|
-
|
||||||
|
name: Use Node.js 20
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20.x
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: 'src-ui/package-lock.json'
|
||||||
|
-
|
||||||
|
name: Cache frontend dependencies
|
||||||
|
id: cache-frontend-deps
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.npm
|
||||||
|
~/.cache
|
||||||
|
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }}
|
||||||
|
-
|
||||||
|
name: Re-link Angular cli
|
||||||
|
run: cd src-ui && npm link @angular/cli
|
||||||
|
-
|
||||||
|
name: Build frontend and upload analysis
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
run: cd src-ui && ng build --configuration=production
|
||||||
|
|
||||||
build-docker-image:
|
build-docker-image:
|
||||||
name: Build Docker image for ${{ github.ref_name }}
|
name: Build Docker image for ${{ github.ref_name }}
|
||||||
@@ -458,12 +482,6 @@ jobs:
|
|||||||
name: Install Python dependencies
|
name: Install Python dependencies
|
||||||
run: |
|
run: |
|
||||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev
|
pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev
|
||||||
-
|
|
||||||
name: Patch whitenoise
|
|
||||||
run: |
|
|
||||||
curl --fail --silent --show-error --location --output 484.patch https://github.com/evansd/whitenoise/pull/484.patch
|
|
||||||
patch -d $(pipenv --venv)/lib/python3.10/site-packages --verbose -p2 < 484.patch
|
|
||||||
rm 484.patch
|
|
||||||
-
|
-
|
||||||
name: Install system dependencies
|
name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -631,7 +649,9 @@ jobs:
|
|||||||
git checkout ${{ needs.publish-release.outputs.version }}-changelog
|
git checkout ${{ needs.publish-release.outputs.version }}-changelog
|
||||||
echo -e "# Changelog\n\n${{ needs.publish-release.outputs.changelog }}\n" > changelog-new.md
|
echo -e "# Changelog\n\n${{ needs.publish-release.outputs.changelog }}\n" > changelog-new.md
|
||||||
echo "Manually linking usernames"
|
echo "Manually linking usernames"
|
||||||
sed -i -r 's|@(.+?) \(\[#|[@\1](https://github.com/\1) ([#|ig' changelog-new.md
|
sed -i -r 's|@([a-zA-Z0-9_]+) \(\[#|[@\1](https://github.com/\1) ([#|g' changelog-new.md
|
||||||
|
echo "Removing unneeded comment tags"
|
||||||
|
sed -i -r 's|@<!---->|@|g' changelog-new.md
|
||||||
CURRENT_CHANGELOG=`tail --lines +2 changelog.md`
|
CURRENT_CHANGELOG=`tail --lines +2 changelog.md`
|
||||||
echo -e "$CURRENT_CHANGELOG" >> changelog-new.md
|
echo -e "$CURRENT_CHANGELOG" >> changelog-new.md
|
||||||
mv changelog-new.md changelog.md
|
mv changelog-new.md changelog.md
|
||||||
|
4
.github/workflows/cleanup-tags.yml
vendored
4
.github/workflows/cleanup-tags.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Clean temporary images
|
name: Clean temporary images
|
||||||
if: "${{ env.TOKEN != '' }}"
|
if: "${{ env.TOKEN != '' }}"
|
||||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.7.0
|
uses: stumpylog/image-cleaner-action/ephemeral@v0.8.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "${{ github.repository_owner }}"
|
owner: "${{ github.repository_owner }}"
|
||||||
@@ -61,7 +61,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Clean untagged images
|
name: Clean untagged images
|
||||||
if: "${{ env.TOKEN != '' }}"
|
if: "${{ env.TOKEN != '' }}"
|
||||||
uses: stumpylog/image-cleaner-action/untagged@v0.7.0
|
uses: stumpylog/image-cleaner-action/untagged@v0.8.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "${{ github.repository_owner }}"
|
owner: "${{ github.repository_owner }}"
|
||||||
|
1
.github/workflows/crowdin.yml
vendored
1
.github/workflows/crowdin.yml
vendored
@@ -15,6 +15,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
synchronize-with-crowdin:
|
synchronize-with-crowdin:
|
||||||
name: Crowdin Sync
|
name: Crowdin Sync
|
||||||
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
21
.github/workflows/repo-maintenance.yml
vendored
21
.github/workflows/repo-maintenance.yml
vendored
@@ -16,6 +16,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
stale:
|
stale:
|
||||||
name: 'Stale'
|
name: 'Stale'
|
||||||
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v9
|
- uses: actions/stale@v9
|
||||||
@@ -31,6 +32,7 @@ jobs:
|
|||||||
for your contributions. See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
for your contributions. See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
||||||
lock-threads:
|
lock-threads:
|
||||||
name: 'Lock Old Threads'
|
name: 'Lock Old Threads'
|
||||||
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: dessant/lock-threads@v5
|
- uses: dessant/lock-threads@v5
|
||||||
@@ -56,6 +58,7 @@ jobs:
|
|||||||
See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
||||||
close-answered-discussions:
|
close-answered-discussions:
|
||||||
name: 'Close Answered Discussions'
|
name: 'Close Answered Discussions'
|
||||||
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v7
|
- uses: actions/github-script@v7
|
||||||
@@ -112,6 +115,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
close-outdated-discussions:
|
close-outdated-discussions:
|
||||||
name: 'Close Outdated Discussions'
|
name: 'Close Outdated Discussions'
|
||||||
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v7
|
- uses: actions/github-script@v7
|
||||||
@@ -203,6 +207,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
close-unsupported-feature-requests:
|
close-unsupported-feature-requests:
|
||||||
name: 'Close Unsupported Feature Requests'
|
name: 'Close Unsupported Feature Requests'
|
||||||
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v7
|
- uses: actions/github-script@v7
|
||||||
@@ -212,15 +217,20 @@ jobs:
|
|||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CUTOFF_MAX_COUNT = 80;
|
||||||
const CUTOFF_1_DAYS = 180;
|
const CUTOFF_1_DAYS = 180;
|
||||||
const CUTOFF_1_COUNT = 5;
|
const CUTOFF_1_COUNT = 5;
|
||||||
const CUTOFF_2_DAYS = 365;
|
const CUTOFF_2_DAYS = 365;
|
||||||
const CUTOFF_2_COUNT = 10;
|
const CUTOFF_2_COUNT = 20;
|
||||||
|
const CUTOFF_3_DAYS = 730;
|
||||||
|
const CUTOFF_3_COUNT = 40;
|
||||||
|
|
||||||
const cutoff1Date = new Date();
|
const cutoff1Date = new Date();
|
||||||
cutoff1Date.setDate(cutoff1Date.getDate() - CUTOFF_1_DAYS);
|
cutoff1Date.setDate(cutoff1Date.getDate() - CUTOFF_1_DAYS);
|
||||||
const cutoff2Date = new Date();
|
const cutoff2Date = new Date();
|
||||||
cutoff2Date.setDate(cutoff2Date.getDate() - CUTOFF_2_DAYS);
|
cutoff2Date.setDate(cutoff2Date.getDate() - CUTOFF_2_DAYS);
|
||||||
|
const cutoff3Date = new Date();
|
||||||
|
cutoff3Date.setDate(cutoff3Date.getDate() - CUTOFF_3_DAYS);
|
||||||
|
|
||||||
const query = `query(
|
const query = `query(
|
||||||
$owner:String!,
|
$owner:String!,
|
||||||
@@ -250,9 +260,12 @@ jobs:
|
|||||||
const result = await github.graphql(query, variables);
|
const result = await github.graphql(query, variables);
|
||||||
|
|
||||||
for (const discussion of result.repository.discussions.nodes) {
|
for (const discussion of result.repository.discussions.nodes) {
|
||||||
const discussionDate = new Date(discussion.updatedAt);
|
const discussionUpdatedDate = new Date(discussion.updatedAt);
|
||||||
if ((discussionDate < cutoff1Date && discussion.upvoteCount < CUTOFF_1_COUNT) ||
|
const discussionCreatedDate = new Date(discussion.createdAt);
|
||||||
(discussionDate < cutoff2Date && discussion.upvoteCount < CUTOFF_2_COUNT)) {
|
if ((discussionUpdatedDate < cutoff1Date && discussion.upvoteCount < CUTOFF_MAX_COUNT) ||
|
||||||
|
(discussionCreatedDate < cutoff1Date && discussion.upvoteCount < CUTOFF_1_COUNT) ||
|
||||||
|
(discussionCreatedDate < cutoff2Date && discussion.upvoteCount < CUTOFF_2_COUNT) ||
|
||||||
|
(discussionCreatedDate < cutoff3Date && discussion.upvoteCount < CUTOFF_3_COUNT)) {
|
||||||
console.log(`Closing discussion #${discussion.number} (${discussion.id}), last updated at ${discussion.updatedAt} with votes ${discussion.upvoteCount}`);
|
console.log(`Closing discussion #${discussion.number} (${discussion.id}), last updated at ${discussion.updatedAt} with votes ${discussion.upvoteCount}`);
|
||||||
const addCommentMutation = `mutation($discussion:ID!, $body:String!) {
|
const addCommentMutation = `mutation($discussion:ID!, $body:String!) {
|
||||||
addDiscussionComment(input:{discussionId:$discussion, body:$body}) {
|
addDiscussionComment(input:{discussionId:$discussion, body:$body}) {
|
||||||
|
@@ -36,8 +36,9 @@ repos:
|
|||||||
exclude_types:
|
exclude_types:
|
||||||
- pofile
|
- pofile
|
||||||
- json
|
- json
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
||||||
rev: 'v3.1.0'
|
- repo: https://github.com/rbubley/mirrors-prettier
|
||||||
|
rev: 'v3.3.3'
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or:
|
types_or:
|
||||||
@@ -47,7 +48,7 @@ repos:
|
|||||||
exclude: "(^Pipfile\\.lock$)"
|
exclude: "(^Pipfile\\.lock$)"
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: 'v0.5.4'
|
rev: 'v0.6.8'
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
@@ -61,6 +62,8 @@ repos:
|
|||||||
rev: v6.2.1
|
rev: v6.2.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: beautysh
|
- id: beautysh
|
||||||
|
additional_dependencies:
|
||||||
|
- setuptools
|
||||||
args:
|
args:
|
||||||
- "--tab"
|
- "--tab"
|
||||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||||
|
@@ -7,9 +7,9 @@
|
|||||||
"trailingComma": "es5",
|
"trailingComma": "es5",
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": ["index.md", "administration.md"],
|
"files": ["docs/*.md"],
|
||||||
"options": {
|
"options": {
|
||||||
"tabWidth": 4
|
"tabWidth": 4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@@ -1 +1 @@
|
|||||||
3.9.19
|
3.10.15
|
||||||
|
@@ -2,7 +2,7 @@ fix = true
|
|||||||
line-length = 88
|
line-length = 88
|
||||||
respect-gitignore = true
|
respect-gitignore = true
|
||||||
src = ["src"]
|
src = ["src"]
|
||||||
target-version = "py39"
|
target-version = "py310"
|
||||||
output-format = "grouped"
|
output-format = "grouped"
|
||||||
show-fixes = true
|
show-fixes = true
|
||||||
|
|
||||||
|
@@ -11,7 +11,7 @@ If you want to implement something big:
|
|||||||
|
|
||||||
## Python
|
## Python
|
||||||
|
|
||||||
Paperless supports python 3.9 - 3.11 at this time. We format Python code with [ruff](https://docs.astral.sh/ruff/formatter/).
|
Paperless supports python 3.10 - 3.12 at this time. We format Python code with [ruff](https://docs.astral.sh/ruff/formatter/).
|
||||||
|
|
||||||
## Branches
|
## Branches
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ community members. That said, in an effort to keep the repository organized and
|
|||||||
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
|
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
|
||||||
- Discussions with a marked answer will be automatically closed.
|
- Discussions with a marked answer will be automatically closed.
|
||||||
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
|
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
|
||||||
- Feature requests that do not meet the following thresholds will be closed: 5 "up-votes" after 180 days of inactivity or 10 "up-votes" after 365 days.
|
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years.
|
||||||
|
|
||||||
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
|
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
|
||||||
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.
|
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.
|
||||||
|
24
Dockerfile
24
Dockerfile
@@ -18,7 +18,7 @@ ARG PNGX_TAG_VERSION=
|
|||||||
# Add the tag to the environment file if its a tagged dev build
|
# Add the tag to the environment file if its a tagged dev build
|
||||||
RUN set -eux && \
|
RUN set -eux && \
|
||||||
case "${PNGX_TAG_VERSION}" in \
|
case "${PNGX_TAG_VERSION}" in \
|
||||||
dev|fix*|feature*) \
|
dev|beta|fix*|feature*) \
|
||||||
sed -i -E "s/version: '([0-9\.]+)'/version: '\1 #${PNGX_TAG_VERSION}'/g" /src/src-ui/src/environments/environment.prod.ts \
|
sed -i -E "s/version: '([0-9\.]+)'/version: '\1 #${PNGX_TAG_VERSION}'/g" /src/src-ui/src/environments/environment.prod.ts \
|
||||||
;; \
|
;; \
|
||||||
esac
|
esac
|
||||||
@@ -31,7 +31,7 @@ RUN set -eux \
|
|||||||
# Comments:
|
# Comments:
|
||||||
# - pipenv dependencies are not left in the final image
|
# - pipenv dependencies are not left in the final image
|
||||||
# - pipenv can't touch the final image somehow
|
# - pipenv can't touch the final image somehow
|
||||||
FROM --platform=$BUILDPLATFORM docker.io/python:3.11-alpine AS pipenv-base
|
FROM --platform=$BUILDPLATFORM docker.io/python:3.12-alpine AS pipenv-base
|
||||||
|
|
||||||
WORKDIR /usr/src/pipenv
|
WORKDIR /usr/src/pipenv
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ COPY Pipfile* ./
|
|||||||
|
|
||||||
RUN set -eux \
|
RUN set -eux \
|
||||||
&& echo "Installing pipenv" \
|
&& echo "Installing pipenv" \
|
||||||
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2024.0.1 \
|
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2024.0.3 \
|
||||||
&& echo "Generating requirement.txt" \
|
&& echo "Generating requirement.txt" \
|
||||||
&& pipenv requirements > requirements.txt
|
&& pipenv requirements > requirements.txt
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ RUN set -eux \
|
|||||||
# Purpose: The final image
|
# Purpose: The final image
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here
|
# - Don't leave anything extra in here
|
||||||
FROM docker.io/python:3.11-slim-bookworm AS main-app
|
FROM docker.io/python:3.12-slim-bookworm AS main-app
|
||||||
|
|
||||||
LABEL org.opencontainers.image.authors="paperless-ngx team <hello@paperless-ngx.com>"
|
LABEL org.opencontainers.image.authors="paperless-ngx team <hello@paperless-ngx.com>"
|
||||||
LABEL org.opencontainers.image.documentation="https://docs.paperless-ngx.com/"
|
LABEL org.opencontainers.image.documentation="https://docs.paperless-ngx.com/"
|
||||||
@@ -233,20 +233,16 @@ RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \
|
|||||||
&& python3 -m pip install --no-cache-dir --upgrade wheel \
|
&& python3 -m pip install --no-cache-dir --upgrade wheel \
|
||||||
&& echo "Installing Python requirements" \
|
&& echo "Installing Python requirements" \
|
||||||
&& curl --fail --silent --show-error --location \
|
&& curl --fail --silent --show-error --location \
|
||||||
--output psycopg_c-3.2.1-cp311-cp311-linux_x86_64.whl \
|
--output psycopg_c-3.2.2-cp312-cp312-linux_x86_64.whl \
|
||||||
https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.1/psycopg_c-3.2.1-cp311-cp311-linux_x86_64.whl \
|
https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.2/psycopg_c-3.2.2-cp312-cp312-linux_x86_64.whl \
|
||||||
&& curl --fail --silent --show-error --location \
|
&& curl --fail --silent --show-error --location \
|
||||||
--output psycopg_c-3.2.1-cp311-cp311-linux_aarch64.whl \
|
--output psycopg_c-3.2.2-cp312-cp312-linux_aarch64.whl \
|
||||||
https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.1/psycopg_c-3.2.1-cp311-cp311-linux_aarch64.whl \
|
https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.2/psycopg_c-3.2.2-cp312-cp312-linux_aarch64.whl \
|
||||||
&& python3 -m pip install --default-timeout=1000 --find-links . --requirement requirements.txt \
|
&& python3 -m pip install --default-timeout=1000 --find-links . --requirement requirements.txt \
|
||||||
&& echo "Patching whitenoise for compression speedup" \
|
|
||||||
&& curl --fail --silent --show-error --location --output 484.patch https://github.com/evansd/whitenoise/pull/484.patch \
|
|
||||||
&& patch -d /usr/local/lib/python3.11/site-packages --verbose -p2 < 484.patch \
|
|
||||||
&& rm 484.patch \
|
|
||||||
&& echo "Installing NLTK data" \
|
&& echo "Installing NLTK data" \
|
||||||
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \
|
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \
|
||||||
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \
|
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \
|
||||||
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" punkt \
|
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" punkt_tab \
|
||||||
&& echo "Cleaning up image" \
|
&& echo "Cleaning up image" \
|
||||||
&& apt-get --yes purge ${BUILD_PACKAGES} \
|
&& apt-get --yes purge ${BUILD_PACKAGES} \
|
||||||
&& apt-get --yes autoremove --purge \
|
&& apt-get --yes autoremove --purge \
|
||||||
@@ -275,6 +271,8 @@ RUN set -eux \
|
|||||||
&& mkdir --parents --verbose /usr/src/paperless/media \
|
&& mkdir --parents --verbose /usr/src/paperless/media \
|
||||||
&& mkdir --parents --verbose /usr/src/paperless/consume \
|
&& mkdir --parents --verbose /usr/src/paperless/consume \
|
||||||
&& mkdir --parents --verbose /usr/src/paperless/export \
|
&& mkdir --parents --verbose /usr/src/paperless/export \
|
||||||
|
&& echo "Creating gnupg directory" \
|
||||||
|
&& mkdir -m700 --verbose /usr/src/paperless/.gnupg \
|
||||||
&& echo "Adjusting all permissions" \
|
&& echo "Adjusting all permissions" \
|
||||||
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
|
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
|
||||||
&& echo "Collecting static files" \
|
&& echo "Collecting static files" \
|
||||||
|
13
Pipfile
13
Pipfile
@@ -7,14 +7,14 @@ name = "pypi"
|
|||||||
dateparser = "~=1.2"
|
dateparser = "~=1.2"
|
||||||
# WARNING: django does not use semver.
|
# WARNING: django does not use semver.
|
||||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||||
django = "~=4.2.14"
|
django = "~=5.1.1"
|
||||||
django-allauth = {extras = ["socialaccount"], version = "*"}
|
django-allauth = {extras = ["socialaccount"], version = "*"}
|
||||||
django-auditlog = "*"
|
django-auditlog = "*"
|
||||||
django-celery-results = "*"
|
django-celery-results = "*"
|
||||||
django-compression-middleware = "*"
|
django-compression-middleware = "*"
|
||||||
django-cors-headers = "*"
|
django-cors-headers = "*"
|
||||||
django-extensions = "*"
|
django-extensions = "*"
|
||||||
django-filter = "~=24.2"
|
django-filter = "~=24.3"
|
||||||
django-guardian = "*"
|
django-guardian = "*"
|
||||||
django-multiselectfield = "*"
|
django-multiselectfield = "*"
|
||||||
django-soft-delete = "*"
|
django-soft-delete = "*"
|
||||||
@@ -30,12 +30,14 @@ filelock = "*"
|
|||||||
flower = "*"
|
flower = "*"
|
||||||
gotenberg-client = "*"
|
gotenberg-client = "*"
|
||||||
gunicorn = "*"
|
gunicorn = "*"
|
||||||
|
httpx-oauth = "*"
|
||||||
imap-tools = "*"
|
imap-tools = "*"
|
||||||
inotifyrecursive = "~=0.3"
|
inotifyrecursive = "~=0.3"
|
||||||
|
jinja2 = "~=3.1"
|
||||||
langdetect = "*"
|
langdetect = "*"
|
||||||
mysqlclient = "*"
|
mysqlclient = "*"
|
||||||
nltk = "*"
|
nltk = "*"
|
||||||
ocrmypdf = "~=15.4"
|
ocrmypdf = "~=16.5"
|
||||||
pathvalidate = "*"
|
pathvalidate = "*"
|
||||||
pdf2image = "*"
|
pdf2image = "*"
|
||||||
psycopg = {version = "*", extras = ["c"]}
|
psycopg = {version = "*", extras = ["c"]}
|
||||||
@@ -54,16 +56,17 @@ tqdm = "*"
|
|||||||
# See https://github.com/paperless-ngx/paperless-ngx/issues/5494
|
# See https://github.com/paperless-ngx/paperless-ngx/issues/5494
|
||||||
uvicorn = {extras = ["standard"], version = "==0.25.0"}
|
uvicorn = {extras = ["standard"], version = "==0.25.0"}
|
||||||
watchdog = "~=4.0"
|
watchdog = "~=4.0"
|
||||||
whitenoise = "~=6.7"
|
whitenoise = "~=6.8"
|
||||||
whoosh = "~=2.7"
|
whoosh = "~=2.7"
|
||||||
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
||||||
|
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
# Linting
|
# Linting
|
||||||
pre-commit = "*"
|
pre-commit = "*"
|
||||||
ruff = "*"
|
ruff = "*"
|
||||||
# Testing
|
|
||||||
factory-boy = "*"
|
factory-boy = "*"
|
||||||
|
# Testing
|
||||||
pytest = "*"
|
pytest = "*"
|
||||||
pytest-cov = "*"
|
pytest-cov = "*"
|
||||||
pytest-django = "*"
|
pytest-django = "*"
|
||||||
|
3951
Pipfile.lock
generated
3951
Pipfile.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,7 @@ Thanks to the generous folks at [DigitalOcean](https://m.do.co/c/8d70b916d462),
|
|||||||
<a href="https://m.do.co/c/8d70b916d462" style="padding-top: 4px; display: block;">
|
<a href="https://m.do.co/c/8d70b916d462" style="padding-top: 4px; display: block;">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_white.svg" width="140px">
|
<source media="(prefers-color-scheme: dark)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_white.svg" width="140px">
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
|
<source media="(prefers-color-scheme: light)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="140px">
|
||||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
|
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
@@ -122,27 +122,38 @@ install_languages() {
|
|||||||
if [ ${#langs[@]} -eq 0 ]; then
|
if [ ${#langs[@]} -eq 0 ]; then
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
apt-get update
|
|
||||||
|
|
||||||
|
# Build list of packages to install
|
||||||
|
to_install=()
|
||||||
for lang in "${langs[@]}"; do
|
for lang in "${langs[@]}"; do
|
||||||
pkg="tesseract-ocr-$lang"
|
pkg="tesseract-ocr-$lang"
|
||||||
|
|
||||||
if dpkg --status "$pkg" &>/dev/null; then
|
if dpkg --status "$pkg" &>/dev/null; then
|
||||||
echo "Package $pkg already installed!"
|
echo "Package $pkg already installed!"
|
||||||
continue
|
continue
|
||||||
fi
|
else
|
||||||
|
to_install+=("$pkg")
|
||||||
if ! apt-cache show "$pkg" &>/dev/null; then
|
|
||||||
echo "Package $pkg not found! :("
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Installing package $pkg..."
|
|
||||||
if ! apt-get --assume-yes install "$pkg" &>/dev/null; then
|
|
||||||
echo "Could not install $pkg"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Use apt only when we install packages
|
||||||
|
if [ ${#to_install[@]} -gt 0 ]; then
|
||||||
|
apt-get update
|
||||||
|
|
||||||
|
for pkg in "${to_install[@]}"; do
|
||||||
|
|
||||||
|
if ! apt-cache show "$pkg" &>/dev/null; then
|
||||||
|
echo "Skipped $pkg: Package not found! :("
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing package $pkg..."
|
||||||
|
if ! apt-get --assume-yes install "$pkg" &>/dev/null; then
|
||||||
|
echo "Could not install $pkg"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "Paperless-ngx docker container starting..."
|
echo "Paperless-ngx docker container starting..."
|
||||||
|
@@ -16,7 +16,7 @@ do
|
|||||||
# Check if it starts with "PAPERLESS_" and ends in "_FILE"
|
# Check if it starts with "PAPERLESS_" and ends in "_FILE"
|
||||||
if [[ ${env_name} == PAPERLESS_*_FILE ]]; then
|
if [[ ${env_name} == PAPERLESS_*_FILE ]]; then
|
||||||
# This should have been named different..
|
# This should have been named different..
|
||||||
if [[ ${env_name} == "PAPERLESS_OCR_SKIP_ARCHIVE_FILE" ]]; then
|
if [[ ${env_name} == "PAPERLESS_OCR_SKIP_ARCHIVE_FILE" || ${env_name} == "PAPERLESS_MODEL_FILE" ]]; then
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
# Extract the value of the environment
|
# Extract the value of the environment
|
||||||
|
@@ -14,7 +14,8 @@ for command in decrypt_documents \
|
|||||||
document_thumbnails \
|
document_thumbnails \
|
||||||
document_sanity_checker \
|
document_sanity_checker \
|
||||||
document_fuzzy_match \
|
document_fuzzy_match \
|
||||||
manage_superuser;
|
manage_superuser \
|
||||||
|
convert_mariadb_uuid;
|
||||||
do
|
do
|
||||||
echo "installing $command..."
|
echo "installing $command..."
|
||||||
sed "s/management_command/$command/g" management_script.sh > /usr/local/bin/$command
|
sed "s/management_command/$command/g" management_script.sh > /usr/local/bin/$command
|
||||||
|
@@ -19,6 +19,8 @@ Options available to any installation of paperless:
|
|||||||
export. Therefore, incremental backups with `rsync` are entirely
|
export. Therefore, incremental backups with `rsync` are entirely
|
||||||
possible.
|
possible.
|
||||||
|
|
||||||
|
The exporter does not include API tokens and they will need to be re-generated after importing.
|
||||||
|
|
||||||
!!! caution
|
!!! caution
|
||||||
|
|
||||||
You cannot import the export generated with one version of paperless in
|
You cannot import the export generated with one version of paperless in
|
||||||
@@ -248,6 +250,7 @@ optional arguments:
|
|||||||
-z, --zip
|
-z, --zip
|
||||||
-zn, --zip-name
|
-zn, --zip-name
|
||||||
--data-only
|
--data-only
|
||||||
|
--no-progress-bar
|
||||||
--passphrase
|
--passphrase
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -310,6 +313,10 @@ value set in `-zn` or `--zip-name`.
|
|||||||
If `--data-only` is provided, only the database will be exported. This option is intended
|
If `--data-only` is provided, only the database will be exported. This option is intended
|
||||||
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
|
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
|
||||||
|
|
||||||
|
If `--no-progress-bar` is provided, the progress bar will be hidden, rendering the
|
||||||
|
exporter quiet. This option is useful for scripting scenarios, such as when using the
|
||||||
|
exporter with `crontab`.
|
||||||
|
|
||||||
If `--passphrase` is provided, it will be used to encrypt certain fields in the export. This value
|
If `--passphrase` is provided, it will be used to encrypt certain fields in the export. This value
|
||||||
must be provided to import. If this value is lost, the export cannot be imported.
|
must be provided to import. If this value is lost, the export cannot be imported.
|
||||||
|
|
||||||
@@ -331,11 +338,12 @@ and the script does the rest of the work:
|
|||||||
document_importer source
|
document_importer source
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Required | Default | Description |
|
| Option | Required | Default | Description |
|
||||||
| -------------- | -------- | ------- | ------------------------------------------------------------------------- |
|
| ------------------- | -------- | ------- | ------------------------------------------------------------------------- |
|
||||||
| source | Yes | N/A | The directory containing an export |
|
| source | Yes | N/A | The directory containing an export |
|
||||||
| `--data-only` | No | False | If provided, only import data, do not import document files or thumbnails |
|
| `--no-progress-bar` | No | False | If provided, the progress bar will be hidden |
|
||||||
| `--passphrase` | No | N/A | If your export was encrypted with a passphrase, must be provided |
|
| `--data-only` | No | False | If provided, only import data, do not import document files or thumbnails |
|
||||||
|
| `--passphrase` | No | N/A | If your export was encrypted with a passphrase, must be provided |
|
||||||
|
|
||||||
When you use the provided docker compose script, put the export inside
|
When you use the provided docker compose script, put the export inside
|
||||||
the `export` folder in your paperless source directory. Specify
|
the `export` folder in your paperless source directory. Specify
|
||||||
|
@@ -25,20 +25,20 @@ documents.
|
|||||||
|
|
||||||
The following algorithms are available:
|
The following algorithms are available:
|
||||||
|
|
||||||
- **None:** No matching will be performed.
|
- **None:** No matching will be performed.
|
||||||
- **Any:** Looks for any occurrence of any word provided in match in
|
- **Any:** Looks for any occurrence of any word provided in match in
|
||||||
the PDF. If you define the match as `Bank1 Bank2`, it will match
|
the PDF. If you define the match as `Bank1 Bank2`, it will match
|
||||||
documents containing either of these terms.
|
documents containing either of these terms.
|
||||||
- **All:** Requires that every word provided appears in the PDF,
|
- **All:** Requires that every word provided appears in the PDF,
|
||||||
albeit not in the order provided.
|
albeit not in the order provided.
|
||||||
- **Exact:** Matches only if the match appears exactly as provided
|
- **Exact:** Matches only if the match appears exactly as provided
|
||||||
(i.e. preserve ordering) in the PDF.
|
(i.e. preserve ordering) in the PDF.
|
||||||
- **Regular expression:** Parses the match as a regular expression and
|
- **Regular expression:** Parses the match as a regular expression and
|
||||||
tries to find a match within the document.
|
tries to find a match within the document.
|
||||||
- **Fuzzy match:** Uses a partial matching based on locating the tag text
|
- **Fuzzy match:** Uses a partial matching based on locating the tag text
|
||||||
inside the document, using a [partial ratio](https://rapidfuzz.github.io/RapidFuzz/Usage/fuzz.html#partial-ratio)
|
inside the document, using a [partial ratio](https://rapidfuzz.github.io/RapidFuzz/Usage/fuzz.html#partial-ratio)
|
||||||
- **Auto:** Tries to automatically match new documents. This does not
|
- **Auto:** Tries to automatically match new documents. This does not
|
||||||
require you to set a match. See the [notes below](#automatic-matching).
|
require you to set a match. See the [notes below](#automatic-matching).
|
||||||
|
|
||||||
When using the _any_ or _all_ matching algorithms, you can search for
|
When using the _any_ or _all_ matching algorithms, you can search for
|
||||||
terms that consist of multiple words by enclosing them in double quotes.
|
terms that consist of multiple words by enclosing them in double quotes.
|
||||||
@@ -69,33 +69,33 @@ Paperless tries to hide much of the involved complexity with this
|
|||||||
approach. However, there are a couple caveats you need to keep in mind
|
approach. However, there are a couple caveats you need to keep in mind
|
||||||
when using this feature:
|
when using this feature:
|
||||||
|
|
||||||
- Changes to your documents are not immediately reflected by the
|
- Changes to your documents are not immediately reflected by the
|
||||||
matching algorithm. The neural network needs to be _trained_ on your
|
matching algorithm. The neural network needs to be _trained_ on your
|
||||||
documents after changes. Paperless periodically (default: once each
|
documents after changes. Paperless periodically (default: once each
|
||||||
hour) checks for changes and does this automatically for you.
|
hour) checks for changes and does this automatically for you.
|
||||||
- The Auto matching algorithm only takes documents into account which
|
- The Auto matching algorithm only takes documents into account which
|
||||||
are NOT placed in your inbox (i.e. have any inbox tags assigned to
|
are NOT placed in your inbox (i.e. have any inbox tags assigned to
|
||||||
them). This ensures that the neural network only learns from
|
them). This ensures that the neural network only learns from
|
||||||
documents which you have correctly tagged before.
|
documents which you have correctly tagged before.
|
||||||
- The matching algorithm can only work if there is a correlation
|
- The matching algorithm can only work if there is a correlation
|
||||||
between the tag, correspondent, document type, or storage path and
|
between the tag, correspondent, document type, or storage path and
|
||||||
the document itself. Your bank statements usually contain your bank
|
the document itself. Your bank statements usually contain your bank
|
||||||
account number and the name of the bank, so this works reasonably
|
account number and the name of the bank, so this works reasonably
|
||||||
well, However, tags such as "TODO" cannot be automatically
|
well, However, tags such as "TODO" cannot be automatically
|
||||||
assigned.
|
assigned.
|
||||||
- The matching algorithm needs a reasonable number of documents to
|
- The matching algorithm needs a reasonable number of documents to
|
||||||
identify when to assign tags, correspondents, storage paths, and
|
identify when to assign tags, correspondents, storage paths, and
|
||||||
types. If one out of a thousand documents has the correspondent
|
types. If one out of a thousand documents has the correspondent
|
||||||
"Very obscure web shop I bought something five years ago", it will
|
"Very obscure web shop I bought something five years ago", it will
|
||||||
probably not assign this correspondent automatically if you buy
|
probably not assign this correspondent automatically if you buy
|
||||||
something from them again. The more documents, the better.
|
something from them again. The more documents, the better.
|
||||||
- Paperless also needs a reasonable amount of negative examples to
|
- Paperless also needs a reasonable amount of negative examples to
|
||||||
decide when not to assign a certain tag, correspondent, document
|
decide when not to assign a certain tag, correspondent, document
|
||||||
type, or storage path. This will usually be the case as you start
|
type, or storage path. This will usually be the case as you start
|
||||||
filling up paperless with documents. Example: If all your documents
|
filling up paperless with documents. Example: If all your documents
|
||||||
are either from "Webshop" or "Bank", paperless will assign one
|
are either from "Webshop" or "Bank", paperless will assign one
|
||||||
of these correspondents to ANY new document, if both are set to
|
of these correspondents to ANY new document, if both are set to
|
||||||
automatic matching.
|
automatic matching.
|
||||||
|
|
||||||
## Hooking into the consumption process {#consume-hooks}
|
## Hooking into the consumption process {#consume-hooks}
|
||||||
|
|
||||||
@@ -242,12 +242,12 @@ webserver:
|
|||||||
|
|
||||||
Troubleshooting:
|
Troubleshooting:
|
||||||
|
|
||||||
- Monitor the Docker Compose log
|
- Monitor the Docker Compose log
|
||||||
`cd ~/paperless-ngx; docker compose logs -f`
|
`cd ~/paperless-ngx; docker compose logs -f`
|
||||||
- Check your script's permission e.g. in case of permission error
|
- Check your script's permission e.g. in case of permission error
|
||||||
`sudo chmod 755 post-consumption-example.sh`
|
`sudo chmod 755 post-consumption-example.sh`
|
||||||
- Pipe your scripts's output to a log file e.g.
|
- Pipe your scripts's output to a log file e.g.
|
||||||
`echo "${DOCUMENT_ID}" | tee --append /usr/src/paperless/scripts/post-consumption-example.log`
|
`echo "${DOCUMENT_ID}" | tee --append /usr/src/paperless/scripts/post-consumption-example.log`
|
||||||
|
|
||||||
## File name handling {#file-name-handling}
|
## File name handling {#file-name-handling}
|
||||||
|
|
||||||
@@ -265,7 +265,7 @@ This variable allows you to configure the filename (folders are allowed)
|
|||||||
using placeholders. For example, configuring this to
|
using placeholders. For example, configuring this to
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PAPERLESS_FILENAME_FORMAT={created_year}/{correspondent}/{title}
|
PAPERLESS_FILENAME_FORMAT={{ created_year }}/{{ correspondent }}/{{ title }}
|
||||||
```
|
```
|
||||||
|
|
||||||
will create a directory structure as follows:
|
will create a directory structure as follows:
|
||||||
@@ -298,39 +298,39 @@ will create a directory structure as follows:
|
|||||||
when changing `PAPERLESS_FILENAME_FORMAT` you will need to manually run the
|
when changing `PAPERLESS_FILENAME_FORMAT` you will need to manually run the
|
||||||
[`document renamer`](administration.md#renamer) to move any existing documents.
|
[`document renamer`](administration.md#renamer) to move any existing documents.
|
||||||
|
|
||||||
#### Placeholders
|
### Placeholders {#filename-format-variables}
|
||||||
|
|
||||||
Paperless provides the following placeholders within filenames:
|
Paperless provides the following variables for use within filenames:
|
||||||
|
|
||||||
- `{asn}`: The archive serial number of the document, or "none".
|
- `{{ asn }}`: The archive serial number of the document, or "none".
|
||||||
- `{correspondent}`: The name of the correspondent, or "none".
|
- `{{ correspondent }}`: The name of the correspondent, or "none".
|
||||||
- `{document_type}`: The name of the document type, or "none".
|
- `{{ document_type }}`: The name of the document type, or "none".
|
||||||
- `{tag_list}`: A comma separated list of all tags assigned to the
|
- `{{ tag_list }}`: A comma separated list of all tags assigned to the
|
||||||
document.
|
document.
|
||||||
- `{title}`: The title of the document.
|
- `{{ title }}`: The title of the document.
|
||||||
- `{created}`: The full date (ISO format) the document was created.
|
- `{{ created }}`: The full date (ISO format) the document was created.
|
||||||
- `{created_year}`: Year created only, formatted as the year with
|
- `{{ created_year }}`: Year created only, formatted as the year with
|
||||||
century.
|
century.
|
||||||
- `{created_year_short}`: Year created only, formatted as the year
|
- `{{ created_year_short }}`: Year created only, formatted as the year
|
||||||
without century, zero padded.
|
without century, zero padded.
|
||||||
- `{created_month}`: Month created only (number 01-12).
|
- `{{ created_month }}`: Month created only (number 01-12).
|
||||||
- `{created_month_name}`: Month created name, as per locale
|
- `{{ created_month_name }}`: Month created name, as per locale
|
||||||
- `{created_month_name_short}`: Month created abbreviated name, as per
|
- `{{ created_month_name_short }}`: Month created abbreviated name, as per
|
||||||
locale
|
locale
|
||||||
- `{created_day}`: Day created only (number 01-31).
|
- `{{ created_day }}`: Day created only (number 01-31).
|
||||||
- `{added}`: The full date (ISO format) the document was added to
|
- `{{ added }}`: The full date (ISO format) the document was added to
|
||||||
paperless.
|
paperless.
|
||||||
- `{added_year}`: Year added only.
|
- `{{ added_year }}`: Year added only.
|
||||||
- `{added_year_short}`: Year added only, formatted as the year without
|
- `{{ added_year_short }}`: Year added only, formatted as the year without
|
||||||
century, zero padded.
|
century, zero padded.
|
||||||
- `{added_month}`: Month added only (number 01-12).
|
- `{{ added_month }}`: Month added only (number 01-12).
|
||||||
- `{added_month_name}`: Month added name, as per locale
|
- `{{ added_month_name }}`: Month added name, as per locale
|
||||||
- `{added_month_name_short}`: Month added abbreviated name, as per
|
- `{{ added_month_name_short }}`: Month added abbreviated name, as per
|
||||||
locale
|
locale
|
||||||
- `{added_day}`: Day added only (number 01-31).
|
- `{{ added_day }}`: Day added only (number 01-31).
|
||||||
- `{owner_username}`: Username of document owner, if any, or "none"
|
- `{{ owner_username }}`: Username of document owner, if any, or "none"
|
||||||
- `{original_name}`: Document original filename, minus the extension, if any, or "none"
|
- `{{ original_name }}`: Document original filename, minus the extension, if any, or "none"
|
||||||
- `{doc_pk}`: The paperless identifier (primary key) for the document.
|
- `{{ doc_pk }}`: The paperless identifier (primary key) for the document.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
@@ -338,6 +338,11 @@ Paperless provides the following placeholders within filenames:
|
|||||||
you may run into the limits of your operating system's maximum path lengths.
|
you may run into the limits of your operating system's maximum path lengths.
|
||||||
In that case, files will retain the previous path instead and the issue logged.
|
In that case, files will retain the previous path instead and the issue logged.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
|
||||||
|
These variables are all simple strings, but the format can be a full template.
|
||||||
|
See [Filename Templates](#filename-templates) for even more advanced formatting.
|
||||||
|
|
||||||
Paperless will try to conserve the information from your database as
|
Paperless will try to conserve the information from your database as
|
||||||
much as possible. However, some characters that you can use in document
|
much as possible. However, some characters that you can use in document
|
||||||
titles and correspondent names (such as `: \ /` and a couple more) are
|
titles and correspondent names (such as `: \ /` and a couple more) are
|
||||||
@@ -363,7 +368,7 @@ paperless will fall back to using the default naming scheme instead.
|
|||||||
However, keep in mind that inside docker, if files get stored outside of
|
However, keep in mind that inside docker, if files get stored outside of
|
||||||
the predefined volumes, they will be lost after a restart.
|
the predefined volumes, they will be lost after a restart.
|
||||||
|
|
||||||
##### Empty placeholders
|
#### Empty placeholders
|
||||||
|
|
||||||
You can affect how empty placeholders are treated by changing the
|
You can affect how empty placeholders are treated by changing the
|
||||||
[`PAPERLESS_FILENAME_FORMAT_REMOVE_NONE`](configuration.md#PAPERLESS_FILENAME_FORMAT_REMOVE_NONE) setting.
|
[`PAPERLESS_FILENAME_FORMAT_REMOVE_NONE`](configuration.md#PAPERLESS_FILENAME_FORMAT_REMOVE_NONE) setting.
|
||||||
@@ -376,10 +381,10 @@ before empty placeholders are removed as well, empty directories are omitted.
|
|||||||
When a single storage layout is not sufficient for your use case, storage paths allow for more complex
|
When a single storage layout is not sufficient for your use case, storage paths allow for more complex
|
||||||
structure to set precisely where each document is stored in the file system.
|
structure to set precisely where each document is stored in the file system.
|
||||||
|
|
||||||
- Each storage path is a [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) and
|
- Each storage path is a [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) and
|
||||||
follows the rules described above
|
follows the rules described above
|
||||||
- Each document is assigned a storage path using the matching algorithms described above, but can be
|
- Each document is assigned a storage path using the matching algorithms described above, but can be
|
||||||
overwritten at any time
|
overwritten at any time
|
||||||
|
|
||||||
For example, you could define the following two storage paths:
|
For example, you could define the following two storage paths:
|
||||||
|
|
||||||
@@ -390,8 +395,8 @@ For example, you could define the following two storage paths:
|
|||||||
the correspondence.
|
the correspondence.
|
||||||
|
|
||||||
```
|
```
|
||||||
By Year = {created_year}/{correspondent}/{title}
|
By Year = {{ created_year }}/{{ correspondent }}/{{ title }}
|
||||||
Insurances = Insurances/{correspondent}/{created_year}-{created_month}-{created_day} {title}
|
Insurances = Insurances/{{ correspondent }}/{{ created_year }}-{{ created_month }}-{{ created_day }} {{ title }}
|
||||||
```
|
```
|
||||||
|
|
||||||
If you then map these storage paths to the documents, you might get the
|
If you then map these storage paths to the documents, you might get the
|
||||||
@@ -418,6 +423,97 @@ Insurances/ # Insurances
|
|||||||
Defining a storage path is optional. If no storage path is defined for a
|
Defining a storage path is optional. If no storage path is defined for a
|
||||||
document, the global [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) is applied.
|
document, the global [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) is applied.
|
||||||
|
|
||||||
|
### Filename Templates {#filename-templates}
|
||||||
|
|
||||||
|
The filename formatting uses [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/) to build the filename.
|
||||||
|
This allows for complex logic to be included in the format, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
|
||||||
|
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11) to manipulate the [variables](#filename-format-variables)
|
||||||
|
provided. The template is provided as a string, potentially multiline, and rendered into a single line.
|
||||||
|
|
||||||
|
In addition, the entire Document instance is available to be utilized in a more advanced way, as well as some variables which only make sense to be accessed
|
||||||
|
with more complex logic.
|
||||||
|
|
||||||
|
#### Additional Variables
|
||||||
|
|
||||||
|
- `{{ tag_name_list }}`: A list of tag names applied to the document, ordered by the tag name. Note this is a list, not a single string
|
||||||
|
- `{{ custom_fields }}`: A mapping of custom field names to their type and value. A user can access the mapping by field name or check if a field is applied by checking its existence in the variable.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
|
||||||
|
To access a custom field which has a space in the name, use the `get_cf_value` filter. See the examples below.
|
||||||
|
This helps get fields by name and handle a default value if the named field is not attached to a Document.
|
||||||
|
|
||||||
|
#### Examples
|
||||||
|
|
||||||
|
This example will construct a path based on the archive serial number range:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
somepath/
|
||||||
|
{% if document.archive_serial_number >= 0 and document.archive_serial_number <= 200 %}
|
||||||
|
asn-000-200/{{title}}
|
||||||
|
{% elif document.archive_serial_number >= 201 and document.archive_serial_number <= 400 %}
|
||||||
|
asn-201-400
|
||||||
|
{% if document.archive_serial_number >= 201 and document.archive_serial_number < 300 %}
|
||||||
|
/asn-2xx
|
||||||
|
{% elif document.archive_serial_number >= 300 and document.archive_serial_number < 400 %}
|
||||||
|
/asn-3xx
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
/{{ title }}
|
||||||
|
```
|
||||||
|
|
||||||
|
For a document with an ASN of 205, it would result in `somepath/asn-201-400/asn-2xx/Title.pdf`, but
|
||||||
|
a document with an ASN of 355 would be placed in `somepath/asn-201-400/asn-3xx/Title.pdf`.
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
{% if document.mime_type == "application/pdf" %}
|
||||||
|
pdfs
|
||||||
|
{% elif document.mime_type == "image/png" %}
|
||||||
|
pngs
|
||||||
|
{% else %}
|
||||||
|
others
|
||||||
|
{% endif %}
|
||||||
|
/{{ title }}
|
||||||
|
```
|
||||||
|
|
||||||
|
For a PDF document, it would result in `pdfs/Title.pdf`, but for a PNG document, the path would be `pngs/Title.pdf`.
|
||||||
|
|
||||||
|
To use custom fields:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
{% if "Invoice" in custom_fields %}
|
||||||
|
invoices/{{ custom_fields.Invoice.value }}
|
||||||
|
{% else %}
|
||||||
|
not-invoices/{{ title }}
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the document has a custom field named "Invoice" with a value of 123, it would be filed into the `invoices/123.pdf`, but a document without the custom field
|
||||||
|
would be filed to `not-invoices/Title.pdf`
|
||||||
|
|
||||||
|
If the custom field is named "Invoice Number", you would access the value of it via the `get_cf_value` filter due to quirks of the Django Template Language:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
"invoices/{{ custom_fields|get_cf_value('Invoice Number') }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also use a custom `datetime` filter to format dates:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
invoices/
|
||||||
|
{{ custom_fields|get_cf_value("Date Field","2024-01-01")|datetime('%Y') }}/
|
||||||
|
{{ custom_fields|get_cf_value("Date Field","2024-01-01")|datetime('%m') }}/
|
||||||
|
{{ custom_fields|get_cf_value("Date Field","2024-01-01")|datetime('%d') }}/
|
||||||
|
Invoice_{{ custom_fields|get_cf_value("Select Field") }}_{{ custom_fields|get_cf_value("Date Field","2024-01-01")|replace("-", "") }}.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create a path like `invoices/2022/01/01/Invoice_OptionTwo_20220101.pdf` if the custom field "Date Field" is set to January 1, 2022 and "Select Field" is set to `OptionTwo`.
|
||||||
|
|
||||||
|
## Automatic recovery of invalid PDFs {#pdf-recovery}
|
||||||
|
|
||||||
|
Paperless will attempt to "clean" certain invalid PDFs with `qpdf` before processing if, for example, the mime_type
|
||||||
|
detection is incorrect. This can happen if the PDF is not properly formatted or contains errors.
|
||||||
|
|
||||||
## Celery Monitoring {#celery-monitoring}
|
## Celery Monitoring {#celery-monitoring}
|
||||||
|
|
||||||
The monitoring tool
|
The monitoring tool
|
||||||
@@ -436,15 +532,15 @@ installation, you can use volumes to accomplish this:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
# ...
|
|
||||||
webserver:
|
|
||||||
environment:
|
|
||||||
- PAPERLESS_ENABLE_FLOWER
|
|
||||||
ports:
|
|
||||||
- 5555:5555 # (2)!
|
|
||||||
# ...
|
# ...
|
||||||
volumes:
|
webserver:
|
||||||
- /path/to/my/flowerconfig.py:/usr/src/paperless/src/paperless/flowerconfig.py:ro # (1)!
|
environment:
|
||||||
|
- PAPERLESS_ENABLE_FLOWER
|
||||||
|
ports:
|
||||||
|
- 5555:5555 # (2)!
|
||||||
|
# ...
|
||||||
|
volumes:
|
||||||
|
- /path/to/my/flowerconfig.py:/usr/src/paperless/src/paperless/flowerconfig.py:ro # (1)!
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Note the `:ro` tag means the file will be mounted as read only.
|
1. Note the `:ro` tag means the file will be mounted as read only.
|
||||||
@@ -475,11 +571,11 @@ For example, using Docker Compose:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
# ...
|
|
||||||
webserver:
|
|
||||||
# ...
|
# ...
|
||||||
volumes:
|
webserver:
|
||||||
- /path/to/my/scripts:/custom-cont-init.d:ro # (1)!
|
# ...
|
||||||
|
volumes:
|
||||||
|
- /path/to/my/scripts:/custom-cont-init.d:ro # (1)!
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Note the `:ro` tag means the folder will be mounted as read only. This is for extra security against changes
|
1. Note the `:ro` tag means the folder will be mounted as read only. This is for extra security against changes
|
||||||
@@ -527,16 +623,16 @@ 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:
|
At this time, the library utilized for detection of barcodes supports the following types:
|
||||||
|
|
||||||
- AN-13/UPC-A
|
- AN-13/UPC-A
|
||||||
- UPC-E
|
- UPC-E
|
||||||
- EAN-8
|
- EAN-8
|
||||||
- Code 128
|
- Code 128
|
||||||
- Code 93
|
- Code 93
|
||||||
- Code 39
|
- Code 39
|
||||||
- Codabar
|
- Codabar
|
||||||
- Interleaved 2 of 5
|
- Interleaved 2 of 5
|
||||||
- QR Code
|
- QR Code
|
||||||
- SQ Code
|
- SQ Code
|
||||||
|
|
||||||
You may check for updates on the [zbar library homepage](https://github.com/mchehab/zbar).
|
You may check for updates on the [zbar library homepage](https://github.com/mchehab/zbar).
|
||||||
For usage in Paperless, the type of barcode does not matter, only the contents of it.
|
For usage in Paperless, the type of barcode does not matter, only the contents of it.
|
||||||
@@ -690,3 +786,57 @@ More details about configuration option for various providers can be found in th
|
|||||||
|
|
||||||
Once external auth is set up, 'regular' login can be disabled with the [PAPERLESS_DISABLE_REGULAR_LOGIN](configuration.md#PAPERLESS_DISABLE_REGULAR_LOGIN) setting and / or users can be automatically
|
Once external auth is set up, 'regular' login can be disabled with the [PAPERLESS_DISABLE_REGULAR_LOGIN](configuration.md#PAPERLESS_DISABLE_REGULAR_LOGIN) setting and / or users can be automatically
|
||||||
redirected with the [PAPERLESS_REDIRECT_LOGIN_TO_SSO](configuration.md#PAPERLESS_REDIRECT_LOGIN_TO_SSO) setting.
|
redirected with the [PAPERLESS_REDIRECT_LOGIN_TO_SSO](configuration.md#PAPERLESS_REDIRECT_LOGIN_TO_SSO) setting.
|
||||||
|
|
||||||
|
## Decryption of encrypted emails before consumption {#gpg-decryptor}
|
||||||
|
|
||||||
|
Paperless-ngx can be configured to decrypt gpg encrypted emails before consumption.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
You need a recent version of `gpg-agent >= 2.1.1` installed on your host.
|
||||||
|
Your host needs to be setup for decrypting your emails via `gpg-agent`, see this [tutorial](https://www.digitalocean.com/community/tutorials/how-to-use-gpg-to-encrypt-and-sign-messages#encrypt-and-decrypt-messages-with-gpg) for instance.
|
||||||
|
Test your setup and make sure that you can encrypt and decrypt files using your key
|
||||||
|
|
||||||
|
```
|
||||||
|
gpg --encrypt --armor -r person@email.com name_of_file
|
||||||
|
gpg --decrypt name_of_file.asc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
First, enable the [PAPERLESS_ENABLE_GPG_DECRYPTOR environment variable](configuration.md#PAPERLESS_ENABLE_GPG_DECRYPTOR).
|
||||||
|
|
||||||
|
Then determine your local `gpg-agent.extra` socket by invoking
|
||||||
|
|
||||||
|
```
|
||||||
|
gpgconf --list-dir agent-extra-socket
|
||||||
|
```
|
||||||
|
|
||||||
|
on your host. A possible output is `~/.gnupg/S.gpg-agent.extra`.
|
||||||
|
Also find the location of your public keyring.
|
||||||
|
|
||||||
|
If using docker, you'll need to add the following volume mounts to your `docker-compose.yml` file:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
webserver:
|
||||||
|
volumes:
|
||||||
|
- /home/user/.gnupg/pubring.gpg:/usr/src/paperless/.gnupg/pubring.gpg
|
||||||
|
- <path to gpg-agent.extra socket>:/usr/src/paperless/.gnupg/S.gpg-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
For a 'bare-metal' installation no further configuration is necessary. If you
|
||||||
|
want to use a separate `GNUPG_HOME`, you can do so by configuring the [PAPERLESS_EMAIL_GNUPG_HOME environment variable](configuration.md#PAPERLESS_EMAIL_GNUPG_HOME).
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
- Make sure, that `gpg-agent` is running on your host machine
|
||||||
|
- Make sure, that encryption and decryption works from inside the container using the `gpg` commands from above.
|
||||||
|
- Check that all files in `/usr/src/paperless/.gnupg` have correct permissions
|
||||||
|
|
||||||
|
```shell
|
||||||
|
paperless@9da1865df327:~/.gnupg$ ls -al
|
||||||
|
drwx------ 1 paperless paperless 4096 Aug 18 17:52 .
|
||||||
|
drwxr-xr-x 1 paperless paperless 4096 Aug 18 17:52 ..
|
||||||
|
srw------- 1 paperless paperless 0 Aug 18 17:22 S.gpg-agent
|
||||||
|
-rw------- 1 paperless paperless 147940 Jul 24 10:23 pubring.gpg
|
||||||
|
```
|
||||||
|
368
docs/api.md
368
docs/api.md
@@ -8,23 +8,23 @@ most of the available filters and ordering fields.
|
|||||||
|
|
||||||
The API provides the following main endpoints:
|
The API provides the following main endpoints:
|
||||||
|
|
||||||
- `/api/correspondents/`: Full CRUD support.
|
- `/api/correspondents/`: Full CRUD support.
|
||||||
- `/api/custom_fields/`: Full CRUD support.
|
- `/api/custom_fields/`: Full CRUD support.
|
||||||
- `/api/documents/`: Full CRUD support, except POSTing new documents.
|
- `/api/documents/`: Full CRUD support, except POSTing new documents.
|
||||||
See [below](#file-uploads).
|
See [below](#file-uploads).
|
||||||
- `/api/document_types/`: Full CRUD support.
|
- `/api/document_types/`: Full CRUD support.
|
||||||
- `/api/groups/`: Full CRUD support.
|
- `/api/groups/`: Full CRUD support.
|
||||||
- `/api/logs/`: Read-Only.
|
- `/api/logs/`: Read-Only.
|
||||||
- `/api/mail_accounts/`: Full CRUD support.
|
- `/api/mail_accounts/`: Full CRUD support.
|
||||||
- `/api/mail_rules/`: Full CRUD support.
|
- `/api/mail_rules/`: Full CRUD support.
|
||||||
- `/api/profile/`: GET, PATCH
|
- `/api/profile/`: GET, PATCH
|
||||||
- `/api/share_links/`: Full CRUD support.
|
- `/api/share_links/`: Full CRUD support.
|
||||||
- `/api/storage_paths/`: Full CRUD support.
|
- `/api/storage_paths/`: Full CRUD support.
|
||||||
- `/api/tags/`: Full CRUD support.
|
- `/api/tags/`: Full CRUD support.
|
||||||
- `/api/tasks/`: Read-only.
|
- `/api/tasks/`: Read-only.
|
||||||
- `/api/users/`: Full CRUD support.
|
- `/api/users/`: Full CRUD support.
|
||||||
- `/api/workflows/`: Full CRUD support.
|
- `/api/workflows/`: Full CRUD support.
|
||||||
- `/api/search/` GET, see [below](#global-search).
|
- `/api/search/` GET, see [below](#global-search).
|
||||||
|
|
||||||
All of these endpoints except for the logging endpoint allow you to
|
All of these endpoints except for the logging endpoint allow you to
|
||||||
fetch (and edit and delete where appropriate) individual objects by
|
fetch (and edit and delete where appropriate) individual objects by
|
||||||
@@ -33,31 +33,32 @@ appending their primary key to the path, e.g. `/api/documents/454/`.
|
|||||||
The objects served by the document endpoint contain the following
|
The objects served by the document endpoint contain the following
|
||||||
fields:
|
fields:
|
||||||
|
|
||||||
- `id`: ID of the document. Read-only.
|
- `id`: ID of the document. Read-only.
|
||||||
- `title`: Title of the document.
|
- `title`: Title of the document.
|
||||||
- `content`: Plain text content of the document.
|
- `content`: Plain text content of the document.
|
||||||
- `tags`: List of IDs of tags assigned to this document, or empty
|
- `tags`: List of IDs of tags assigned to this document, or empty
|
||||||
list.
|
list.
|
||||||
- `document_type`: Document type of this document, or null.
|
- `document_type`: Document type of this document, or null.
|
||||||
- `correspondent`: Correspondent of this document or null.
|
- `correspondent`: Correspondent of this document or null.
|
||||||
- `created`: The date time at which this document was created.
|
- `created`: The date time at which this document was created.
|
||||||
- `created_date`: The date (YYYY-MM-DD) at which this document was
|
- `created_date`: The date (YYYY-MM-DD) at which this document was
|
||||||
created. Optional. If also passed with created, this is ignored.
|
created. Optional. If also passed with created, this is ignored.
|
||||||
- `modified`: The date at which this document was last edited in
|
- `modified`: The date at which this document was last edited in
|
||||||
paperless. Read-only.
|
paperless. Read-only.
|
||||||
- `added`: The date at which this document was added to paperless.
|
- `added`: The date at which this document was added to paperless.
|
||||||
Read-only.
|
Read-only.
|
||||||
- `archive_serial_number`: The identifier of this document in a
|
- `archive_serial_number`: The identifier of this document in a
|
||||||
physical document archive.
|
physical document archive.
|
||||||
- `original_file_name`: Verbose filename of the original document.
|
- `original_file_name`: Verbose filename of the original document.
|
||||||
Read-only.
|
Read-only.
|
||||||
- `archived_file_name`: Verbose filename of the archived document.
|
- `archived_file_name`: Verbose filename of the archived document.
|
||||||
Read-only. Null if no archived document is available.
|
Read-only. Null if no archived document is available.
|
||||||
- `notes`: Array of notes associated with the document.
|
- `notes`: Array of notes associated with the document.
|
||||||
- `set_permissions`: Allows setting document permissions. Optional,
|
- `page_count`: Number of pages.
|
||||||
write-only. See [below](#permissions).
|
- `set_permissions`: Allows setting document permissions. Optional,
|
||||||
- `custom_fields`: Array of custom fields & values, specified as
|
write-only. See [below](#permissions).
|
||||||
`{ field: CUSTOM_FIELD_ID, value: VALUE }`
|
- `custom_fields`: Array of custom fields & values, specified as
|
||||||
|
`{ field: CUSTOM_FIELD_ID, value: VALUE }`
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
@@ -68,11 +69,11 @@ fields:
|
|||||||
In addition to that, the document endpoint offers these additional
|
In addition to that, the document endpoint offers these additional
|
||||||
actions on individual documents:
|
actions on individual documents:
|
||||||
|
|
||||||
- `/api/documents/<pk>/download/`: Download the document.
|
- `/api/documents/<pk>/download/`: Download the document.
|
||||||
- `/api/documents/<pk>/preview/`: Display the document inline, without
|
- `/api/documents/<pk>/preview/`: Display the document inline, without
|
||||||
downloading it.
|
downloading it.
|
||||||
- `/api/documents/<pk>/thumb/`: Download the PNG thumbnail of a
|
- `/api/documents/<pk>/thumb/`: Download the PNG thumbnail of a
|
||||||
document.
|
document.
|
||||||
|
|
||||||
Paperless generates archived PDF/A documents from consumed files and
|
Paperless generates archived PDF/A documents from consumed files and
|
||||||
stores both the original files as well as the archived files. By
|
stores both the original files as well as the archived files. By
|
||||||
@@ -106,30 +107,30 @@ Access the metadata of a document with an ID `id` at
|
|||||||
|
|
||||||
The endpoint reports the following data:
|
The endpoint reports the following data:
|
||||||
|
|
||||||
- `original_checksum`: MD5 checksum of the original document.
|
- `original_checksum`: MD5 checksum of the original document.
|
||||||
- `original_size`: Size of the original document, in bytes.
|
- `original_size`: Size of the original document, in bytes.
|
||||||
- `original_mime_type`: Mime type of the original document.
|
- `original_mime_type`: Mime type of the original document.
|
||||||
- `media_filename`: Current filename of the document, under which it
|
- `media_filename`: Current filename of the document, under which it
|
||||||
is stored inside the media directory.
|
is stored inside the media directory.
|
||||||
- `has_archive_version`: True, if this document is archived, false
|
- `has_archive_version`: True, if this document is archived, false
|
||||||
otherwise.
|
otherwise.
|
||||||
- `original_metadata`: A list of metadata associated with the original
|
- `original_metadata`: A list of metadata associated with the original
|
||||||
document. See below.
|
document. See below.
|
||||||
- `archive_checksum`: MD5 checksum of the archived document, or null.
|
- `archive_checksum`: MD5 checksum of the archived document, or null.
|
||||||
- `archive_size`: Size of the archived document in bytes, or null.
|
- `archive_size`: Size of the archived document in bytes, or null.
|
||||||
- `archive_metadata`: Metadata associated with the archived document,
|
- `archive_metadata`: Metadata associated with the archived document,
|
||||||
or null. See below.
|
or null. See below.
|
||||||
|
|
||||||
File metadata is reported as a list of objects in the following form:
|
File metadata is reported as a list of objects in the following form:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"namespace": "http://ns.adobe.com/pdf/1.3/",
|
"namespace": "http://ns.adobe.com/pdf/1.3/",
|
||||||
"prefix": "pdf",
|
"prefix": "pdf",
|
||||||
"key": "Producer",
|
"key": "Producer",
|
||||||
"value": "SparklePDF, Fancy edition"
|
"value": "SparklePDF, Fancy edition"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -139,9 +140,9 @@ document. Paperless only reports PDF metadata at this point.
|
|||||||
|
|
||||||
## Documents additional endpoints
|
## Documents additional endpoints
|
||||||
|
|
||||||
- `/api/documents/<id>/notes/`: Retrieve notes for a document.
|
- `/api/documents/<id>/notes/`: Retrieve notes for a document.
|
||||||
- `/api/documents/<id>/share_links/`: Retrieve share links for a document.
|
- `/api/documents/<id>/share_links/`: Retrieve share links for a document.
|
||||||
- `/api/documents/<id>/history/`: Retrieve history of changes for a document.
|
- `/api/documents/<id>/history/`: Retrieve history of changes for a document.
|
||||||
|
|
||||||
## Authorization
|
## Authorization
|
||||||
|
|
||||||
@@ -227,20 +228,14 @@ Full text searching is available on the `/api/documents/` endpoint. Two
|
|||||||
specific query parameters cause the API to return full text search
|
specific query parameters cause the API to return full text search
|
||||||
results:
|
results:
|
||||||
|
|
||||||
- `/api/documents/?query=your%20search%20query`: Search for a document
|
- `/api/documents/?query=your%20search%20query`: Search for a document
|
||||||
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
|
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
|
||||||
- `/api/documents/?more_like_id=1234`: Search for documents similar to
|
- `/api/documents/?more_like_id=1234`: Search for documents similar to
|
||||||
the document with id 1234.
|
the document with id 1234.
|
||||||
|
|
||||||
Pagination works exactly the same as it does for normal requests on this
|
Pagination works exactly the same as it does for normal requests on this
|
||||||
endpoint.
|
endpoint.
|
||||||
|
|
||||||
Certain limitations apply to full text queries:
|
|
||||||
|
|
||||||
- Results are always sorted by search score. The results matching the
|
|
||||||
query best will show up first.
|
|
||||||
- Only a small subset of filtering parameters are supported.
|
|
||||||
|
|
||||||
Furthermore, each returned document has an additional `__search_hit__`
|
Furthermore, each returned document has an additional `__search_hit__`
|
||||||
attribute with various information about the search results:
|
attribute with various information about the search results:
|
||||||
|
|
||||||
@@ -273,12 +268,57 @@ attribute with various information about the search results:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `score` is an indication how well this document matches the query
|
- `score` is an indication how well this document matches the query
|
||||||
relative to the other search results.
|
relative to the other search results.
|
||||||
- `highlights` is an excerpt from the document content and highlights
|
- `highlights` is an excerpt from the document content and highlights
|
||||||
the search terms with `<span>` tags as shown above.
|
the search terms with `<span>` tags as shown above.
|
||||||
- `rank` is the index of the search results. The first result will
|
- `rank` is the index of the search results. The first result will
|
||||||
have rank 0.
|
have rank 0.
|
||||||
|
|
||||||
|
### Filtering by custom fields
|
||||||
|
|
||||||
|
You can filter documents by their custom field values by specifying the
|
||||||
|
`custom_field_query` query parameter. Here are some recipes for common
|
||||||
|
use cases:
|
||||||
|
|
||||||
|
1. Documents with a custom field "due" (date) between Aug 1, 2024 and
|
||||||
|
Sept 1, 2024 (inclusive):
|
||||||
|
|
||||||
|
`?custom_field_query=["due", "range", ["2024-08-01", "2024-09-01"]]`
|
||||||
|
|
||||||
|
2. Documents with a custom field "customer" (text) that equals "bob"
|
||||||
|
(case sensitive):
|
||||||
|
|
||||||
|
`?custom_field_query=["customer", "exact", "bob"]`
|
||||||
|
|
||||||
|
3. Documents with a custom field "answered" (boolean) set to `true`:
|
||||||
|
|
||||||
|
`?custom_field_query=["answered", "exact", true]`
|
||||||
|
|
||||||
|
4. Documents with a custom field "favorite animal" (select) set to either
|
||||||
|
"cat" or "dog":
|
||||||
|
|
||||||
|
`?custom_field_query=["favorite animal", "in", ["cat", "dog"]]`
|
||||||
|
|
||||||
|
5. Documents with a custom field "address" (text) that is empty:
|
||||||
|
|
||||||
|
`?custom_field_query=["OR", ["address", "isnull", true], ["address", "exact", ""]]`
|
||||||
|
|
||||||
|
6. Documents that don't have a field called "foo":
|
||||||
|
|
||||||
|
`?custom_field_query=["foo", "exists", false]`
|
||||||
|
|
||||||
|
7. Documents that have document links "references" to both document 3 and 7:
|
||||||
|
|
||||||
|
`?custom_field_query=["references", "contains", [3, 7]]`
|
||||||
|
|
||||||
|
All field types support basic operations including `exact`, `in`, `isnull`,
|
||||||
|
and `exists`. String, URL, and monetary fields support case-insensitive
|
||||||
|
substring matching operations including `icontains`, `istartswith`, and
|
||||||
|
`iendswith`. Integer, float, and date fields support arithmetic comparisons
|
||||||
|
including `gt` (>), `gte` (>=), `lt` (<), `lte` (<=), and `range`.
|
||||||
|
Lastly, document link fields support a `contains` operator that behaves
|
||||||
|
like a "is superset of" check.
|
||||||
|
|
||||||
### `/api/search/autocomplete/`
|
### `/api/search/autocomplete/`
|
||||||
|
|
||||||
@@ -286,8 +326,8 @@ Get auto completions for a partial search term.
|
|||||||
|
|
||||||
Query parameters:
|
Query parameters:
|
||||||
|
|
||||||
- `term`: The incomplete term.
|
- `term`: The incomplete term.
|
||||||
- `limit`: Amount of results. Defaults to 10.
|
- `limit`: Amount of results. Defaults to 10.
|
||||||
|
|
||||||
Results returned by the endpoint are ordered by importance of the term
|
Results returned by the endpoint are ordered by importance of the term
|
||||||
in the document index. The first result is the term that has the highest
|
in the document index. The first result is the term that has the highest
|
||||||
@@ -311,19 +351,19 @@ from there.
|
|||||||
|
|
||||||
The endpoint supports the following optional form fields:
|
The endpoint supports the following optional form fields:
|
||||||
|
|
||||||
- `title`: Specify a title that the consumer should use for the
|
- `title`: Specify a title that the consumer should use for the
|
||||||
document.
|
document.
|
||||||
- `created`: Specify a DateTime where the document was created (e.g.
|
- `created`: Specify a DateTime where the document was created (e.g.
|
||||||
"2016-04-19" or "2016-04-19 06:15:00+02:00").
|
"2016-04-19" or "2016-04-19 06:15:00+02:00").
|
||||||
- `correspondent`: Specify the ID of a correspondent that the consumer
|
- `correspondent`: Specify the ID of a correspondent that the consumer
|
||||||
should use for the document.
|
should use for the document.
|
||||||
- `document_type`: Similar to correspondent.
|
- `document_type`: Similar to correspondent.
|
||||||
- `storage_path`: Similar to correspondent.
|
- `storage_path`: Similar to correspondent.
|
||||||
- `tags`: Similar to correspondent. Specify this multiple times to
|
- `tags`: Similar to correspondent. Specify this multiple times to
|
||||||
have multiple tags added to the document.
|
have multiple tags added to the document.
|
||||||
- `archive_serial_number`: An optional archive serial number to set.
|
- `archive_serial_number`: An optional archive serial number to set.
|
||||||
- `custom_fields`: An array of custom field ids to assign (with an empty
|
- `custom_fields`: An array of custom field ids to assign (with an empty
|
||||||
value) to the document.
|
value) to the document.
|
||||||
|
|
||||||
The endpoint will immediately return HTTP 200 if the document consumption
|
The endpoint will immediately return HTTP 200 if the document consumption
|
||||||
process was started successfully, with the UUID of the consumption task
|
process was started successfully, with the UUID of the consumption task
|
||||||
@@ -389,50 +429,50 @@ a json payload of the format:
|
|||||||
|
|
||||||
The following methods are supported:
|
The following methods are supported:
|
||||||
|
|
||||||
- `set_correspondent`
|
- `set_correspondent`
|
||||||
- Requires `parameters`: `{ "correspondent": CORRESPONDENT_ID }`
|
- Requires `parameters`: `{ "correspondent": CORRESPONDENT_ID }`
|
||||||
- `set_document_type`
|
- `set_document_type`
|
||||||
- Requires `parameters`: `{ "document_type": DOCUMENT_TYPE_ID }`
|
- Requires `parameters`: `{ "document_type": DOCUMENT_TYPE_ID }`
|
||||||
- `set_storage_path`
|
- `set_storage_path`
|
||||||
- Requires `parameters`: `{ "storage_path": STORAGE_PATH_ID }`
|
- Requires `parameters`: `{ "storage_path": STORAGE_PATH_ID }`
|
||||||
- `add_tag`
|
- `add_tag`
|
||||||
- Requires `parameters`: `{ "tag": TAG_ID }`
|
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||||
- `remove_tag`
|
- `remove_tag`
|
||||||
- Requires `parameters`: `{ "tag": TAG_ID }`
|
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||||
- `modify_tags`
|
- `modify_tags`
|
||||||
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and / or `{ "remove_tags": [LIST_OF_TAG_IDS] }`
|
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and / or `{ "remove_tags": [LIST_OF_TAG_IDS] }`
|
||||||
- `delete`
|
- `delete`
|
||||||
- No `parameters` required
|
- No `parameters` required
|
||||||
- `reprocess`
|
- `reprocess`
|
||||||
- No `parameters` required
|
- No `parameters` required
|
||||||
- `set_permissions`
|
- `set_permissions`
|
||||||
- Requires `parameters`:
|
- Requires `parameters`:
|
||||||
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
||||||
- `"owner": OWNER_ID or null`
|
- `"owner": OWNER_ID or null`
|
||||||
- `"merge": true or false` (defaults to false)
|
- `"merge": true or false` (defaults to false)
|
||||||
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
||||||
removing them) or be merged with existing permissions.
|
removing them) or be merged with existing permissions.
|
||||||
- `merge`
|
- `merge`
|
||||||
- No additional `parameters` required.
|
- No additional `parameters` required.
|
||||||
- The ordering of the merged document is determined by the list of IDs.
|
- The ordering of the merged document is determined by the list of IDs.
|
||||||
- Optional `parameters`:
|
- Optional `parameters`:
|
||||||
- `"metadata_document_id": DOC_ID` apply metadata (tags, correspondent, etc.) from this document to the merged document.
|
- `"metadata_document_id": DOC_ID` apply metadata (tags, correspondent, etc.) from this document to the merged document.
|
||||||
- `"delete_originals": true` to delete the original documents. This requires the calling user being the owner of
|
- `"delete_originals": true` to delete the original documents. This requires the calling user being the owner of
|
||||||
all documents that are merged.
|
all documents that are merged.
|
||||||
- `split`
|
- `split`
|
||||||
- Requires `parameters`:
|
- Requires `parameters`:
|
||||||
- `"pages": [..]` The list should be a list of pages and/or a ranges, separated by commas e.g. `"[1,2-3,4,5-7]"`
|
- `"pages": [..]` The list should be a list of pages and/or a ranges, separated by commas e.g. `"[1,2-3,4,5-7]"`
|
||||||
- Optional `parameters`:
|
- Optional `parameters`:
|
||||||
- `"delete_originals": true` to delete the original document after consumption. This requires the calling user being the owner of
|
- `"delete_originals": true` to delete the original document after consumption. This requires the calling user being the owner of
|
||||||
the document.
|
the document.
|
||||||
- The split operation only accepts a single document.
|
- The split operation only accepts a single document.
|
||||||
- `rotate`
|
- `rotate`
|
||||||
- Requires `parameters`:
|
- Requires `parameters`:
|
||||||
- `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270
|
- `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270
|
||||||
- `delete_pages`
|
- `delete_pages`
|
||||||
- Requires `parameters`:
|
- Requires `parameters`:
|
||||||
- `"pages": [..]` The list should be a list of integers e.g. `"[2,3,4]"`
|
- `"pages": [..]` The list should be a list of integers e.g. `"[2,3,4]"`
|
||||||
- The delete_pages operation only accepts a single document.
|
- The delete_pages operation only accepts a single document.
|
||||||
|
|
||||||
### Objects
|
### Objects
|
||||||
|
|
||||||
@@ -454,16 +494,16 @@ operations, using the endpoint: `/api/bulk_edit_objects/`, which requires a json
|
|||||||
|
|
||||||
The REST API is versioned since Paperless-ngx 1.3.0.
|
The REST API is versioned since Paperless-ngx 1.3.0.
|
||||||
|
|
||||||
- Versioning ensures that changes to the API don't break older
|
- Versioning ensures that changes to the API don't break older
|
||||||
clients.
|
clients.
|
||||||
- Clients specify the specific version of the API they wish to use
|
- Clients specify the specific version of the API they wish to use
|
||||||
with every request and Paperless will handle the request using the
|
with every request and Paperless will handle the request using the
|
||||||
specified API version.
|
specified API version.
|
||||||
- Even if the underlying data model changes, older API versions will
|
- Even if the underlying data model changes, older API versions will
|
||||||
always serve compatible data.
|
always serve compatible data.
|
||||||
- If no version is specified, Paperless will serve version 1 to ensure
|
- If no version is specified, Paperless will serve version 1 to ensure
|
||||||
compatibility with older clients that do not request a specific API
|
compatibility with older clients that do not request a specific API
|
||||||
version.
|
version.
|
||||||
|
|
||||||
API versions are specified by submitting an additional HTTP `Accept`
|
API versions are specified by submitting an additional HTTP `Accept`
|
||||||
header with every request:
|
header with every request:
|
||||||
@@ -500,19 +540,19 @@ Initial API version.
|
|||||||
|
|
||||||
#### Version 2
|
#### Version 2
|
||||||
|
|
||||||
- Added field `Tag.color`. This read/write string field contains a hex
|
- Added field `Tag.color`. This read/write string field contains a hex
|
||||||
color such as `#a6cee3`.
|
color such as `#a6cee3`.
|
||||||
- Added read-only field `Tag.text_color`. This field contains the text
|
- Added read-only field `Tag.text_color`. This field contains the text
|
||||||
color to use for a specific tag, which is either black or white
|
color to use for a specific tag, which is either black or white
|
||||||
depending on the brightness of `Tag.color`.
|
depending on the brightness of `Tag.color`.
|
||||||
- Removed field `Tag.colour`.
|
- Removed field `Tag.colour`.
|
||||||
|
|
||||||
#### Version 3
|
#### Version 3
|
||||||
|
|
||||||
- Permissions endpoints have been added.
|
- Permissions endpoints have been added.
|
||||||
- The format of the `/api/ui_settings/` has changed.
|
- The format of the `/api/ui_settings/` has changed.
|
||||||
|
|
||||||
#### Version 4
|
#### Version 4
|
||||||
|
|
||||||
- Consumption templates were refactored to workflows and API endpoints
|
- Consumption templates were refactored to workflows and API endpoints
|
||||||
changed as such.
|
changed as such.
|
||||||
|
@@ -10,7 +10,7 @@
|
|||||||
--md-hue: 222;
|
--md-hue: 222;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 400px) {
|
@media (min-width: 768px) {
|
||||||
.grid-left {
|
.grid-left {
|
||||||
width: 33%;
|
width: 33%;
|
||||||
float: left;
|
float: left;
|
||||||
|
7631
docs/changelog.md
7631
docs/changelog.md
File diff suppressed because it is too large
Load Diff
@@ -8,17 +8,17 @@ common [OCR](#ocr) related settings and some frontend settings. If set, these wi
|
|||||||
preference over the settings via environment variables. If not set, the environment setting
|
preference over the settings via environment variables. If not set, the environment setting
|
||||||
or applicable default will be utilized instead.
|
or applicable default will be utilized instead.
|
||||||
|
|
||||||
- If you run paperless on docker, `paperless.conf` is not used.
|
- If you run paperless on docker, `paperless.conf` is not used.
|
||||||
Rather, configure paperless by copying necessary options to
|
Rather, configure paperless by copying necessary options to
|
||||||
`docker-compose.env`.
|
`docker-compose.env`.
|
||||||
|
|
||||||
- If you are running paperless on anything else, paperless will search
|
- If you are running paperless on anything else, paperless will search
|
||||||
for the configuration file in these locations and use the first one
|
for the configuration file in these locations and use the first one
|
||||||
it finds:
|
it finds:
|
||||||
- The environment variable `PAPERLESS_CONFIGURATION_PATH`
|
- The environment variable `PAPERLESS_CONFIGURATION_PATH`
|
||||||
- `/path/to/paperless/paperless.conf`
|
- `/path/to/paperless/paperless.conf`
|
||||||
- `/etc/paperless.conf`
|
- `/etc/paperless.conf`
|
||||||
- `/usr/local/etc/paperless.conf`
|
- `/usr/local/etc/paperless.conf`
|
||||||
|
|
||||||
## Required services
|
## Required services
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ matcher.
|
|||||||
`redis://<username>:<password>@<host>:<port>/<DBIndex>`
|
`redis://<username>:<password>@<host>:<port>/<DBIndex>`
|
||||||
|
|
||||||
[More information on securing your Redis
|
[More information on securing your Redis
|
||||||
Instance](https://redis.io/docs/getting-started/#securing-redis).
|
Instance](https://redis.io/docs/latest/operate/oss_and_stack/management/security).
|
||||||
|
|
||||||
Defaults to `redis://localhost:6379`.
|
Defaults to `redis://localhost:6379`.
|
||||||
|
|
||||||
@@ -600,7 +600,7 @@ You can optionally also automatically redirect users to the SSO login with [PAPE
|
|||||||
|
|
||||||
Defaults to False
|
Defaults to False
|
||||||
|
|
||||||
#### ['PAPERLESS_REDIRECT_LOGIN_TO_SSO=<bool>`](#PAPERLESS_REDIRECT_LOGIN_TO_SSO) {#PAPERLESS_REDIRECT_LOGIN_TO_SSO}
|
#### [`PAPERLESS_REDIRECT_LOGIN_TO_SSO=<bool>`](#PAPERLESS_REDIRECT_LOGIN_TO_SSO) {#PAPERLESS_REDIRECT_LOGIN_TO_SSO}
|
||||||
|
|
||||||
: When this setting is enabled users will automatically be redirected (using javascript) to the first SSO provider login. You may still want to disable the frontend login form for clarity.
|
: When this setting is enabled users will automatically be redirected (using javascript) to the first SSO provider login. You may still want to disable the frontend login form for clarity.
|
||||||
|
|
||||||
@@ -608,9 +608,18 @@ You can optionally also automatically redirect users to the SSO login with [PAPE
|
|||||||
|
|
||||||
#### [`PAPERLESS_ACCOUNT_SESSION_REMEMBER=<bool>`](#PAPERLESS_ACCOUNT_SESSION_REMEMBER) {#PAPERLESS_ACCOUNT_SESSION_REMEMBER}
|
#### [`PAPERLESS_ACCOUNT_SESSION_REMEMBER=<bool>`](#PAPERLESS_ACCOUNT_SESSION_REMEMBER) {#PAPERLESS_ACCOUNT_SESSION_REMEMBER}
|
||||||
|
|
||||||
: See the corresponding
|
: If false, sessions will expire at browser close, if true will use `PAPERLESS_SESSION_COOKIE_AGE` for expiration. See the corresponding
|
||||||
[django-allauth documentation](https://docs.allauth.org/en/latest/account/configuration.html)
|
[django-allauth documentation](https://docs.allauth.org/en/latest/account/configuration.html)
|
||||||
|
|
||||||
|
Defaults to True
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SESSION_COOKIE_AGE=<int>`](#PAPERLESS_SESSION_COOKIE_AGE) {#PAPERLESS_SESSION_COOKIE_AGE}
|
||||||
|
|
||||||
|
: Login session cookie expiration. Applies if `PAPERLESS_ACCOUNT_SESSION_REMEMBER` is enabled. See the corresponding
|
||||||
|
[django documentation](https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-SESSION_COOKIE_AGE)
|
||||||
|
|
||||||
|
Defaults to 1209600 (2 weeks)
|
||||||
|
|
||||||
## OCR settings {#ocr}
|
## OCR settings {#ocr}
|
||||||
|
|
||||||
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)
|
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)
|
||||||
@@ -1149,6 +1158,12 @@ within your documents.
|
|||||||
second, and year last order. Characters D, M, or Y can be shuffled
|
second, and year last order. Characters D, M, or Y can be shuffled
|
||||||
to meet the required order.
|
to meet the required order.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ENABLE_GPG_DECRYPTOR=<bool>`](#PAPERLESS_ENABLE_GPG_DECRYPTOR) {#PAPERLESS_ENABLE_GPG_DECRYPTOR}
|
||||||
|
|
||||||
|
: Enable or disable the GPG decryptor for encrypted emails. See [GPG Decryptor](advanced_usage.md#gpg-decryptor) for more information.
|
||||||
|
|
||||||
|
Defaults to false.
|
||||||
|
|
||||||
### Polling {#polling}
|
### Polling {#polling}
|
||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_POLLING=<num>`](#PAPERLESS_CONSUMER_POLLING) {#PAPERLESS_CONSUMER_POLLING}
|
#### [`PAPERLESS_CONSUMER_POLLING=<num>`](#PAPERLESS_CONSUMER_POLLING) {#PAPERLESS_CONSUMER_POLLING}
|
||||||
@@ -1192,6 +1207,48 @@ consumers working on the same file. Configure this to prevent that.
|
|||||||
|
|
||||||
Defaults to 0.5 seconds.
|
Defaults to 0.5 seconds.
|
||||||
|
|
||||||
|
## Incoming Mail {#incoming_mail}
|
||||||
|
|
||||||
|
### Email OAuth {#email_oauth}
|
||||||
|
|
||||||
|
#### [`PAPERLESS_OAUTH_CALLBACK_BASE_URL=<str>`](#PAPERLESS_OAUTH_CALLBACK_BASE_URL) {#PAPERLESS_OAUTH_CALLBACK_BASE_URL}
|
||||||
|
|
||||||
|
: The base URL for the OAuth callback. This is used to construct the full URL for the OAuth callback. This should be the URL that the Paperless instance is accessible at. If not set, defaults to the `PAPERLESS_URL` setting. At least one of these settings must be set to enable OAuth Email setup.
|
||||||
|
|
||||||
|
Defaults to none (thus will use [PAPERLESS_URL](#PAPERLESS_URL)).
|
||||||
|
|
||||||
|
#### [`PAPERLESS_GMAIL_OAUTH_CLIENT_ID=<str>`](#PAPERLESS_GMAIL_OAUTH_CLIENT_ID) {#PAPERLESS_GMAIL_OAUTH_CLIENT_ID}
|
||||||
|
|
||||||
|
: The OAuth client ID for Gmail. This is required for Gmail OAuth Email setup. See [OAuth Email Setup](usage.md#oauth-email-setup) for more information.
|
||||||
|
|
||||||
|
Defaults to none.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_GMAIL_OAUTH_CLIENT_SECRET=<str>`](#PAPERLESS_GMAIL_OAUTH_CLIENT_SECRET) {#PAPERLESS_GMAIL_OAUTH_CLIENT_SECRET}
|
||||||
|
|
||||||
|
: The OAuth client secret for Gmail. This is required for Gmail OAuth Email setup. See [OAuth Email Setup](usage.md#oauth-email-setup) for more information.
|
||||||
|
|
||||||
|
Defaults to none.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_OUTLOOK_OAUTH_CLIENT_ID=<str>`](#PAPERLESS_OUTLOOK_OAUTH_CLIENT_ID) {#PAPERLESS_OUTLOOK_OAUTH_CLIENT_ID}
|
||||||
|
|
||||||
|
: The OAuth client ID for Outlook. This is required for Outlook OAuth Email setup. See [OAuth Email Setup](usage.md#oauth-email-setup) for more information.
|
||||||
|
|
||||||
|
Defaults to none.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_OUTLOOK_OAUTH_CLIENT_SECRET=<str>`](#PAPERLESS_OUTLOOK_OAUTH_CLIENT_SECRET) {#PAPERLESS_OUTLOOK_OAUTH_CLIENT_SECRET}
|
||||||
|
|
||||||
|
: The OAuth client secret for Outlook. This is required for Outlook OAuth Email setup. See [OAuth Email Setup](usage.md#oauth-email-setup) for more information.
|
||||||
|
|
||||||
|
Defaults to none.
|
||||||
|
|
||||||
|
### Encrypted Emails {#encrypted_emails}
|
||||||
|
|
||||||
|
#### [`PAPERLESS_EMAIL_GNUPG_HOME=<str>`](#PAPERLESS_EMAIL_GNUPG_HOME) {#PAPERLESS_EMAIL_GNUPG_HOME}
|
||||||
|
|
||||||
|
: Optional, sets the `GNUPG_HOME` path to use with GPG decryptor for encrypted emails. See [GPG Decryptor](advanced_usage.md#gpg-decryptor) for more information. If not set, defaults to the default `GNUPG_HOME` path.
|
||||||
|
|
||||||
|
Defaults to <not set>.
|
||||||
|
|
||||||
## Barcodes {#barcodes}
|
## Barcodes {#barcodes}
|
||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_ENABLE_BARCODES=<bool>`](#PAPERLESS_CONSUMER_ENABLE_BARCODES) {#PAPERLESS_CONSUMER_ENABLE_BARCODES}
|
#### [`PAPERLESS_CONSUMER_ENABLE_BARCODES=<bool>`](#PAPERLESS_CONSUMER_ENABLE_BARCODES) {#PAPERLESS_CONSUMER_ENABLE_BARCODES}
|
||||||
@@ -1230,6 +1287,12 @@ change this.
|
|||||||
|
|
||||||
Defaults to "PATCHT"
|
Defaults to "PATCHT"
|
||||||
|
|
||||||
|
#### [`PAPERLESS_CONSUMER_BARCODE_RETAIN_SPLIT_PAGES=<bool>`](#PAPERLESS_CONSUMER_BARCODE_RETAIN_SPLIT_PAGES) {#PAPERLESS_CONSUMER_BARCODE_RETAIN_SPLIT_PAGES}
|
||||||
|
|
||||||
|
: If set to true, all pages that are split by a barcode (such as PATCHT) will be kept.
|
||||||
|
|
||||||
|
Defaults to false.
|
||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE=<bool>`](#PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE) {#PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE}
|
#### [`PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE=<bool>`](#PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE) {#PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE}
|
||||||
|
|
||||||
: Enables the detection of barcodes in the scanned document and
|
: Enables the detection of barcodes in the scanned document and
|
||||||
@@ -1277,6 +1340,15 @@ combination with PAPERLESS_CONSUMER_BARCODE_UPSCALE bigger than 1.0.
|
|||||||
|
|
||||||
Defaults to "300"
|
Defaults to "300"
|
||||||
|
|
||||||
|
#### [`PAPERLESS_CONSUMER_BARCODE_MAX_PAGES=<int>`](#PAPERLESS_CONSUMER_BARCODE_MAX_PAGES) {#PAPERLESS_CONSUMER_BARCODE_MAX_PAGES}
|
||||||
|
|
||||||
|
: Because barcode detection is a computationally-intensive operation, this setting
|
||||||
|
limits the detection of barcodes to a number of first pages. If your scanner has
|
||||||
|
a limit for the number of pages that can be scanned it would be sensible to set this
|
||||||
|
as the limit here.
|
||||||
|
|
||||||
|
Defaults to "0", allowing all pages to be checked for barcodes.
|
||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE=<bool>`](#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE) {#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE}
|
#### [`PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE=<bool>`](#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE) {#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE}
|
||||||
|
|
||||||
: Enables the detection of barcodes in the scanned document and
|
: Enables the detection of barcodes in the scanned document and
|
||||||
|
@@ -6,23 +6,23 @@ on Paperless-ngx.
|
|||||||
Check out the source from GitHub. The repository is organized in the
|
Check out the source from GitHub. The repository is organized in the
|
||||||
following way:
|
following way:
|
||||||
|
|
||||||
- `main` always represents the latest release and will only see
|
- `main` always represents the latest release and will only see
|
||||||
changes when a new release is made.
|
changes when a new release is made.
|
||||||
- `dev` contains the code that will be in the next release.
|
- `dev` contains the code that will be in the next release.
|
||||||
- `feature-X` contains 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.
|
not necessarily the next one.
|
||||||
|
|
||||||
When making functional changes to Paperless-ngx, _always_ make your changes
|
When making functional changes to Paperless-ngx, _always_ make your changes
|
||||||
on the `dev` branch.
|
on the `dev` branch.
|
||||||
|
|
||||||
Apart from that, the folder structure is as follows:
|
Apart from that, the folder structure is as follows:
|
||||||
|
|
||||||
- `docs/` - Documentation.
|
- `docs/` - Documentation.
|
||||||
- `src-ui/` - Code of the front end.
|
- `src-ui/` - Code of the front end.
|
||||||
- `src/` - Code of the back end.
|
- `src/` - Code of the back end.
|
||||||
- `scripts/` - Various scripts that help with different parts of
|
- `scripts/` - Various scripts that help with different parts of
|
||||||
development.
|
development.
|
||||||
- `docker/` - Files required to build the docker image.
|
- `docker/` - Files required to build the docker image.
|
||||||
|
|
||||||
## Contributing to Paperless-ngx
|
## Contributing to Paperless-ngx
|
||||||
|
|
||||||
@@ -99,17 +99,17 @@ first-time setup.
|
|||||||
|
|
||||||
7. You can now either ...
|
7. You can now either ...
|
||||||
|
|
||||||
- install redis or
|
- install redis or
|
||||||
|
|
||||||
- use the included `scripts/start_services.sh` to use docker to fire
|
- use the included `scripts/start_services.sh` to use docker to fire
|
||||||
up a redis instance (and some other services such as tika,
|
up a redis instance (and some other services such as tika,
|
||||||
gotenberg and a database server) or
|
gotenberg and a database server) or
|
||||||
|
|
||||||
- spin up a bare redis container
|
- spin up a bare redis container
|
||||||
|
|
||||||
```
|
```
|
||||||
$ docker run -d -p 6379:6379 --restart unless-stopped redis:latest
|
$ docker run -d -p 6379:6379 --restart unless-stopped redis:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
8. Continue with either back-end or front-end development – or both :-).
|
8. Continue with either back-end or front-end development – or both :-).
|
||||||
|
|
||||||
@@ -122,9 +122,9 @@ work well for development, but you can use whatever you want.
|
|||||||
Configure the IDE to use the `src/`-folder as the base source folder.
|
Configure the IDE to use the `src/`-folder as the base source folder.
|
||||||
Configure the following launch configurations in your IDE:
|
Configure the following launch configurations in your IDE:
|
||||||
|
|
||||||
- `python3 manage.py runserver`
|
- `python3 manage.py runserver`
|
||||||
- `python3 manage.py document_consumer`
|
- `python3 manage.py document_consumer`
|
||||||
- `celery --app paperless worker -l DEBUG` (or any other log level)
|
- `celery --app paperless worker -l DEBUG` (or any other log level)
|
||||||
|
|
||||||
To start them all:
|
To start them all:
|
||||||
|
|
||||||
@@ -150,11 +150,11 @@ $ ng build --configuration production
|
|||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
- Run `pytest` in the `src/` directory to execute all tests. This also
|
- Run `pytest` in the `src/` directory to execute all tests. This also
|
||||||
generates a HTML coverage report. When runnings test, `paperless.conf`
|
generates a HTML coverage report. When runnings test, `paperless.conf`
|
||||||
is loaded as well. However, the tests rely on the default
|
is loaded as well. However, the tests rely on the default
|
||||||
configuration. This is not ideal. But for now, make sure no settings
|
configuration. This is not ideal. But for now, make sure no settings
|
||||||
except for DEBUG are overridden when testing.
|
except for DEBUG are overridden when testing.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
@@ -245,14 +245,14 @@ these parts have to be translated separately.
|
|||||||
|
|
||||||
### Front end localization
|
### Front end localization
|
||||||
|
|
||||||
- The AngularJS front end does localization according to the [Angular
|
- The AngularJS front end does localization according to the [Angular
|
||||||
documentation](https://angular.io/guide/i18n).
|
documentation](https://angular.io/guide/i18n).
|
||||||
- The source language of the project is "en_US".
|
- The source language of the project is "en_US".
|
||||||
- The source strings end up in the file `src-ui/messages.xlf`.
|
- The source strings end up in the file `src-ui/messages.xlf`.
|
||||||
- The translated strings need to be placed in the
|
- The translated strings need to be placed in the
|
||||||
`src-ui/src/locale/` folder.
|
`src-ui/src/locale/` folder.
|
||||||
- In order to extract added or changed strings from the source files,
|
- In order to extract added or changed strings from the source files,
|
||||||
call `ng extract-i18n`.
|
call `ng extract-i18n`.
|
||||||
|
|
||||||
Adding new languages requires adding the translated files in the
|
Adding new languages requires adding the translated files in the
|
||||||
`src-ui/src/locale/` folder and adjusting a couple files.
|
`src-ui/src/locale/` folder and adjusting a couple files.
|
||||||
@@ -298,18 +298,18 @@ A majority of the strings that appear in the back end appear only when
|
|||||||
the admin is used. However, some of these are still shown on the front
|
the admin is used. However, some of these are still shown on the front
|
||||||
end (such as error messages).
|
end (such as error messages).
|
||||||
|
|
||||||
- The django application does localization according to the [Django
|
- The django application does localization according to the [Django
|
||||||
documentation](https://docs.djangoproject.com/en/3.1/topics/i18n/translation/).
|
documentation](https://docs.djangoproject.com/en/3.1/topics/i18n/translation/).
|
||||||
- The source language of the project is "en_US".
|
- The source language of the project is "en_US".
|
||||||
- Localization files end up in the folder `src/locale/`.
|
- Localization files end up in the folder `src/locale/`.
|
||||||
- In order to extract strings from the application, call
|
- In order to extract strings from the application, call
|
||||||
`python3 manage.py makemessages -l en_US`. This is important after
|
`python3 manage.py makemessages -l en_US`. This is important after
|
||||||
making changes to translatable strings.
|
making changes to translatable strings.
|
||||||
- The message files need to be compiled for them to show up in the
|
- The message files need to be compiled for them to show up in the
|
||||||
application. Call `python3 manage.py compilemessages` to do this.
|
application. Call `python3 manage.py compilemessages` to do this.
|
||||||
The generated files don't get committed into git, since these are
|
The generated files don't get committed into git, since these are
|
||||||
derived artifacts. The build pipeline takes care of executing this
|
derived artifacts. The build pipeline takes care of executing this
|
||||||
command.
|
command.
|
||||||
|
|
||||||
Adding new languages requires adding the translated files in the
|
Adding new languages requires adding the translated files in the
|
||||||
`src/locale/`-folder and adjusting the file
|
`src/locale/`-folder and adjusting the file
|
||||||
@@ -360,10 +360,10 @@ If you want to build the documentation locally, this is how you do it:
|
|||||||
The docker image is primarily built by the GitHub actions workflow, but
|
The docker image is primarily built by the GitHub actions workflow, but
|
||||||
it can be faster when developing to build and tag an image locally.
|
it can be faster when developing to build and tag an image locally.
|
||||||
|
|
||||||
Building the image works as with any image:
|
Make sure you have the `docker-buildx` package installed. Building the image works as with any image:
|
||||||
|
|
||||||
```
|
```
|
||||||
docker build --file Dockerfile --tag paperless:local --progress simple .
|
docker build --file Dockerfile --tag paperless:local .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Extending Paperless-ngx
|
## Extending Paperless-ngx
|
||||||
@@ -378,10 +378,10 @@ base code.
|
|||||||
Paperless-ngx uses parsers to add documents. A parser is
|
Paperless-ngx uses parsers to add documents. A parser is
|
||||||
responsible for:
|
responsible for:
|
||||||
|
|
||||||
- Retrieving the content from the original
|
- Retrieving the content from the original
|
||||||
- Creating a thumbnail
|
- Creating a thumbnail
|
||||||
- _optional:_ Retrieving a created date from the original
|
- _optional:_ Retrieving a created date from the original
|
||||||
- _optional:_ Creating an archived document from the original
|
- _optional:_ Creating an archived document from the original
|
||||||
|
|
||||||
Custom parsers can be added to Paperless-ngx to support more file types. In
|
Custom parsers can be added to Paperless-ngx to support more file types. In
|
||||||
order to do that, you need to write the parser itself and announce its
|
order to do that, you need to write the parser itself and announce its
|
||||||
@@ -439,14 +439,14 @@ def myparser_consumer_declaration(sender, **kwargs):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `parser` is a reference to a class that extends `DocumentParser`.
|
- `parser` is a reference to a class that extends `DocumentParser`.
|
||||||
- `weight` is used whenever two or more parsers are able to parse a
|
- `weight` is used whenever two or more parsers are able to parse a
|
||||||
file: The parser with the higher weight wins. This can be used to
|
file: The parser with the higher weight wins. This can be used to
|
||||||
override the parsers provided by Paperless-ngx.
|
override the parsers provided by Paperless-ngx.
|
||||||
- `mime_types` is a dictionary. The keys are the mime types your
|
- `mime_types` is a dictionary. The keys are the mime types your
|
||||||
parser supports and the value is the default file extension that
|
parser supports and the value is the default file extension that
|
||||||
Paperless-ngx should use when storing files and serving them for
|
Paperless-ngx should use when storing files and serving them for
|
||||||
download. We could guess that from the file extensions, but some
|
download. We could guess that from the file extensions, but some
|
||||||
mime types have many extensions associated with them and the Python
|
mime types have many extensions associated with them and the Python
|
||||||
methods responsible for guessing the extension do not always return
|
methods responsible for guessing the extension do not always return
|
||||||
the same value.
|
the same value.
|
||||||
|
52
docs/faq.md
52
docs/faq.md
@@ -40,28 +40,28 @@ system. On Linux, chances are high that this location is
|
|||||||
You can always drag those files out of that folder to use them
|
You can always drag those files out of that folder to use them
|
||||||
elsewhere. Here are a couple notes about that.
|
elsewhere. Here are a couple notes about that.
|
||||||
|
|
||||||
- Paperless-ngx never modifies your original documents. It keeps
|
- Paperless-ngx never modifies your original documents. It keeps
|
||||||
checksums of all documents and uses a scheduled sanity checker to
|
checksums of all documents and uses a scheduled sanity checker to
|
||||||
check that they remain the same.
|
check that they remain the same.
|
||||||
- By default, paperless uses the internal ID of each document as its
|
- By default, paperless uses the internal ID of each document as its
|
||||||
filename. This might not be very convenient for export. However, you
|
filename. This might not be very convenient for export. However, you
|
||||||
can adjust the way files are stored in paperless by
|
can adjust the way files are stored in paperless by
|
||||||
[configuring the filename format](advanced_usage.md#file-name-handling).
|
[configuring the filename format](advanced_usage.md#file-name-handling).
|
||||||
- [The exporter](administration.md#exporter) is
|
- [The exporter](administration.md#exporter) is
|
||||||
another easy way to get your files out of paperless with reasonable
|
another easy way to get your files out of paperless with reasonable
|
||||||
file names.
|
file names.
|
||||||
|
|
||||||
## _What file types does paperless-ngx support?_
|
## _What file types does paperless-ngx support?_
|
||||||
|
|
||||||
**A:** Currently, the following files are supported:
|
**A:** Currently, the following files are supported:
|
||||||
|
|
||||||
- PDF documents, PNG images, JPEG images, TIFF images, GIF images and
|
- PDF documents, PNG images, JPEG images, TIFF images, GIF images and
|
||||||
WebP images are processed with OCR and converted into PDF documents.
|
WebP images are processed with OCR and converted into PDF documents.
|
||||||
- Plain text documents are supported as well and are added verbatim to
|
- Plain text documents are supported as well and are added verbatim to
|
||||||
paperless.
|
paperless.
|
||||||
- With the optional Tika integration enabled (see [Tika configuration](https://docs.paperless-ngx.com/configuration#tika)),
|
- With the optional Tika integration enabled (see [Tika configuration](https://docs.paperless-ngx.com/configuration#tika)),
|
||||||
Paperless also supports various Office documents (.docx, .doc, odt,
|
Paperless also supports various Office documents (.docx, .doc, odt,
|
||||||
.ppt, .pptx, .odp, .xls, .xlsx, .ods).
|
.ppt, .pptx, .odp, .xls, .xlsx, .ods).
|
||||||
|
|
||||||
Paperless-ngx determines the type of a file by inspecting its content.
|
Paperless-ngx determines the type of a file by inspecting its content.
|
||||||
The file extensions do not matter.
|
The file extensions do not matter.
|
||||||
@@ -127,8 +127,16 @@ ASGI-enabled web server as well that processes WebSocket connections,
|
|||||||
and configure Apache to redirect WebSocket connections to this server.
|
and configure Apache to redirect WebSocket connections to this server.
|
||||||
Multiple options for ASGI servers exist:
|
Multiple options for ASGI servers exist:
|
||||||
|
|
||||||
- `gunicorn` with `uvicorn` as the worker implementation (the default
|
- `gunicorn` with `uvicorn` as the worker implementation (the default
|
||||||
of paperless)
|
of paperless)
|
||||||
- `daphne` as a standalone server, which is the reference
|
- `daphne` as a standalone server, which is the reference
|
||||||
implementation for ASGI.
|
implementation for ASGI.
|
||||||
- `uvicorn` as a standalone server
|
- `uvicorn` as a standalone server
|
||||||
|
|
||||||
|
## _What about the Redis licensing change and using one of the open source forks_?
|
||||||
|
|
||||||
|
Currently (October 2024), forks of Redis such as Valkey or Redirect are not officially supported by our upstream
|
||||||
|
libraries, so using one of these to replace Redis is not officially supported.
|
||||||
|
|
||||||
|
However, they do claim to be compatible with the Redis protocol and will likely work, but we will
|
||||||
|
not be updating from using Redis as the broker officially just yet.
|
||||||
|
229
docs/setup.md
229
docs/setup.md
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
You can go multiple routes to setup and run Paperless:
|
You can go multiple routes to setup and run Paperless:
|
||||||
|
|
||||||
- [Use the easy install docker script](#docker_script)
|
- [Use the easy install docker script](#docker_script)
|
||||||
- [Pull the image from Docker Hub](#docker_hub)
|
- [Pull the image from Docker Hub](#docker_hub)
|
||||||
- [Build the Docker image yourself](#docker_build)
|
- [Build the Docker image yourself](#docker_build)
|
||||||
- [Install Paperless directly on your system manually (bare metal)](#bare_metal)
|
- [Install Paperless directly on your system manually (bare metal)](#bare_metal)
|
||||||
- A user-maintained list of commercial hosting providers can be found [in the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects)
|
- A user-maintained list of commercial hosting providers can be found [in the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects)
|
||||||
|
|
||||||
The Docker routes are quick & easy. These are the recommended routes.
|
The Docker routes are quick & easy. These are the recommended routes.
|
||||||
This configures all the stuff from the above automatically so that it
|
This configures all the stuff from the above automatically so that it
|
||||||
@@ -105,14 +105,14 @@ steps described in [Docker setup](#docker_hub) automatically.
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ports:
|
ports:
|
||||||
- 8000:8000
|
- 8000:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace the part BEFORE the colon with a port of your choice:
|
Replace the part BEFORE the colon with a port of your choice:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ports:
|
ports:
|
||||||
- 8010:8000
|
- 8010:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
Don't change the part after the colon or edit other lines that
|
Don't change the part after the colon or edit other lines that
|
||||||
@@ -129,11 +129,11 @@ steps described in [Docker setup](#docker_hub) automatically.
|
|||||||
If you want to run Paperless as a rootless container, you will need
|
If you want to run Paperless as a rootless container, you will need
|
||||||
to do the following in your `docker-compose.yml`:
|
to do the following in your `docker-compose.yml`:
|
||||||
|
|
||||||
- set the `user` running the container to map to the `paperless`
|
- set the `user` running the container to map to the `paperless`
|
||||||
user in the container. This value (`user_id` below), should be
|
user in the container. This value (`user_id` below), should be
|
||||||
the same id that `USERMAP_UID` and `USERMAP_GID` are set to in
|
the same id that `USERMAP_UID` and `USERMAP_GID` are set to in
|
||||||
the next step. See `USERMAP_UID` and `USERMAP_GID`
|
the next step. See `USERMAP_UID` and `USERMAP_GID`
|
||||||
[here](configuration.md#docker).
|
[here](configuration.md#docker).
|
||||||
|
|
||||||
Your entry for Paperless should contain something like:
|
Your entry for Paperless should contain something like:
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ steps described in [Docker setup](#docker_hub) automatically.
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
webserver:
|
webserver:
|
||||||
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
and replace it with a line that instructs Docker Compose to build
|
and replace it with a line that instructs Docker Compose to build
|
||||||
@@ -230,8 +230,8 @@ steps described in [Docker setup](#docker_hub) automatically.
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
webserver:
|
webserver:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Follow steps 3 to 8 of [Docker Setup](#docker_hub). When asked to run
|
4. Follow steps 3 to 8 of [Docker Setup](#docker_hub). When asked to run
|
||||||
@@ -250,49 +250,48 @@ a minimal installation of Debian/Buster, which is the current stable
|
|||||||
release at the time of writing. Windows is not and will never be
|
release at the time of writing. Windows is not and will never be
|
||||||
supported.
|
supported.
|
||||||
|
|
||||||
Paperless requires Python 3. At this time, 3.9 - 3.11 are tested versions.
|
Paperless requires Python 3. At this time, 3.10 - 3.12 are tested versions.
|
||||||
Newer versions may work, but some dependencies may not fully support newer versions.
|
Newer versions may work, but some dependencies may not fully support newer versions.
|
||||||
Support for older Python versions may be dropped as they reach end of life or as newer versions
|
Support for older Python versions may be dropped as they reach end of life or as newer versions
|
||||||
are released, dependency support is confirmed, etc.
|
are released, dependency support is confirmed, etc.
|
||||||
|
|
||||||
1. Install dependencies. Paperless requires the following packages.
|
1. Install dependencies. Paperless requires the following packages.
|
||||||
|
|
||||||
- `python3`
|
- `python3`
|
||||||
- `python3-pip`
|
- `python3-pip`
|
||||||
- `python3-dev`
|
- `python3-dev`
|
||||||
- `default-libmysqlclient-dev` for MariaDB
|
- `default-libmysqlclient-dev` for MariaDB
|
||||||
- `pkg-config` for mysqlclient (python dependency)
|
- `pkg-config` for mysqlclient (python dependency)
|
||||||
- `fonts-liberation` for generating thumbnails for plain text
|
- `fonts-liberation` for generating thumbnails for plain text
|
||||||
files
|
files
|
||||||
- `imagemagick` >= 6 for PDF conversion
|
- `imagemagick` >= 6 for PDF conversion
|
||||||
- `gnupg` for handling encrypted documents
|
- `gnupg` for handling encrypted documents
|
||||||
- `libpq-dev` for PostgreSQL
|
- `libpq-dev` for PostgreSQL
|
||||||
- `libmagic-dev` for mime type detection
|
- `libmagic-dev` for mime type detection
|
||||||
- `mariadb-client` for MariaDB compile time
|
- `mariadb-client` for MariaDB compile time
|
||||||
- `mime-support` for mime type detection
|
- `libzbar0` for barcode detection
|
||||||
- `libzbar0` for barcode detection
|
- `poppler-utils` for barcode detection
|
||||||
- `poppler-utils` for barcode detection
|
|
||||||
|
|
||||||
Use this list for your preferred package management:
|
Use this list for your preferred package management:
|
||||||
|
|
||||||
```
|
```
|
||||||
python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev mime-support libzbar0 poppler-utils
|
python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev libzbar0 poppler-utils
|
||||||
```
|
```
|
||||||
|
|
||||||
These dependencies are required for OCRmyPDF, which is used for text
|
These dependencies are required for OCRmyPDF, which is used for text
|
||||||
recognition.
|
recognition.
|
||||||
|
|
||||||
- `unpaper`
|
- `unpaper`
|
||||||
- `ghostscript`
|
- `ghostscript`
|
||||||
- `icc-profiles-free`
|
- `icc-profiles-free`
|
||||||
- `qpdf`
|
- `qpdf`
|
||||||
- `liblept5`
|
- `liblept5`
|
||||||
- `libxml2`
|
- `libxml2`
|
||||||
- `pngquant` (suggested for certain PDF image optimizations)
|
- `pngquant` (suggested for certain PDF image optimizations)
|
||||||
- `zlib1g`
|
- `zlib1g`
|
||||||
- `tesseract-ocr` >= 4.0.0 for OCR
|
- `tesseract-ocr` >= 4.0.0 for OCR
|
||||||
- `tesseract-ocr` language packs (`tesseract-ocr-eng`,
|
- `tesseract-ocr` language packs (`tesseract-ocr-eng`,
|
||||||
`tesseract-ocr-deu`, etc)
|
`tesseract-ocr-deu`, etc)
|
||||||
|
|
||||||
Use this list for your preferred package management:
|
Use this list for your preferred package management:
|
||||||
|
|
||||||
@@ -302,14 +301,15 @@ are released, dependency support is confirmed, etc.
|
|||||||
|
|
||||||
On Raspberry Pi, these libraries are required as well:
|
On Raspberry Pi, these libraries are required as well:
|
||||||
|
|
||||||
- `libatlas-base-dev`
|
- `libatlas-base-dev`
|
||||||
- `libxslt1-dev`
|
- `libxslt1-dev`
|
||||||
|
- `mime-support`
|
||||||
|
|
||||||
You will also need these for installing some of the python dependencies:
|
You will also need these for installing some of the python dependencies:
|
||||||
|
|
||||||
- `build-essential`
|
- `build-essential`
|
||||||
- `python3-setuptools`
|
- `python3-setuptools`
|
||||||
- `python3-wheel`
|
- `python3-wheel`
|
||||||
|
|
||||||
Use this list for your preferred package management:
|
Use this list for your preferred package management:
|
||||||
|
|
||||||
@@ -361,33 +361,33 @@ are released, dependency support is confirmed, etc.
|
|||||||
needs. Required settings for getting
|
needs. Required settings for getting
|
||||||
paperless running are:
|
paperless running are:
|
||||||
|
|
||||||
- [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your redis server, such as
|
- [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) should point to your redis server, such as
|
||||||
<redis://localhost:6379>.
|
<redis://localhost:6379>.
|
||||||
- [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) optional, and should be one of `postgres`,
|
- [`PAPERLESS_DBENGINE`](configuration.md#PAPERLESS_DBENGINE) optional, and should be one of `postgres`,
|
||||||
`mariadb`, or `sqlite`
|
`mariadb`, or `sqlite`
|
||||||
- [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your
|
- [`PAPERLESS_DBHOST`](configuration.md#PAPERLESS_DBHOST) should be the hostname on which your
|
||||||
PostgreSQL server is running. Do not configure this to use
|
PostgreSQL server is running. Do not configure this to use
|
||||||
SQLite instead. Also configure port, database name, user and
|
SQLite instead. Also configure port, database name, user and
|
||||||
password as necessary.
|
password as necessary.
|
||||||
- [`PAPERLESS_CONSUMPTION_DIR`](configuration.md#PAPERLESS_CONSUMPTION_DIR) should point to a folder which
|
- [`PAPERLESS_CONSUMPTION_DIR`](configuration.md#PAPERLESS_CONSUMPTION_DIR) should point to a folder which
|
||||||
paperless should watch for documents. You might want to have
|
paperless should watch for documents. You might want to have
|
||||||
this somewhere else. Likewise, [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) and
|
this somewhere else. Likewise, [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) and
|
||||||
[`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) define where paperless stores its data.
|
[`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) define where paperless stores its data.
|
||||||
If you like, you can point both to the same directory.
|
If you like, you can point both to the same directory.
|
||||||
- [`PAPERLESS_SECRET_KEY`](configuration.md#PAPERLESS_SECRET_KEY) should be a random sequence of
|
- [`PAPERLESS_SECRET_KEY`](configuration.md#PAPERLESS_SECRET_KEY) should be a random sequence of
|
||||||
characters. It's used for authentication. Failure to do so
|
characters. It's used for authentication. Failure to do so
|
||||||
allows third parties to forge authentication credentials.
|
allows third parties to forge authentication credentials.
|
||||||
- [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) if you are behind a reverse proxy. This should
|
- [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) if you are behind a reverse proxy. This should
|
||||||
point to your domain. Please see
|
point to your domain. Please see
|
||||||
[configuration](configuration.md) for more
|
[configuration](configuration.md) for more
|
||||||
information.
|
information.
|
||||||
|
|
||||||
Many more adjustments can be made to paperless, especially the OCR
|
Many more adjustments can be made to paperless, especially the OCR
|
||||||
part. The following options are recommended for everyone:
|
part. The following options are recommended for everyone:
|
||||||
|
|
||||||
- Set [`PAPERLESS_OCR_LANGUAGE`](configuration.md#PAPERLESS_OCR_LANGUAGE) to the language most of your
|
- Set [`PAPERLESS_OCR_LANGUAGE`](configuration.md#PAPERLESS_OCR_LANGUAGE) to the language most of your
|
||||||
documents are written in.
|
documents are written in.
|
||||||
- Set [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to your local time zone.
|
- Set [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to your local time zone.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
@@ -395,9 +395,9 @@ are released, dependency support is confirmed, etc.
|
|||||||
|
|
||||||
7. Create the following directories if they are missing:
|
7. Create the following directories if they are missing:
|
||||||
|
|
||||||
- `/opt/paperless/media`
|
- `/opt/paperless/media`
|
||||||
- `/opt/paperless/data`
|
- `/opt/paperless/data`
|
||||||
- `/opt/paperless/consume`
|
- `/opt/paperless/consume`
|
||||||
|
|
||||||
Adjust as necessary if you configured different folders.
|
Adjust as necessary if you configured different folders.
|
||||||
Ensure that the paperless user has write permissions for every one
|
Ensure that the paperless user has write permissions for every one
|
||||||
@@ -540,8 +540,7 @@ are released, dependency support is confirmed, etc.
|
|||||||
15. Optional: If using the NLTK machine learning processing (see
|
15. Optional: If using the NLTK machine learning processing (see
|
||||||
[`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) for details),
|
[`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) for details),
|
||||||
download the NLTK data for the Snowball
|
download the NLTK data for the Snowball
|
||||||
Stemmer, Stopwords and Punkt tokenizer to your
|
Stemmer, Stopwords and Punkt tokenizer to `/usr/share/nltk_data`. Refer to the [NLTK
|
||||||
`PAPERLESS_DATA_DIR/nltk`. Refer to the [NLTK
|
|
||||||
instructions](https://www.nltk.org/data.html) for details on how to
|
instructions](https://www.nltk.org/data.html) for details on how to
|
||||||
download the data.
|
download the data.
|
||||||
|
|
||||||
@@ -587,21 +586,21 @@ your setup depending on how you installed paperless.
|
|||||||
This setup describes how to update an existing paperless Docker
|
This setup describes how to update an existing paperless Docker
|
||||||
installation. The important things to keep in mind are as follows:
|
installation. The important things to keep in mind are as follows:
|
||||||
|
|
||||||
- Read the [changelog](changelog.md) and
|
- Read the [changelog](changelog.md) and
|
||||||
take note of breaking changes.
|
take note of breaking changes.
|
||||||
- You should decide if you want to stick with SQLite or want to
|
- You should decide if you want to stick with SQLite or want to
|
||||||
migrate your database to PostgreSQL. See [documentation](#sqlite_to_psql)
|
migrate your database to PostgreSQL. See [documentation](#sqlite_to_psql)
|
||||||
for details on
|
for details on
|
||||||
how to move your data from SQLite to PostgreSQL. Both work fine with
|
how to move your data from SQLite to PostgreSQL. Both work fine with
|
||||||
paperless. However, if you already have a database server running
|
paperless. However, if you already have a database server running
|
||||||
for other services, you might as well use it for paperless as well.
|
for other services, you might as well use it for paperless as well.
|
||||||
- The task scheduler of paperless, which is used to execute periodic
|
- The task scheduler of paperless, which is used to execute periodic
|
||||||
tasks such as email checking and maintenance, requires a
|
tasks such as email checking and maintenance, requires a
|
||||||
[redis](https://redis.io/) message broker instance. The
|
[redis](https://redis.io/) message broker instance. The
|
||||||
Docker Compose route takes care of that.
|
Docker Compose route takes care of that.
|
||||||
- The layout of the folder structure for your documents and data
|
- The layout of the folder structure for your documents and data
|
||||||
remains the same, so you can just plug your old docker volumes into
|
remains the same, so you can just plug your old docker volumes into
|
||||||
paperless-ngx and expect it to find everything where it should be.
|
paperless-ngx and expect it to find everything where it should be.
|
||||||
|
|
||||||
Migration to paperless-ngx is then performed in a few simple steps:
|
Migration to paperless-ngx is then performed in a few simple steps:
|
||||||
|
|
||||||
@@ -764,30 +763,30 @@ Paperless runs on Raspberry Pi. However, some things are rather slow on
|
|||||||
the Pi and configuring some options in paperless can help improve
|
the Pi and configuring some options in paperless can help improve
|
||||||
performance immensely:
|
performance immensely:
|
||||||
|
|
||||||
- Stick with SQLite to save some resources.
|
- Stick with SQLite to save some resources.
|
||||||
- Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that paperless will
|
- Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that paperless will
|
||||||
only OCR the first page of your documents. In most cases, this page
|
only OCR the first page of your documents. In most cases, this page
|
||||||
contains enough information to be able to find it.
|
contains enough information to be able to find it.
|
||||||
- [`PAPERLESS_TASK_WORKERS`](configuration.md#PAPERLESS_TASK_WORKERS) and [`PAPERLESS_THREADS_PER_WORKER`](configuration.md#PAPERLESS_THREADS_PER_WORKER) are
|
- [`PAPERLESS_TASK_WORKERS`](configuration.md#PAPERLESS_TASK_WORKERS) and [`PAPERLESS_THREADS_PER_WORKER`](configuration.md#PAPERLESS_THREADS_PER_WORKER) are
|
||||||
configured to use all cores. The Raspberry Pi models 3 and up have 4
|
configured to use all cores. The Raspberry Pi models 3 and up have 4
|
||||||
cores, meaning that paperless will use 2 workers and 2 threads per
|
cores, meaning that paperless will use 2 workers and 2 threads per
|
||||||
worker. This may result in sluggish response times during
|
worker. This may result in sluggish response times during
|
||||||
consumption, so you might want to lower these settings (example: 2
|
consumption, so you might want to lower these settings (example: 2
|
||||||
workers and 1 thread to always have some computing power left for
|
workers and 1 thread to always have some computing power left for
|
||||||
other tasks).
|
other tasks).
|
||||||
- Keep [`PAPERLESS_OCR_MODE`](configuration.md#PAPERLESS_OCR_MODE) at its default value `skip` and consider
|
- Keep [`PAPERLESS_OCR_MODE`](configuration.md#PAPERLESS_OCR_MODE) at its default value `skip` and consider
|
||||||
OCR'ing your documents before feeding them into paperless. Some
|
OCR'ing your documents before feeding them into paperless. Some
|
||||||
scanners are able to do this!
|
scanners are able to do this!
|
||||||
- Set [`PAPERLESS_OCR_SKIP_ARCHIVE_FILE`](configuration.md#PAPERLESS_OCR_SKIP_ARCHIVE_FILE) to `with_text` to skip archive
|
- Set [`PAPERLESS_OCR_SKIP_ARCHIVE_FILE`](configuration.md#PAPERLESS_OCR_SKIP_ARCHIVE_FILE) to `with_text` to skip archive
|
||||||
file generation for already ocr'ed documents, or `always` to skip it
|
file generation for already ocr'ed documents, or `always` to skip it
|
||||||
for all documents.
|
for all documents.
|
||||||
- If you want to perform OCR on the device, consider using
|
- If you want to perform OCR on the device, consider using
|
||||||
`PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use
|
`PAPERLESS_OCR_CLEAN=none`. This will speed up OCR times and use
|
||||||
less memory at the expense of slightly worse OCR results.
|
less memory at the expense of slightly worse OCR results.
|
||||||
- If using docker, consider setting [`PAPERLESS_WEBSERVER_WORKERS`](configuration.md#PAPERLESS_WEBSERVER_WORKERS) to 1. This will save some memory.
|
- If using docker, consider setting [`PAPERLESS_WEBSERVER_WORKERS`](configuration.md#PAPERLESS_WEBSERVER_WORKERS) to 1. This will save some memory.
|
||||||
- Consider setting [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) to false, to disable the
|
- Consider setting [`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) to false, to disable the
|
||||||
more advanced language processing, which can take more memory and
|
more advanced language processing, which can take more memory and
|
||||||
processing time.
|
processing time.
|
||||||
|
|
||||||
For details, refer to [configuration](configuration.md).
|
For details, refer to [configuration](configuration.md).
|
||||||
|
|
||||||
|
@@ -4,27 +4,27 @@
|
|||||||
|
|
||||||
Check for the following issues:
|
Check for the following issues:
|
||||||
|
|
||||||
- Ensure that the directory you're putting your documents in is the
|
- Ensure that the directory you're putting your documents in is the
|
||||||
folder paperless is watching. With docker, this setting is performed
|
folder paperless is watching. With docker, this setting is performed
|
||||||
in the `docker-compose.yml` file. Without Docker, look at the
|
in the `docker-compose.yml` file. Without Docker, look at the
|
||||||
`CONSUMPTION_DIR` setting. Don't adjust this setting if you're
|
`CONSUMPTION_DIR` setting. Don't adjust this setting if you're
|
||||||
using docker.
|
using docker.
|
||||||
|
|
||||||
- Ensure that redis is up and running. Paperless does its task
|
- Ensure that redis is up and running. Paperless does its task
|
||||||
processing asynchronously, and for documents to arrive at the task
|
processing asynchronously, and for documents to arrive at the task
|
||||||
processor, it needs redis to run.
|
processor, it needs redis to run.
|
||||||
|
|
||||||
- Ensure that the task processor is running. Docker does this
|
- Ensure that the task processor is running. Docker does this
|
||||||
automatically. Manually invoke the task processor by executing
|
automatically. Manually invoke the task processor by executing
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
$ celery --app paperless worker
|
$ celery --app paperless worker
|
||||||
```
|
```
|
||||||
|
|
||||||
- Look at the output of paperless and inspect it for any errors.
|
- Look at the output of paperless and inspect it for any errors.
|
||||||
|
|
||||||
- Go to the admin interface, and check if there are failed tasks. If
|
- Go to the admin interface, and check if there are failed tasks. If
|
||||||
so, the tasks will contain an error message.
|
so, the tasks will contain an error message.
|
||||||
|
|
||||||
## Consumer warns `OCR for XX failed`
|
## Consumer warns `OCR for XX failed`
|
||||||
|
|
||||||
@@ -78,12 +78,12 @@ Ensure that `chown` is possible on these directories.
|
|||||||
This indicates that the Auto matching algorithm found no documents to
|
This indicates that the Auto matching algorithm found no documents to
|
||||||
learn from. This may have two reasons:
|
learn from. This may have two reasons:
|
||||||
|
|
||||||
- You don't use the Auto matching algorithm: The error can be safely
|
- You don't use the Auto matching algorithm: The error can be safely
|
||||||
ignored in this case.
|
ignored in this case.
|
||||||
- You are using the Auto matching algorithm: The classifier explicitly
|
- You are using the Auto matching algorithm: The classifier explicitly
|
||||||
excludes documents with Inbox tags. Verify that there are documents
|
excludes documents with Inbox tags. Verify that there are documents
|
||||||
in your archive without inbox tags. The algorithm will only learn
|
in your archive without inbox tags. The algorithm will only learn
|
||||||
from documents not in your inbox.
|
from documents not in your inbox.
|
||||||
|
|
||||||
## UserWarning in sklearn on every single document
|
## UserWarning in sklearn on every single document
|
||||||
|
|
||||||
@@ -127,10 +127,10 @@ change in the `docker-compose.yml` file:
|
|||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
command:
|
command:
|
||||||
- 'gotenberg'
|
- 'gotenberg'
|
||||||
- '--chromium-disable-javascript=true'
|
- '--chromium-disable-javascript=true'
|
||||||
- '--chromium-allow-list=file:///tmp/.*'
|
- '--chromium-allow-list=file:///tmp/.*'
|
||||||
- '--api-timeout=60'
|
- '--api-timeout=60'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Permission denied errors in the consumption directory
|
## Permission denied errors in the consumption directory
|
||||||
@@ -353,6 +353,20 @@ ways from the original. As the logs indicate, if you encounter this error you ca
|
|||||||
`PAPERLESS_OCR_USER_ARGS: '{"continue_on_soft_render_error": true}'` to try to 'force'
|
`PAPERLESS_OCR_USER_ARGS: '{"continue_on_soft_render_error": true}'` to try to 'force'
|
||||||
processing documents with this issue.
|
processing documents with this issue.
|
||||||
|
|
||||||
|
## Logs show "possible incompatible database column" when deleting documents {#convert-uuid-field}
|
||||||
|
|
||||||
|
You may see errors when deleting documents like:
|
||||||
|
|
||||||
|
```
|
||||||
|
Data too long for column 'transaction_id' at row 1
|
||||||
|
```
|
||||||
|
|
||||||
|
This error can occur in installations which have upgraded from a version of Paperless-ngx that used Django 4 (Paperless-ngx versions prior to v2.13.0) with a MariaDB/MySQL database. Due to the backawards-incompatible change in Django 5, the column "documents_document.transaction_id" will need to be re-created, which can be done with a one-time run of the following management command:
|
||||||
|
|
||||||
|
```shell-session
|
||||||
|
$ python3 manage.py convert_mariadb_uuid
|
||||||
|
```
|
||||||
|
|
||||||
## Platform-Specific Deployment Troubleshooting
|
## Platform-Specific Deployment Troubleshooting
|
||||||
|
|
||||||
A user-maintained wiki page is available to help troubleshoot issues that may arise when trying to deploy Paperless-ngx on specific platforms, for example SELinux. Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Platform%E2%80%90Specific-Troubleshooting).
|
A user-maintained wiki page is available to help troubleshoot issues that may arise when trying to deploy Paperless-ngx on specific platforms, for example SELinux. Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Platform%E2%80%90Specific-Troubleshooting).
|
||||||
|
351
docs/usage.md
351
docs/usage.md
@@ -10,37 +10,37 @@ and provides many utilities for finding and managing your documents.
|
|||||||
Paperless essentially consists of two different parts for managing your
|
Paperless essentially consists of two different parts for managing your
|
||||||
documents:
|
documents:
|
||||||
|
|
||||||
- The _consumer_ watches a specified folder and adds all documents in
|
- The _consumer_ watches a specified folder and adds all documents in
|
||||||
that folder to paperless.
|
that folder to paperless.
|
||||||
- The _web server_ provides a UI that you use to manage and search for
|
- The _web server_ provides a UI that you use to manage and search for
|
||||||
your scanned documents.
|
your scanned documents.
|
||||||
|
|
||||||
Each document has a couple of fields that you can assign to them:
|
Each document has a couple of fields that you can assign to them:
|
||||||
|
|
||||||
- A _Document_ is a piece of paper that sometimes contains valuable
|
- A _Document_ is a piece of paper that sometimes contains valuable
|
||||||
information.
|
information.
|
||||||
- The _correspondent_ of a document is the person, institution or
|
- The _correspondent_ of a document is the person, institution or
|
||||||
company that a document either originates from, or is sent to.
|
company that a document either originates from, or is sent to.
|
||||||
- A _tag_ is a label that you can assign to documents. Think of labels
|
- A _tag_ is a label that you can assign to documents. Think of labels
|
||||||
as more powerful folders: Multiple documents can be grouped together
|
as more powerful folders: Multiple documents can be grouped together
|
||||||
with a single tag, however, a single document can also have multiple
|
with a single tag, however, a single document can also have multiple
|
||||||
tags. This is not possible with folders. The reason folders are not
|
tags. This is not possible with folders. The reason folders are not
|
||||||
implemented in paperless is simply that tags are much more versatile
|
implemented in paperless is simply that tags are much more versatile
|
||||||
than folders.
|
than folders.
|
||||||
- A _document type_ is used to demarcate the type of a document such
|
- A _document type_ is used to demarcate the type of a document such
|
||||||
as letter, bank statement, invoice, contract, etc. It is used to
|
as letter, bank statement, invoice, contract, etc. It is used to
|
||||||
identify what a document is about.
|
identify what a document is about.
|
||||||
- The _date added_ of a document is the date the document was scanned
|
- The _date added_ of a document is the date the document was scanned
|
||||||
into paperless. You cannot and should not change this date.
|
into paperless. You cannot and should not change this date.
|
||||||
- The _date created_ of a document is the date the document was
|
- The _date created_ of a document is the date the document was
|
||||||
initially issued. This can be the date you bought a product, the
|
initially issued. This can be the date you bought a product, the
|
||||||
date you signed a contract, or the date a letter was sent to you.
|
date you signed a contract, or the date a letter was sent to you.
|
||||||
- The _archive serial number_ (short: ASN) of a document is the
|
- The _archive serial number_ (short: ASN) of a document is the
|
||||||
identifier of the document in your physical document binders. See
|
identifier of the document in your physical document binders. See
|
||||||
[recommended workflow](#usage-recommended-workflow) below.
|
[recommended workflow](#usage-recommended-workflow) below.
|
||||||
- The _content_ of a document is the text that was OCR'ed from the
|
- The _content_ of a document is the text that was OCR'ed from the
|
||||||
document. This text is fed into the search engine and is used for
|
document. This text is fed into the search engine and is used for
|
||||||
matching tags, correspondents and document types.
|
matching tags, correspondents and document types.
|
||||||
|
|
||||||
## Adding documents to paperless
|
## Adding documents to paperless
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ process.
|
|||||||
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects) for a user-maintained list of related projects and
|
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects) for a user-maintained list of related projects and
|
||||||
software (e.g. for mobile devices) that is compatible with Paperless-ngx.
|
software (e.g. for mobile devices) that is compatible with Paperless-ngx.
|
||||||
|
|
||||||
### IMAP (Email) {#usage-email}
|
### Email {#usage-email}
|
||||||
|
|
||||||
You can tell paperless-ngx to consume documents from your email
|
You can tell paperless-ngx to consume documents from your email
|
||||||
accounts. This is a very flexible and powerful feature, if you regularly
|
accounts. This is a very flexible and powerful feature, if you regularly
|
||||||
@@ -136,26 +136,27 @@ These rules perform the following:
|
|||||||
|
|
||||||
Paperless will check all emails only once and completely ignore messages
|
Paperless will check all emails only once and completely ignore messages
|
||||||
that do not match your filters. It will also only perform the rule action
|
that do not match your filters. It will also only perform the rule action
|
||||||
on e-mails that it has consumed documents from.
|
on e-mails that it has consumed documents from. The filename attachment
|
||||||
|
patterns can include wildcards and multiple patterns separated by a comma.
|
||||||
|
|
||||||
The actions all ensure that the same mail is not consumed twice by
|
The actions all ensure that the same mail is not consumed twice by
|
||||||
different means. These are as follows:
|
different means. These are as follows:
|
||||||
|
|
||||||
- **Delete:** Immediately deletes mail that paperless has consumed
|
- **Delete:** Immediately deletes mail that paperless has consumed
|
||||||
documents from. Use with caution.
|
documents from. Use with caution.
|
||||||
- **Mark as read:** Mark consumed mail as read. Paperless will not
|
- **Mark as read:** Mark consumed mail as read. Paperless will not
|
||||||
consume documents from already read mails. If you read a mail before
|
consume documents from already read mails. If you read a mail before
|
||||||
paperless sees it, it will be ignored.
|
paperless sees it, it will be ignored.
|
||||||
- **Flag:** Sets the 'important' flag on mails with consumed
|
- **Flag:** Sets the 'important' flag on mails with consumed
|
||||||
documents. Paperless will not consume flagged mails.
|
documents. Paperless will not consume flagged mails.
|
||||||
- **Move to folder:** Moves consumed mails out of the way so that
|
- **Move to folder:** Moves consumed mails out of the way so that
|
||||||
paperless won't consume them again.
|
paperless won't consume them again.
|
||||||
- **Add custom Tag:** Adds a custom tag to mails with consumed
|
- **Add custom Tag:** Adds a custom tag to mails with consumed
|
||||||
documents (the IMAP standard calls these "keywords"). Paperless
|
documents (the IMAP standard calls these "keywords"). Paperless
|
||||||
will not consume mails already tagged. Not all mail servers support
|
will not consume mails already tagged. Not all mail servers support
|
||||||
this feature!
|
this feature!
|
||||||
|
|
||||||
- **Apple Mail support:** Apple Mail clients allow differently colored tags. For this to work use `apple:<color>` (e.g. _apple:green_) as a custom tag. Available colors are _red_, _orange_, _yellow_, _blue_, _green_, _violet_ and _grey_.
|
- **Apple Mail support:** Apple Mail clients allow differently colored tags. For this to work use `apple:<color>` (e.g. _apple:green_) as a custom tag. Available colors are _red_, _orange_, _yellow_, _blue_, _green_, _violet_ and _grey_.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
@@ -199,6 +200,14 @@ different means. These are as follows:
|
|||||||
Paperless is set up to check your mails every 10 minutes. This can be
|
Paperless is set up to check your mails every 10 minutes. This can be
|
||||||
configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON)
|
configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON)
|
||||||
|
|
||||||
|
#### OAuth Email Setup
|
||||||
|
|
||||||
|
Paperless-ngx supports OAuth2 authentication for Gmail and Outlook email accounts. To set up an email account with OAuth2, you will need to create a 'developer' app with the respective provider and obtain the client ID and client secret and set the appropriate [configuration variables](configuration.md#email_oauth). You will also need to set either [`PAPERLESS_OAUTH_CALLBACK_BASE_URL`](configuration.md#PAPERLESS_OAUTH_CALLBACK_BASE_URL) or [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) to the correct value for the OAuth2 flow to work correctly.
|
||||||
|
|
||||||
|
Specific instructions for setting up the required 'developer' app with Google or Microsoft are beyond the scope of this documentation, but you can find user-maintained instructions in [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Email-OAuth-App-Setup) or by searching the web.
|
||||||
|
|
||||||
|
Once setup, navigating to the email settings page in Paperless-ngx will allow you to add an email account for Gmail or Outlook using OAuth2. After authenticating, you will be presented with the newly-created account where you will need to enter and save your email address. After this, the account will work as any other email account in Paperless-ngx and refreshing tokens will be handled automatically.
|
||||||
|
|
||||||
### REST API
|
### REST API
|
||||||
|
|
||||||
You can also submit a document using the REST API, see [POSTing documents](api.md#file-uploads)
|
You can also submit a document using the REST API, see [POSTing documents](api.md#file-uploads)
|
||||||
@@ -351,32 +360,32 @@ flowchart TD
|
|||||||
|
|
||||||
Workflows allow you to filter by:
|
Workflows allow you to filter by:
|
||||||
|
|
||||||
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
|
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
|
||||||
- File name, including wildcards e.g. \*.pdf will apply to all pdfs
|
- File name, including wildcards e.g. \*.pdf will apply to all pdfs
|
||||||
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
||||||
example, automatically assigning documents to different owners based on the upload directory.
|
example, automatically assigning documents to different owners based on the upload directory.
|
||||||
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
||||||
- Content matching (`Added` and `Updated` triggers only). Filter document content using the matching settings.
|
- Content matching (`Added` and `Updated` triggers only). Filter document content using the matching settings.
|
||||||
- Tags (`Added` and `Updated` triggers only). Filter for documents with any of the specified tags
|
- Tags (`Added` and `Updated` triggers only). Filter for documents with any of the specified tags
|
||||||
- Document type (`Added` and `Updated` triggers only). Filter documents with this doc type
|
- Document type (`Added` and `Updated` triggers only). Filter documents with this doc type
|
||||||
- Correspondent (`Added` and `Updated` triggers only). Filter documents with this correspondent
|
- Correspondent (`Added` and `Updated` triggers only). Filter documents with this correspondent
|
||||||
|
|
||||||
### Workflow Actions
|
### Workflow Actions
|
||||||
|
|
||||||
There are currently two types of workflow actions, "Assignment", which can assign:
|
There are currently two types of workflow actions, "Assignment", which can assign:
|
||||||
|
|
||||||
- Title, see [title placeholders](usage.md#title-placeholders) below
|
- Title, see [title placeholders](usage.md#title-placeholders) below
|
||||||
- Tags, correspondent, document type and storage path
|
- Tags, correspondent, document type and storage path
|
||||||
- Document owner
|
- Document owner
|
||||||
- View and / or edit permissions to users or groups
|
- View and / or edit permissions to users or groups
|
||||||
- Custom fields. Note that no value for the field will be set
|
- Custom fields. Note that no value for the field will be set
|
||||||
|
|
||||||
and "Removal" actions, which can remove either all of or specific sets of the following:
|
and "Removal" actions, which can remove either all of or specific sets of the following:
|
||||||
|
|
||||||
- Tags, correspondents, document types or storage paths
|
- Tags, correspondents, document types or storage paths
|
||||||
- Document owner
|
- Document owner
|
||||||
- View and / or edit permissions
|
- View and / or edit permissions
|
||||||
- Custom fields
|
- Custom fields
|
||||||
|
|
||||||
#### Title placeholders
|
#### Title placeholders
|
||||||
|
|
||||||
@@ -384,29 +393,29 @@ Workflow titles can include placeholders but the available options differ depend
|
|||||||
workflow trigger. This is because at the time of consumption (when the title is to be set), no automatic tags etc. have been
|
workflow trigger. This is because at the time of consumption (when the title is to be set), no automatic tags etc. have been
|
||||||
applied. You can use the following placeholders with any trigger type:
|
applied. You can use the following placeholders with any trigger type:
|
||||||
|
|
||||||
- `{correspondent}`: assigned correspondent name
|
- `{correspondent}`: assigned correspondent name
|
||||||
- `{document_type}`: assigned document type name
|
- `{document_type}`: assigned document type name
|
||||||
- `{owner_username}`: assigned owner username
|
- `{owner_username}`: assigned owner username
|
||||||
- `{added}`: added datetime
|
- `{added}`: added datetime
|
||||||
- `{added_year}`: added year
|
- `{added_year}`: added year
|
||||||
- `{added_year_short}`: added year
|
- `{added_year_short}`: added year
|
||||||
- `{added_month}`: added month
|
- `{added_month}`: added month
|
||||||
- `{added_month_name}`: added month name
|
- `{added_month_name}`: added month name
|
||||||
- `{added_month_name_short}`: added month short name
|
- `{added_month_name_short}`: added month short name
|
||||||
- `{added_day}`: added day
|
- `{added_day}`: added day
|
||||||
- `{added_time}`: added time in HH:MM format
|
- `{added_time}`: added time in HH:MM format
|
||||||
- `{original_filename}`: original file name without extension
|
- `{original_filename}`: original file name without extension
|
||||||
|
|
||||||
The following placeholders are only available for "added" or "updated" triggers
|
The following placeholders are only available for "added" or "updated" triggers
|
||||||
|
|
||||||
- `{created}`: created datetime
|
- `{created}`: created datetime
|
||||||
- `{created_year}`: created year
|
- `{created_year}`: created year
|
||||||
- `{created_year_short}`: created year
|
- `{created_year_short}`: created year
|
||||||
- `{created_month}`: created month
|
- `{created_month}`: created month
|
||||||
- `{created_month_name}`: created month name
|
- `{created_month_name}`: created month name
|
||||||
- `{created_month_name_short}`: created month short name
|
- `{created_month_name_short}`: created month short name
|
||||||
- `{created_day}`: created day
|
- `{created_day}`: created day
|
||||||
- `{created_time}`: created time in HH:MM format
|
- `{created_time}`: created time in HH:MM format
|
||||||
|
|
||||||
### Workflow permissions
|
### Workflow permissions
|
||||||
|
|
||||||
@@ -441,24 +450,24 @@ Multiple fields may be attached to a document but the same field name cannot be
|
|||||||
|
|
||||||
The following custom field types are supported:
|
The following custom field types are supported:
|
||||||
|
|
||||||
- `Text`: any text
|
- `Text`: any text
|
||||||
- `Boolean`: true / false (check / unchecked) field
|
- `Boolean`: true / false (check / unchecked) field
|
||||||
- `Date`: date
|
- `Date`: date
|
||||||
- `URL`: a valid url
|
- `URL`: a valid url
|
||||||
- `Integer`: integer number e.g. 12
|
- `Integer`: integer number e.g. 12
|
||||||
- `Number`: float number e.g. 12.3456
|
- `Number`: float number e.g. 12.3456
|
||||||
- `Monetary`: [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes) and a number with exactly two decimals, e.g. USD12.30
|
- `Monetary`: [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes) and a number with exactly two decimals, e.g. USD12.30
|
||||||
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
||||||
- `Select`: a pre-defined list of strings from which the user can choose
|
- `Select`: a pre-defined list of strings from which the user can choose
|
||||||
|
|
||||||
## Share Links
|
## Share Links
|
||||||
|
|
||||||
Paperless-ngx added the ability to create shareable links to files in version 2.0. You can find the button for this on the document detail screen.
|
Paperless-ngx added the ability to create shareable links to files in version 2.0. You can find the button for this on the document detail screen.
|
||||||
|
|
||||||
- Share links do not require a user to login and thus link directly to a file.
|
- Share links do not require a user to login and thus link directly to a file.
|
||||||
- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
|
- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
|
||||||
- Links can optionally have an expiration time set.
|
- Links can optionally have an expiration time set.
|
||||||
- After a link expires or is deleted users will be redirected to the regular paperless-ngx login.
|
- After a link expires or is deleted users will be redirected to the regular paperless-ngx login.
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
|
|
||||||
@@ -468,10 +477,10 @@ Paperless-ngx added the ability to create shareable links to files in version 2.
|
|||||||
|
|
||||||
Paperless-ngx supports four basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files):
|
Paperless-ngx supports four basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files):
|
||||||
|
|
||||||
- Merging documents: available when selecting multiple documents for 'bulk editing'.
|
- Merging documents: available when selecting multiple documents for 'bulk editing'.
|
||||||
- Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page.
|
- Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page.
|
||||||
- Splitting documents: available from an individual document's details page.
|
- Splitting documents: available from an individual document's details page.
|
||||||
- Deleting pages: available from an individual document's details page.
|
- Deleting pages: available from an individual document's details page.
|
||||||
|
|
||||||
!!! important
|
!!! important
|
||||||
|
|
||||||
@@ -549,18 +558,18 @@ the system.
|
|||||||
Here are a couple examples of tags and types that you could use in your
|
Here are a couple examples of tags and types that you could use in your
|
||||||
collection.
|
collection.
|
||||||
|
|
||||||
- An `inbox` tag for newly added documents that you haven't manually
|
- An `inbox` tag for newly added documents that you haven't manually
|
||||||
edited yet.
|
edited yet.
|
||||||
- A tag `car` for everything car related (repairs, registration,
|
- A tag `car` for everything car related (repairs, registration,
|
||||||
insurance, etc)
|
insurance, etc)
|
||||||
- A tag `todo` for documents that you still need to do something with,
|
- A tag `todo` for documents that you still need to do something with,
|
||||||
such as reply, or perform some task online.
|
such as reply, or perform some task online.
|
||||||
- A tag `bank account x` for all bank statement related to that
|
- A tag `bank account x` for all bank statement related to that
|
||||||
account.
|
account.
|
||||||
- A tag `mail` for anything that you added to paperless via its mail
|
- A tag `mail` for anything that you added to paperless via its mail
|
||||||
processing capabilities.
|
processing capabilities.
|
||||||
- A tag `missing_metadata` when you still need to add some metadata to
|
- A tag `missing_metadata` when you still need to add some metadata to
|
||||||
a document, but can't or don't want to do this right now.
|
a document, but can't or don't want to do this right now.
|
||||||
|
|
||||||
## Searching {#basic-usage_searching}
|
## Searching {#basic-usage_searching}
|
||||||
|
|
||||||
@@ -649,8 +658,8 @@ The following diagram shows how easy it is to manage your documents.
|
|||||||
|
|
||||||
### Preparations in paperless
|
### Preparations in paperless
|
||||||
|
|
||||||
- Create an inbox tag that gets assigned to all new documents.
|
- Create an inbox tag that gets assigned to all new documents.
|
||||||
- Create a TODO tag.
|
- Create a TODO tag.
|
||||||
|
|
||||||
### Processing of the physical documents
|
### Processing of the physical documents
|
||||||
|
|
||||||
@@ -724,78 +733,78 @@ Some documents require attention and require you to act on the document.
|
|||||||
You may take two different approaches to handle these documents based on
|
You may take two different approaches to handle these documents based on
|
||||||
how regularly you intend to scan documents and use paperless.
|
how regularly you intend to scan documents and use paperless.
|
||||||
|
|
||||||
- If you scan and process your documents in paperless regularly,
|
- If you scan and process your documents in paperless regularly,
|
||||||
assign a TODO tag to all scanned documents that you need to process.
|
assign a TODO tag to all scanned documents that you need to process.
|
||||||
Create a saved view on the dashboard that shows all documents with
|
Create a saved view on the dashboard that shows all documents with
|
||||||
this tag.
|
this tag.
|
||||||
- If you do not scan documents regularly and use paperless solely for
|
- If you do not scan documents regularly and use paperless solely for
|
||||||
archiving, create a physical todo box next to your physical inbox
|
archiving, create a physical todo box next to your physical inbox
|
||||||
and put documents you need to process in the TODO box. When you
|
and put documents you need to process in the TODO box. When you
|
||||||
performed the task associated with the document, move it to the
|
performed the task associated with the document, move it to the
|
||||||
inbox.
|
inbox.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Paperless-ngx consists of the following components:
|
Paperless-ngx consists of the following components:
|
||||||
|
|
||||||
- **The webserver:** This serves the administration pages, the API,
|
- **The webserver:** This serves the administration pages, the API,
|
||||||
and the new frontend. This is the main tool you'll be using to interact
|
and the new frontend. This is the main tool you'll be using to interact
|
||||||
with paperless. You may start the webserver directly with
|
with paperless. You may start the webserver directly with
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
$ cd /path/to/paperless/src/
|
$ cd /path/to/paperless/src/
|
||||||
$ gunicorn -c ../gunicorn.conf.py paperless.wsgi
|
$ gunicorn -c ../gunicorn.conf.py paperless.wsgi
|
||||||
```
|
```
|
||||||
|
|
||||||
or by any other means such as Apache `mod_wsgi`.
|
or by any other means such as Apache `mod_wsgi`.
|
||||||
|
|
||||||
- **The consumer:** This is what watches your consumption folder for
|
- **The consumer:** This is what watches your consumption folder for
|
||||||
documents. However, the consumer itself does not really consume your
|
documents. However, the consumer itself does not really consume your
|
||||||
documents. Now it notifies a task processor that a new file is ready
|
documents. Now it notifies a task processor that a new file is ready
|
||||||
for consumption. I suppose it should be named differently. This was
|
for consumption. I suppose it should be named differently. This was
|
||||||
also used to check your emails, but that's now done elsewhere as
|
also used to check your emails, but that's now done elsewhere as
|
||||||
well.
|
well.
|
||||||
|
|
||||||
Start the consumer with the management command `document_consumer`:
|
Start the consumer with the management command `document_consumer`:
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
$ cd /path/to/paperless/src/
|
$ cd /path/to/paperless/src/
|
||||||
$ python3 manage.py document_consumer
|
$ python3 manage.py document_consumer
|
||||||
```
|
```
|
||||||
|
|
||||||
- **The task processor:** Paperless relies on [Celery - Distributed
|
- **The task processor:** Paperless relies on [Celery - Distributed
|
||||||
Task Queue](https://docs.celeryq.dev/en/stable/index.html) for doing
|
Task Queue](https://docs.celeryq.dev/en/stable/index.html) for doing
|
||||||
most of the heavy lifting. This is a task queue that accepts tasks
|
most of the heavy lifting. This is a task queue that accepts tasks
|
||||||
from multiple sources and processes these in parallel. It also comes
|
from multiple sources and processes these in parallel. It also comes
|
||||||
with a scheduler that executes certain commands periodically.
|
with a scheduler that executes certain commands periodically.
|
||||||
|
|
||||||
This task processor is responsible for:
|
This task processor is responsible for:
|
||||||
|
|
||||||
- Consuming documents. When the consumer finds new documents, it
|
- Consuming documents. When the consumer finds new documents, it
|
||||||
notifies the task processor to start a consumption task.
|
notifies the task processor to start a consumption task.
|
||||||
- The task processor also performs the consumption of any
|
- The task processor also performs the consumption of any
|
||||||
documents you upload through the web interface.
|
documents you upload through the web interface.
|
||||||
- Consuming emails. It periodically checks your configured
|
- Consuming emails. It periodically checks your configured
|
||||||
accounts for new emails and notifies the task processor to
|
accounts for new emails and notifies the task processor to
|
||||||
consume the attachment of an email.
|
consume the attachment of an email.
|
||||||
- Maintaining the search index and the automatic matching
|
- Maintaining the search index and the automatic matching
|
||||||
algorithm. These are things that paperless needs to do from time
|
algorithm. These are things that paperless needs to do from time
|
||||||
to time in order to operate properly.
|
to time in order to operate properly.
|
||||||
|
|
||||||
This allows paperless to process multiple documents from your
|
This allows paperless to process multiple documents from your
|
||||||
consumption folder in parallel! On a modern multi core system, this
|
consumption folder in parallel! On a modern multi core system, this
|
||||||
makes the consumption process with full OCR blazingly fast.
|
makes the consumption process with full OCR blazingly fast.
|
||||||
|
|
||||||
The task processor comes with a built-in admin interface that you
|
The task processor comes with a built-in admin interface that you
|
||||||
can use to check whenever any of the tasks fail and inspect the
|
can use to check whenever any of the tasks fail and inspect the
|
||||||
errors (i.e., wrong email credentials, errors during consuming a
|
errors (i.e., wrong email credentials, errors during consuming a
|
||||||
specific file, etc).
|
specific file, etc).
|
||||||
|
|
||||||
- A [redis](https://redis.io/) message broker: This is a really
|
- A [redis](https://redis.io/) message broker: This is a really
|
||||||
lightweight service that is responsible for getting the tasks from
|
lightweight service that is responsible for getting the tasks from
|
||||||
the webserver and the consumer to the task scheduler. These run in a
|
the webserver and the consumer to the task scheduler. These run in a
|
||||||
different process (maybe even on different machines!), and
|
different process (maybe even on different machines!), and
|
||||||
therefore, this is necessary.
|
therefore, this is necessary.
|
||||||
|
|
||||||
- Optional: A database server. Paperless supports PostgreSQL, MariaDB
|
- Optional: A database server. Paperless supports PostgreSQL, MariaDB
|
||||||
and SQLite for storing its data.
|
and SQLite for storing its data.
|
||||||
|
@@ -6,6 +6,12 @@ theme:
|
|||||||
text: Roboto
|
text: Roboto
|
||||||
code: Roboto Mono
|
code: Roboto Mono
|
||||||
palette:
|
palette:
|
||||||
|
# Palette toggle for automatic mode
|
||||||
|
- media: "(prefers-color-scheme)"
|
||||||
|
toggle:
|
||||||
|
icon: material/brightness-auto
|
||||||
|
name: Switch to light mode
|
||||||
|
|
||||||
# Palette toggle for light mode
|
# Palette toggle for light mode
|
||||||
- media: "(prefers-color-scheme: light)"
|
- media: "(prefers-color-scheme: light)"
|
||||||
scheme: default
|
scheme: default
|
||||||
@@ -18,7 +24,7 @@ theme:
|
|||||||
scheme: slate
|
scheme: slate
|
||||||
toggle:
|
toggle:
|
||||||
icon: material/brightness-4
|
icon: material/brightness-4
|
||||||
name: Switch to light mode
|
name: Switch to system preference
|
||||||
features:
|
features:
|
||||||
- navigation.tabs
|
- navigation.tabs
|
||||||
- navigation.top
|
- navigation.top
|
||||||
|
@@ -18,6 +18,7 @@
|
|||||||
# Paths and folders
|
# Paths and folders
|
||||||
|
|
||||||
#PAPERLESS_CONSUMPTION_DIR=../consume
|
#PAPERLESS_CONSUMPTION_DIR=../consume
|
||||||
|
#PAPERLESS_CONSUMPTION_FAILED_DIR=../consume/failed
|
||||||
#PAPERLESS_DATA_DIR=../data
|
#PAPERLESS_DATA_DIR=../data
|
||||||
#PAPERLESS_EMPTY_TRASH_DIR=
|
#PAPERLESS_EMPTY_TRASH_DIR=
|
||||||
#PAPERLESS_MEDIA_ROOT=../media
|
#PAPERLESS_MEDIA_ROOT=../media
|
||||||
|
@@ -33,6 +33,7 @@
|
|||||||
"it-IT": "src/locale/messages.it_IT.xlf",
|
"it-IT": "src/locale/messages.it_IT.xlf",
|
||||||
"ja-JP": "src/locale/messages.ja_JP.xlf",
|
"ja-JP": "src/locale/messages.ja_JP.xlf",
|
||||||
"lb-LU": "src/locale/messages.lb_LU.xlf",
|
"lb-LU": "src/locale/messages.lb_LU.xlf",
|
||||||
|
"ko-KR": "src/locale/messages.ko_KR.xlf",
|
||||||
"nl-NL": "src/locale/messages.nl_NL.xlf",
|
"nl-NL": "src/locale/messages.nl_NL.xlf",
|
||||||
"no-NO": "src/locale/messages.no_NO.xlf",
|
"no-NO": "src/locale/messages.no_NO.xlf",
|
||||||
"pl-PL": "src/locale/messages.pl_PL.xlf",
|
"pl-PL": "src/locale/messages.pl_PL.xlf",
|
||||||
@@ -51,8 +52,11 @@
|
|||||||
},
|
},
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-devkit/build-angular:browser",
|
"builder": "@angular-builders/custom-webpack:browser",
|
||||||
"options": {
|
"options": {
|
||||||
|
"customWebpackConfig": {
|
||||||
|
"path": "./extra-webpack.config.ts"
|
||||||
|
},
|
||||||
"outputPath": "dist/paperless-ui",
|
"outputPath": "dist/paperless-ui",
|
||||||
"outputHashing": "none",
|
"outputHashing": "none",
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
@@ -66,8 +70,8 @@
|
|||||||
"src/assets",
|
"src/assets",
|
||||||
"src/manifest.webmanifest",
|
"src/manifest.webmanifest",
|
||||||
{
|
{
|
||||||
"glob": "{pdf.worker.min.js,pdf.min.js}",
|
"glob": "{pdf.worker.min.mjs,pdf.min.mjs}",
|
||||||
"input": "node_modules/pdfjs-dist/build/",
|
"input": "node_modules/pdfjs-dist/legacy/build/",
|
||||||
"output": "/assets/js/"
|
"output": "/assets/js/"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -124,7 +128,7 @@
|
|||||||
"defaultConfiguration": ""
|
"defaultConfiguration": ""
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular-builders/custom-webpack:dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "paperless-ui:build:en-US"
|
"buildTarget": "paperless-ui:build:en-US"
|
||||||
},
|
},
|
||||||
@@ -135,7 +139,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
"builder": "@angular-builders/custom-webpack:extract-i18n",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "paperless-ui:build"
|
"buildTarget": "paperless-ui:build"
|
||||||
}
|
}
|
||||||
|
@@ -84,13 +84,8 @@ test('date filtering', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Dates' }).click()
|
await page.getByRole('button', { name: 'Dates' }).click()
|
||||||
await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click()
|
await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click()
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
||||||
await page.getByRole('button', { name: 'Dates Clear selected' }).click()
|
await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click()
|
||||||
await page.getByRole('button', { name: 'Dates' }).click()
|
await page.getByLabel('Datesselected').getByRole('button').first().click()
|
||||||
await page
|
|
||||||
.getByRole('menuitem', { name: 'After mm/dd/yyyy' })
|
|
||||||
.getByRole('button')
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
|
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
|
||||||
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
|
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
|
||||||
await page.getByText('11', { exact: true }).click()
|
await page.getByText('11', { exact: true }).click()
|
||||||
|
24
src-ui/extra-webpack.config.ts
Normal file
24
src-ui/extra-webpack.config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as webpack from 'webpack'
|
||||||
|
import {
|
||||||
|
CustomWebpackBrowserSchema,
|
||||||
|
TargetOptions,
|
||||||
|
} from '@angular-builders/custom-webpack'
|
||||||
|
const { codecovWebpackPlugin } = require('@codecov/webpack-plugin')
|
||||||
|
|
||||||
|
export default (
|
||||||
|
config: webpack.Configuration,
|
||||||
|
options: CustomWebpackBrowserSchema,
|
||||||
|
targetOptions: TargetOptions
|
||||||
|
) => {
|
||||||
|
if (config.plugins) {
|
||||||
|
config.plugins.push(
|
||||||
|
codecovWebpackPlugin({
|
||||||
|
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
|
||||||
|
bundleName: 'paperless-ngx',
|
||||||
|
uploadToken: process.env.CODECOV_TOKEN,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
1620
src-ui/messages.xlf
1620
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
6430
src-ui/package-lock.json
generated
6430
src-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,60 +11,60 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^18.0.6",
|
"@angular/cdk": "^18.2.6",
|
||||||
"@angular/common": "~18.0.6",
|
"@angular/common": "~18.2.6",
|
||||||
"@angular/compiler": "~18.0.6",
|
"@angular/compiler": "~18.2.6",
|
||||||
"@angular/core": "~18.0.6",
|
"@angular/core": "~18.2.6",
|
||||||
"@angular/forms": "~18.0.6",
|
"@angular/forms": "~18.2.6",
|
||||||
"@angular/localize": "~18.0.6",
|
"@angular/localize": "~18.2.6",
|
||||||
"@angular/platform-browser": "~18.0.6",
|
"@angular/platform-browser": "~18.2.6",
|
||||||
"@angular/platform-browser-dynamic": "~18.0.6",
|
"@angular/platform-browser-dynamic": "~18.2.6",
|
||||||
"@angular/router": "~18.0.6",
|
"@angular/router": "~18.2.6",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^17.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^17.0.1",
|
||||||
"@ng-select/ng-select": "^13.4.1",
|
"@ng-select/ng-select": "^13.9.0",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ng2-pdf-viewer": "^10.2.2",
|
"ng2-pdf-viewer": "^10.3.1",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^9.0.0",
|
"ngx-color": "^9.0.0",
|
||||||
"ngx-cookie-service": "^18.0.0",
|
"ngx-cookie-service": "^18.0.0",
|
||||||
"ngx-file-drop": "^16.0.0",
|
"ngx-file-drop": "^16.0.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^15.0.0",
|
"ngx-ui-tour-ng-bootstrap": "^15.0.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.7.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^10.0.0",
|
||||||
"zone.js": "^0.14.4"
|
"zone.js": "^0.14.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@angular-builders/custom-webpack": "^18.0.0",
|
||||||
"@angular-builders/jest": "^18.0.0",
|
"@angular-builders/jest": "^18.0.0",
|
||||||
"@angular-devkit/build-angular": "^18.0.7",
|
"@angular-devkit/build-angular": "^18.2.2",
|
||||||
"@angular-devkit/core": "^18.0.7",
|
"@angular-devkit/core": "^18.2.6",
|
||||||
"@angular-devkit/schematics": "^18.0.7",
|
"@angular-devkit/schematics": "^18.2.6",
|
||||||
"@angular-eslint/builder": "18.1.0",
|
"@angular-eslint/builder": "18.3.1",
|
||||||
"@angular-eslint/eslint-plugin": "18.1.0",
|
"@angular-eslint/eslint-plugin": "18.3.1",
|
||||||
"@angular-eslint/eslint-plugin-template": "18.1.0",
|
"@angular-eslint/eslint-plugin-template": "18.3.1",
|
||||||
"@angular-eslint/schematics": "18.1.0",
|
"@angular-eslint/schematics": "18.3.1",
|
||||||
"@angular-eslint/template-parser": "18.1.0",
|
"@angular-eslint/template-parser": "18.3.1",
|
||||||
"@angular/cli": "~18.0.7",
|
"@angular/cli": "~18.2.6",
|
||||||
"@angular/compiler-cli": "~18.0.3",
|
"@angular/compiler-cli": "~18.2.2",
|
||||||
"@playwright/test": "^1.42.1",
|
"@codecov/webpack-plugin": "^1.2.0",
|
||||||
"@types/jest": "^29.5.12",
|
"@playwright/test": "^1.47.2",
|
||||||
"@types/node": "^20.12.2",
|
"@types/jest": "^29.5.13",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.4.0",
|
"@types/node": "^22.7.4",
|
||||||
"@typescript-eslint/parser": "^7.4.0",
|
"@typescript-eslint/eslint-plugin": "^8.8.0",
|
||||||
"@typescript-eslint/utils": "^7.13.0",
|
"@typescript-eslint/parser": "^8.8.0",
|
||||||
"concurrently": "^8.2.2",
|
"@typescript-eslint/utils": "^8.0.0",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^9.11.1",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"jest-preset-angular": "^14.0.0",
|
"jest-preset-angular": "^14.2.4",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.5.4"
|
||||||
"wait-on": "^7.2.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -24,6 +24,7 @@ import localeFr from '@angular/common/locales/fr'
|
|||||||
import localeHu from '@angular/common/locales/hu'
|
import localeHu from '@angular/common/locales/hu'
|
||||||
import localeIt from '@angular/common/locales/it'
|
import localeIt from '@angular/common/locales/it'
|
||||||
import localeJa from '@angular/common/locales/ja'
|
import localeJa from '@angular/common/locales/ja'
|
||||||
|
import localeKo from '@angular/common/locales/ko'
|
||||||
import localeLb from '@angular/common/locales/lb'
|
import localeLb from '@angular/common/locales/lb'
|
||||||
import localeNl from '@angular/common/locales/nl'
|
import localeNl from '@angular/common/locales/nl'
|
||||||
import localeNo from '@angular/common/locales/no'
|
import localeNo from '@angular/common/locales/no'
|
||||||
@@ -55,6 +56,7 @@ registerLocaleData(localeFr)
|
|||||||
registerLocaleData(localeHu)
|
registerLocaleData(localeHu)
|
||||||
registerLocaleData(localeIt)
|
registerLocaleData(localeIt)
|
||||||
registerLocaleData(localeJa)
|
registerLocaleData(localeJa)
|
||||||
|
registerLocaleData(localeKo)
|
||||||
registerLocaleData(localeLb)
|
registerLocaleData(localeLb)
|
||||||
registerLocaleData(localeNl)
|
registerLocaleData(localeNl)
|
||||||
registerLocaleData(localeNo)
|
registerLocaleData(localeNo)
|
||||||
|
@@ -36,7 +36,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
private hotKeyService: HotKeyService
|
private hotKeyService: HotKeyService
|
||||||
) {
|
) {
|
||||||
let anyWindow = window as any
|
let anyWindow = window as any
|
||||||
anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js'
|
anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.mjs'
|
||||||
this.settings.updateAppearanceSettings()
|
this.settings.updateAppearanceSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -41,6 +41,7 @@ import { DocumentCardSmallComponent } from './components/document-list/document-
|
|||||||
import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component'
|
import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component'
|
||||||
import { NgxFileDropModule } from 'ngx-file-drop'
|
import { NgxFileDropModule } from 'ngx-file-drop'
|
||||||
import { TextComponent } from './components/common/input/text/text.component'
|
import { TextComponent } from './components/common/input/text/text.component'
|
||||||
|
import { TextAreaComponent } from './components/common/input/textarea/textarea.component'
|
||||||
import { SelectComponent } from './components/common/input/select/select.component'
|
import { SelectComponent } from './components/common/input/select/select.component'
|
||||||
import { CheckComponent } from './components/common/input/check/check.component'
|
import { CheckComponent } from './components/common/input/check/check.component'
|
||||||
import { UrlComponent } from './components/common/input/url/url.component'
|
import { UrlComponent } from './components/common/input/url/url.component'
|
||||||
@@ -108,6 +109,7 @@ import { FileDropComponent } from './components/file-drop/file-drop.component'
|
|||||||
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
||||||
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
|
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||||
|
import { CustomFieldsQueryDropdownComponent } from './components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
|
||||||
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { PdfViewerModule } from 'ng2-pdf-viewer'
|
import { PdfViewerModule } from 'ng2-pdf-viewer'
|
||||||
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
|
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
|
||||||
@@ -141,7 +143,9 @@ import {
|
|||||||
arrowRightShort,
|
arrowRightShort,
|
||||||
arrowUpRight,
|
arrowUpRight,
|
||||||
asterisk,
|
asterisk,
|
||||||
|
braces,
|
||||||
bodyText,
|
bodyText,
|
||||||
|
boxArrowInRight,
|
||||||
boxArrowUp,
|
boxArrowUp,
|
||||||
boxArrowUpRight,
|
boxArrowUpRight,
|
||||||
boxes,
|
boxes,
|
||||||
@@ -172,6 +176,7 @@ import {
|
|||||||
download,
|
download,
|
||||||
envelope,
|
envelope,
|
||||||
envelopeAt,
|
envelopeAt,
|
||||||
|
envelopeAtFill,
|
||||||
exclamationCircleFill,
|
exclamationCircleFill,
|
||||||
exclamationTriangle,
|
exclamationTriangle,
|
||||||
exclamationTriangleFill,
|
exclamationTriangleFill,
|
||||||
@@ -188,6 +193,7 @@ import {
|
|||||||
folderFill,
|
folderFill,
|
||||||
funnel,
|
funnel,
|
||||||
gear,
|
gear,
|
||||||
|
google,
|
||||||
grid,
|
grid,
|
||||||
gripVertical,
|
gripVertical,
|
||||||
hash,
|
hash,
|
||||||
@@ -198,6 +204,8 @@ import {
|
|||||||
link,
|
link,
|
||||||
listTask,
|
listTask,
|
||||||
listUl,
|
listUl,
|
||||||
|
microsoft,
|
||||||
|
nodePlus,
|
||||||
pencil,
|
pencil,
|
||||||
people,
|
people,
|
||||||
peopleFill,
|
peopleFill,
|
||||||
@@ -227,6 +235,7 @@ import {
|
|||||||
uiRadios,
|
uiRadios,
|
||||||
upcScan,
|
upcScan,
|
||||||
x,
|
x,
|
||||||
|
xCircle,
|
||||||
xLg,
|
xLg,
|
||||||
} from 'ngx-bootstrap-icons'
|
} from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
@@ -242,7 +251,9 @@ const icons = {
|
|||||||
arrowRightShort,
|
arrowRightShort,
|
||||||
arrowUpRight,
|
arrowUpRight,
|
||||||
asterisk,
|
asterisk,
|
||||||
|
braces,
|
||||||
bodyText,
|
bodyText,
|
||||||
|
boxArrowInRight,
|
||||||
boxArrowUp,
|
boxArrowUp,
|
||||||
boxArrowUpRight,
|
boxArrowUpRight,
|
||||||
boxes,
|
boxes,
|
||||||
@@ -273,6 +284,7 @@ const icons = {
|
|||||||
download,
|
download,
|
||||||
envelope,
|
envelope,
|
||||||
envelopeAt,
|
envelopeAt,
|
||||||
|
envelopeAtFill,
|
||||||
exclamationCircleFill,
|
exclamationCircleFill,
|
||||||
exclamationTriangle,
|
exclamationTriangle,
|
||||||
exclamationTriangleFill,
|
exclamationTriangleFill,
|
||||||
@@ -289,6 +301,7 @@ const icons = {
|
|||||||
folderFill,
|
folderFill,
|
||||||
funnel,
|
funnel,
|
||||||
gear,
|
gear,
|
||||||
|
google,
|
||||||
grid,
|
grid,
|
||||||
gripVertical,
|
gripVertical,
|
||||||
hash,
|
hash,
|
||||||
@@ -299,6 +312,8 @@ const icons = {
|
|||||||
link,
|
link,
|
||||||
listTask,
|
listTask,
|
||||||
listUl,
|
listUl,
|
||||||
|
microsoft,
|
||||||
|
nodePlus,
|
||||||
pencil,
|
pencil,
|
||||||
people,
|
people,
|
||||||
peopleFill,
|
peopleFill,
|
||||||
@@ -328,6 +343,7 @@ const icons = {
|
|||||||
uiRadios,
|
uiRadios,
|
||||||
upcScan,
|
upcScan,
|
||||||
x,
|
x,
|
||||||
|
xCircle,
|
||||||
xLg,
|
xLg,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +363,7 @@ import localeFr from '@angular/common/locales/fr'
|
|||||||
import localeHu from '@angular/common/locales/hu'
|
import localeHu from '@angular/common/locales/hu'
|
||||||
import localeIt from '@angular/common/locales/it'
|
import localeIt from '@angular/common/locales/it'
|
||||||
import localeJa from '@angular/common/locales/ja'
|
import localeJa from '@angular/common/locales/ja'
|
||||||
|
import localeKo from '@angular/common/locales/ko'
|
||||||
import localeLb from '@angular/common/locales/lb'
|
import localeLb from '@angular/common/locales/lb'
|
||||||
import localeNl from '@angular/common/locales/nl'
|
import localeNl from '@angular/common/locales/nl'
|
||||||
import localeNo from '@angular/common/locales/no'
|
import localeNo from '@angular/common/locales/no'
|
||||||
@@ -378,6 +395,7 @@ registerLocaleData(localeFr)
|
|||||||
registerLocaleData(localeHu)
|
registerLocaleData(localeHu)
|
||||||
registerLocaleData(localeIt)
|
registerLocaleData(localeIt)
|
||||||
registerLocaleData(localeJa)
|
registerLocaleData(localeJa)
|
||||||
|
registerLocaleData(localeKo)
|
||||||
registerLocaleData(localeLb)
|
registerLocaleData(localeLb)
|
||||||
registerLocaleData(localeNl)
|
registerLocaleData(localeNl)
|
||||||
registerLocaleData(localeNo)
|
registerLocaleData(localeNo)
|
||||||
@@ -431,6 +449,7 @@ function initializeApp(settings: SettingsService) {
|
|||||||
DocumentCardSmallComponent,
|
DocumentCardSmallComponent,
|
||||||
BulkEditorComponent,
|
BulkEditorComponent,
|
||||||
TextComponent,
|
TextComponent,
|
||||||
|
TextAreaComponent,
|
||||||
SelectComponent,
|
SelectComponent,
|
||||||
CheckComponent,
|
CheckComponent,
|
||||||
UrlComponent,
|
UrlComponent,
|
||||||
@@ -483,6 +502,7 @@ function initializeApp(settings: SettingsService) {
|
|||||||
CustomFieldsComponent,
|
CustomFieldsComponent,
|
||||||
CustomFieldEditDialogComponent,
|
CustomFieldEditDialogComponent,
|
||||||
CustomFieldsDropdownComponent,
|
CustomFieldsDropdownComponent,
|
||||||
|
CustomFieldsQueryDropdownComponent,
|
||||||
ProfileEditDialogComponent,
|
ProfileEditDialogComponent,
|
||||||
DocumentLinkComponent,
|
DocumentLinkComponent,
|
||||||
PreviewPopupComponent,
|
PreviewPopupComponent,
|
||||||
|
@@ -332,7 +332,7 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li [ngbNavItem]="SettingsNavIDs.SavedViews">
|
<li [ngbNavItem]="SettingsNavIDs.SavedViews" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.SavedView }">
|
||||||
<a ngbNavLink i18n>Saved views</a>
|
<a ngbNavLink i18n>Saved views</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
|
|
||||||
|
@@ -81,6 +81,9 @@
|
|||||||
</td>
|
</td>
|
||||||
<td scope="row">
|
<td scope="row">
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
|
@if (task.status === PaperlessTaskStatus.Failed) {
|
||||||
|
<ng-container *ngTemplateOutlet="retryDropdown; context: { task: task }"></ng-container>
|
||||||
|
}
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
|
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
|
||||||
<i-bs name="check"></i-bs> <ng-container i18n>Dismiss</ng-container>
|
<i-bs name="check"></i-bs> <ng-container i18n>Dismiss</ng-container>
|
||||||
</button>
|
</button>
|
||||||
@@ -153,3 +156,25 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div [ngbNavOutlet]="nav"></div>
|
<div [ngbNavOutlet]="nav"></div>
|
||||||
|
|
||||||
|
<ng-template #retryDropdown let-task="task">
|
||||||
|
<div ngbDropdown>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" (click)="$event.stopImmediatePropagation()" ngbDropdownToggle>
|
||||||
|
<i-bs name="arrow-repeat"></i-bs> <ng-container i18n>Retry</ng-container>
|
||||||
|
</button>
|
||||||
|
<div ngbDropdownMenu class="shadow retry-dropdown">
|
||||||
|
<div class="p-2">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item small" i18n>
|
||||||
|
<pngx-input-check [(ngModel)]="retryClean" i18n-title title="Attempt to clean pdf"></pngx-input-check>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" (click)="retryTask(task); $event.stopPropagation();">
|
||||||
|
<ng-container i18n>Proceed</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
@@ -26,3 +26,7 @@ pre {
|
|||||||
max-width: 150px;
|
max-width: 150px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.retry-dropdown {
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
@@ -31,6 +31,9 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
|||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { of, throwError } from 'rxjs'
|
||||||
|
import { CheckComponent } from '../../common/input/check/check.component'
|
||||||
|
|
||||||
const tasks: PaperlessTask[] = [
|
const tasks: PaperlessTask[] = [
|
||||||
{
|
{
|
||||||
@@ -115,6 +118,7 @@ describe('TasksComponent', () => {
|
|||||||
let modalService: NgbModal
|
let modalService: NgbModal
|
||||||
let router: Router
|
let router: Router
|
||||||
let httpTestingController: HttpTestingController
|
let httpTestingController: HttpTestingController
|
||||||
|
let toastService: ToastService
|
||||||
let reloadSpy
|
let reloadSpy
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -125,6 +129,7 @@ describe('TasksComponent', () => {
|
|||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
CustomDatePipe,
|
CustomDatePipe,
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
|
CheckComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
NgbModule,
|
NgbModule,
|
||||||
@@ -152,6 +157,7 @@ describe('TasksComponent', () => {
|
|||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
|
toastService = TestBed.inject(ToastService)
|
||||||
fixture = TestBed.createComponent(TasksComponent)
|
fixture = TestBed.createComponent(TasksComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
@@ -173,8 +179,10 @@ describe('TasksComponent', () => {
|
|||||||
`Failed${currentTasksLength}`
|
`Failed${currentTasksLength}`
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
fixture.debugElement.queryAll(By.css('table input[type="checkbox"]'))
|
fixture.debugElement.queryAll(
|
||||||
).toHaveLength(currentTasksLength + 1)
|
By.css('table td > .form-check input[type="checkbox"]')
|
||||||
|
)
|
||||||
|
).toHaveLength(currentTasksLength)
|
||||||
|
|
||||||
currentTasksLength = tasks.filter(
|
currentTasksLength = tasks.filter(
|
||||||
(t) => t.status === PaperlessTaskStatus.Complete
|
(t) => t.status === PaperlessTaskStatus.Complete
|
||||||
@@ -289,4 +297,20 @@ describe('TasksComponent', () => {
|
|||||||
jest.advanceTimersByTime(6000)
|
jest.advanceTimersByTime(6000)
|
||||||
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should retry a task, show toast on error or success', () => {
|
||||||
|
const retrySpy = jest.spyOn(tasksService, 'retryTask')
|
||||||
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
retrySpy.mockReturnValueOnce(of({ task_id: '123' }))
|
||||||
|
component.retryTask(tasks[0])
|
||||||
|
expect(retrySpy).toHaveBeenCalledWith(tasks[0], false)
|
||||||
|
expect(toastInfoSpy).toHaveBeenCalledWith('Retrying task...')
|
||||||
|
retrySpy.mockReturnValueOnce(throwError(() => new Error('test')))
|
||||||
|
component.retryTask(tasks[0])
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Failed to retry task',
|
||||||
|
new Error('test')
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -2,10 +2,11 @@ import { Component, OnInit, OnDestroy } from '@angular/core'
|
|||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { first } from 'rxjs'
|
import { first } from 'rxjs'
|
||||||
import { PaperlessTask } from 'src/app/data/paperless-task'
|
import { PaperlessTask, PaperlessTaskStatus } from 'src/app/data/paperless-task'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-tasks',
|
selector: 'pngx-tasks',
|
||||||
@@ -16,6 +17,7 @@ export class TasksComponent
|
|||||||
extends ComponentWithPermissions
|
extends ComponentWithPermissions
|
||||||
implements OnInit, OnDestroy
|
implements OnInit, OnDestroy
|
||||||
{
|
{
|
||||||
|
public PaperlessTaskStatus = PaperlessTaskStatus
|
||||||
public activeTab: string
|
public activeTab: string
|
||||||
public selectedTasks: Set<number> = new Set()
|
public selectedTasks: Set<number> = new Set()
|
||||||
public togggleAll: boolean = false
|
public togggleAll: boolean = false
|
||||||
@@ -26,6 +28,8 @@ export class TasksComponent
|
|||||||
|
|
||||||
public autoRefreshInterval: any
|
public autoRefreshInterval: any
|
||||||
|
|
||||||
|
public retryClean: boolean = false
|
||||||
|
|
||||||
get dismissButtonText(): string {
|
get dismissButtonText(): string {
|
||||||
return this.selectedTasks.size > 0
|
return this.selectedTasks.size > 0
|
||||||
? $localize`Dismiss selected`
|
? $localize`Dismiss selected`
|
||||||
@@ -35,6 +39,7 @@ export class TasksComponent
|
|||||||
constructor(
|
constructor(
|
||||||
public tasksService: TasksService,
|
public tasksService: TasksService,
|
||||||
private modalService: NgbModal,
|
private modalService: NgbModal,
|
||||||
|
private toastService: ToastService,
|
||||||
private readonly router: Router
|
private readonly router: Router
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
@@ -70,11 +75,11 @@ export class TasksComponent
|
|||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
modal.close()
|
modal.close()
|
||||||
this.tasksService.dismissTasks(tasks)
|
this.tasksService.dismissTasks(tasks)
|
||||||
this.selectedTasks.clear()
|
this.clearSelection()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.tasksService.dismissTasks(tasks)
|
this.tasksService.dismissTasks(tasks)
|
||||||
this.selectedTasks.clear()
|
this.clearSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +88,17 @@ export class TasksComponent
|
|||||||
this.router.navigate(['documents', task.related_document])
|
this.router.navigate(['documents', task.related_document])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retryTask(task: PaperlessTask) {
|
||||||
|
this.tasksService.retryTask(task, this.retryClean).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toastService.showInfo($localize`Retrying task...`)
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.toastService.showError($localize`Failed to retry task`, e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
expandTask(task: PaperlessTask) {
|
expandTask(task: PaperlessTask) {
|
||||||
this.expandedTask = this.expandedTask == task.id ? undefined : task.id
|
this.expandedTask = this.expandedTask == task.id ? undefined : task.id
|
||||||
}
|
}
|
||||||
|
@@ -43,7 +43,7 @@
|
|||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
</div>
|
</div>
|
||||||
<button ngbDropdownItem class="nav-link" (click)="editProfile()">
|
<button ngbDropdownItem class="nav-link" (click)="editProfile()">
|
||||||
<i-bs class="me-2" name="person"></i-bs> <ng-container i18n>My Profile</ng-container>
|
<i-bs class="me-2" name="person"></i-bs><ng-container i18n>My Profile</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()"
|
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }">
|
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }">
|
||||||
@@ -93,33 +93,35 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||||
@if (savedViewService.loading || savedViewService.sidebarViews?.length > 0) {
|
@if (savedViewService.loading) {
|
||||||
<h6 class="sidebar-heading px-3 text-muted">
|
<h6 class="sidebar-heading px-3 text-muted">
|
||||||
<span i18n>Saved views</span>
|
<span i18n>Saved views</span>
|
||||||
@if (savedViewService.loading) {
|
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
|
||||||
}
|
|
||||||
</h6>
|
</h6>
|
||||||
|
} @else if (savedViewService.sidebarViews?.length > 0) {
|
||||||
|
<h6 class="sidebar-heading px-3 text-muted">
|
||||||
|
<span i18n>Saved views</span>
|
||||||
|
</h6>
|
||||||
|
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||||
|
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||||
|
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
|
||||||
|
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||||
|
(cdkDragEnded)="onDragEnd($event)">
|
||||||
|
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}"
|
||||||
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
||||||
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
|
popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="funnel"></i-bs><span> {{view.name}}</span>
|
||||||
|
</a>
|
||||||
|
@if (settingsService.organizingSidebarSavedViews) {
|
||||||
|
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
||||||
|
<i-bs name="grip-vertical"></i-bs>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
}
|
}
|
||||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
|
||||||
@for (view of savedViewService.sidebarViews; track view.id) {
|
|
||||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
|
|
||||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
|
||||||
(cdkDragEnded)="onDragEnd($event)">
|
|
||||||
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}"
|
|
||||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
|
||||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
|
||||||
popoverClass="popover-slim">
|
|
||||||
<i-bs class="me-1" name="funnel"></i-bs><span> {{view.name}}</span>
|
|
||||||
</a>
|
|
||||||
@if (settingsService.organizingSidebarSavedViews) {
|
|
||||||
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
|
||||||
<i-bs name="grip-vertical"></i-bs>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
|
@@ -12,6 +12,9 @@
|
|||||||
z-index: 995; /* Behind the navbar */
|
z-index: 995; /* Behind the navbar */
|
||||||
padding: 50px 0 0; /* Height of navbar */
|
padding: 50px 0 0; /* Height of navbar */
|
||||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
||||||
|
overflow-y: auto;
|
||||||
|
--pngx-sidebar-width: 100%;
|
||||||
|
max-width: var(--pngx-sidebar-width);
|
||||||
|
|
||||||
.sidebar-heading .spinner-border {
|
.sidebar-heading .spinner-border {
|
||||||
width: 0.8em;
|
width: 0.8em;
|
||||||
@@ -24,15 +27,15 @@
|
|||||||
|
|
||||||
// These come from the col-* classes for non-slim sidebar, needed for animation
|
// These come from the col-* classes for non-slim sidebar, needed for animation
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
max-width: 25%;
|
--pngx-sidebar-width: 25%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 992px) {
|
@media (min-width: 992px) {
|
||||||
max-width: 16.66666667%;
|
--pngx-sidebar-width: 16.66666667%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 2400px) {
|
@media (min-width: 2400px) {
|
||||||
max-width: 8.33333333%;
|
--pngx-sidebar-width: 8.33333333%;
|
||||||
}
|
}
|
||||||
|
|
||||||
transition: all .2s ease;
|
transition: all .2s ease;
|
||||||
@@ -109,12 +112,17 @@ main {
|
|||||||
|
|
||||||
.sidebar-slim-toggler {
|
.sidebar-slim-toggler {
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: fixed;
|
||||||
right: -12px;
|
left: calc(var(--pngx-sidebar-width) - 12px);
|
||||||
top: 60px;
|
top: 60px;
|
||||||
z-index: 996;
|
z-index: 996;
|
||||||
--bs-btn-padding-x: 0.35rem;
|
--bs-btn-padding-x: 0.35rem;
|
||||||
--bs-btn-padding-y: 0.125rem;
|
--bs-btn-padding-y: 0.125rem;
|
||||||
|
transition: all .2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.slim .sidebar-slim-toggler {
|
||||||
|
--pngx-sidebar-width: 50px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -115,7 +115,7 @@ describe('AppFrameComponent', () => {
|
|||||||
{
|
{
|
||||||
provide: SavedViewService,
|
provide: SavedViewService,
|
||||||
useValue: {
|
useValue: {
|
||||||
initialize: () => {},
|
reload: () => {},
|
||||||
listAll: () =>
|
listAll: () =>
|
||||||
of({
|
of({
|
||||||
all: [saved_views.map((v) => v.id)],
|
all: [saved_views.map((v) => v.id)],
|
||||||
@@ -170,7 +170,7 @@ describe('AppFrameComponent', () => {
|
|||||||
.mockReturnValue('Hello World')
|
.mockReturnValue('Hello World')
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
|
||||||
savedViewSpy = jest.spyOn(savedViewService, 'initialize')
|
savedViewSpy = jest.spyOn(savedViewService, 'reload')
|
||||||
|
|
||||||
fixture = TestBed.createComponent(AppFrameComponent)
|
fixture = TestBed.createComponent(AppFrameComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
@@ -35,7 +35,6 @@ import {
|
|||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-app-frame',
|
selector: 'pngx-app-frame',
|
||||||
@@ -74,7 +73,7 @@ export class AppFrameComponent
|
|||||||
PermissionType.SavedView
|
PermissionType.SavedView
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
this.savedViewService.initialize()
|
this.savedViewService.reload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -49,14 +49,14 @@
|
|||||||
[disabled]="disablePrimaryButton(type, item)"
|
[disabled]="disablePrimaryButton(type, item)"
|
||||||
(mouseenter)="onButtonHover($event)">
|
(mouseenter)="onButtonHover($event)">
|
||||||
@if (type === DataType.Document) {
|
@if (type === DataType.Document) {
|
||||||
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
<i-bs width="1em" height="1em" name="box-arrow-in-right"></i-bs>
|
||||||
<span> <ng-container i18n>Open</ng-container></span>
|
<span> <ng-container i18n>Open</ng-container></span>
|
||||||
} @else if (type === DataType.SavedView) {
|
} @else if (type === DataType.SavedView) {
|
||||||
<i-bs width="1em" height="1em" name="eye"></i-bs>
|
<i-bs width="1em" height="1em" name="eye"></i-bs>
|
||||||
<span> <ng-container i18n>Open</ng-container></span>
|
<span> <ng-container i18n>Open</ng-container></span>
|
||||||
} @else if (type === DataType.Workflow || type === DataType.CustomField || type === DataType.Group || type === DataType.User || type === DataType.MailAccount || type === DataType.MailRule) {
|
} @else if (type === DataType.Workflow || type === DataType.CustomField || type === DataType.Group || type === DataType.User || type === DataType.MailAccount || type === DataType.MailRule) {
|
||||||
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
||||||
<span> <ng-container i18n>Edit</ng-container></span>
|
<span> <ng-container i18n>Open</ng-container></span>
|
||||||
} @else {
|
} @else {
|
||||||
<i-bs width="1em" height="1em" name="filter"></i-bs>
|
<i-bs width="1em" height="1em" name="filter"></i-bs>
|
||||||
<span> <ng-container i18n>Filter documents</ng-container></span>
|
<span> <ng-container i18n>Filter documents</ng-container></span>
|
||||||
@@ -72,8 +72,8 @@
|
|||||||
<i-bs width="1em" height="1em" name="download"></i-bs>
|
<i-bs width="1em" height="1em" name="download"></i-bs>
|
||||||
<span> <ng-container i18n>Download</ng-container></span>
|
<span> <ng-container i18n>Download</ng-container></span>
|
||||||
} @else {
|
} @else {
|
||||||
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
<i-bs width="1em" height="1em" name="box-arrow-in-right"></i-bs>
|
||||||
<span> <ng-container i18n>Edit</ng-container></span>
|
<span> <ng-container i18n>Open</ng-container></span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@@ -65,10 +65,6 @@ form {
|
|||||||
--pngx-focus-alpha: 0;
|
--pngx-focus-alpha: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cursor-pointer {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mh-75 {
|
.mh-75 {
|
||||||
max-height: 75vh;
|
max-height: 75vh;
|
||||||
}
|
}
|
||||||
|
@@ -18,9 +18,11 @@
|
|||||||
@case (CustomFieldDataType.DocumentLink) {
|
@case (CustomFieldDataType.DocumentLink) {
|
||||||
<div [ngbTooltip]="nameTooltip" class="d-flex gap-1 flex-wrap">
|
<div [ngbTooltip]="nameTooltip" class="d-flex gap-1 flex-wrap">
|
||||||
@for (docId of value; track docId) {
|
@for (docId of value; track docId) {
|
||||||
<a routerLink="/documents/{{docId}}" class="badge bg-body text-primary" title="View" i18n-title>
|
@if (getDocumentTitle(docId)) {
|
||||||
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs> <span>{{ getDocumentTitle(docId) }}</span>
|
<a routerLink="/documents/{{docId}}" class="badge bg-body text-primary" title="View" i18n-title>
|
||||||
</a>
|
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs> <span>{{ getDocumentTitle(docId) }}</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@@ -20,6 +20,12 @@ const customFields: CustomField[] = [
|
|||||||
select_options: ['Option 1', 'Option 2', 'Option 3'],
|
select_options: ['Option 1', 'Option 2', 'Option 3'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: 'Field 5',
|
||||||
|
data_type: CustomFieldDataType.Monetary,
|
||||||
|
extra_data: { default_currency: 'JPY' },
|
||||||
|
},
|
||||||
]
|
]
|
||||||
const document: Document = {
|
const document: Document = {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -112,6 +118,18 @@ describe('CustomFieldDisplayComponent', () => {
|
|||||||
expect(component.value).toEqual(100)
|
expect(component.value).toEqual(100)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should respect explicit default currency', () => {
|
||||||
|
component['defaultCurrencyCode'] = 'EUR' // mock default locale injection
|
||||||
|
component.fieldId = 5
|
||||||
|
component.document = {
|
||||||
|
id: 1,
|
||||||
|
title: 'Doc 1',
|
||||||
|
custom_fields: [{ field: 5, document: 1, created: null, value: '100' }],
|
||||||
|
}
|
||||||
|
expect(component.currency).toEqual('JPY')
|
||||||
|
expect(component.value).toEqual(100)
|
||||||
|
})
|
||||||
|
|
||||||
it('should show select value', () => {
|
it('should show select value', () => {
|
||||||
expect(component.getSelectValue(customFields[3], 2)).toEqual('Option 3')
|
expect(component.getSelectValue(customFields[3], 2)).toEqual('Option 3')
|
||||||
})
|
})
|
||||||
|
@@ -90,7 +90,9 @@ export class CustomFieldDisplayComponent implements OnInit, OnDestroy {
|
|||||||
)?.value
|
)?.value
|
||||||
if (this.value && this.field.data_type === CustomFieldDataType.Monetary) {
|
if (this.value && this.field.data_type === CustomFieldDataType.Monetary) {
|
||||||
this.currency =
|
this.currency =
|
||||||
this.value.match(/([A-Z]{3})/)?.[0] ?? this.defaultCurrencyCode
|
this.value.match(/([A-Z]{3})/)?.[0] ??
|
||||||
|
this.field.extra_data?.default_currency ??
|
||||||
|
this.defaultCurrencyCode
|
||||||
this.value = parseFloat(this.value.replace(this.currency, ''))
|
this.value = parseFloat(this.value.replace(this.currency, ''))
|
||||||
} else if (
|
} else if (
|
||||||
this.value?.length &&
|
this.value?.length &&
|
||||||
@@ -105,9 +107,9 @@ export class CustomFieldDisplayComponent implements OnInit, OnDestroy {
|
|||||||
.getFew(this.value, { fields: 'id,title' })
|
.getFew(this.value, { fields: 'id,title' })
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((result: Results<Document>) => {
|
.subscribe((result: Results<Document>) => {
|
||||||
this.docLinkDocuments = this.value.map((id) =>
|
this.docLinkDocuments = this.value
|
||||||
result.results.find((d) => d.id === id)
|
.map((id) => result.results.find((d) => d.id === id))
|
||||||
)
|
.filter((d) => d)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -0,0 +1,163 @@
|
|||||||
|
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
|
||||||
|
<i-bs name="{{icon}}"></i-bs>
|
||||||
|
<div class="d-none d-sm-inline"> {{title}}</div>
|
||||||
|
@if (isActive) {
|
||||||
|
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
@for (element of selectionModel.queries; track element.id; let i = $index) {
|
||||||
|
<div class="list-group-item px-0 d-flex flex-nowrap">
|
||||||
|
@switch (element.type) {
|
||||||
|
@case (CustomFieldQueryComponentType.Atom) {
|
||||||
|
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
|
||||||
|
}
|
||||||
|
@case (CustomFieldQueryComponentType.Expression) {
|
||||||
|
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #comparisonValueTemplate let-atom="atom">
|
||||||
|
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
|
||||||
|
<input class="form-control" placeholder="yyyy-mm-dd"
|
||||||
|
[(ngModel)]="atom.value"
|
||||||
|
ngbDatepicker
|
||||||
|
#d="ngbDatepicker" />
|
||||||
|
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button">
|
||||||
|
<i-bs name="calendar-event"></i-bs>
|
||||||
|
</button>
|
||||||
|
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Float || getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Integer) {
|
||||||
|
<input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [disabled]="disabled">
|
||||||
|
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) {
|
||||||
|
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
|
||||||
|
<option value="true" i18n>True</option>
|
||||||
|
<option value="false" i18n>False</option>
|
||||||
|
</select>
|
||||||
|
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Select) {
|
||||||
|
<ng-select #fieldSelects
|
||||||
|
class="paperless-input-select rounded-end"
|
||||||
|
[items]="getSelectOptionsForField(atom.field)"
|
||||||
|
[(ngModel)]="atom.value"
|
||||||
|
[disabled]="disabled"
|
||||||
|
(mousedown)="$event.stopImmediatePropagation()"
|
||||||
|
></ng-select>
|
||||||
|
} @else {
|
||||||
|
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #queryAtom let-atom="atom">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<ng-select
|
||||||
|
class="paperless-input-select"
|
||||||
|
[items]="customFields"
|
||||||
|
[(ngModel)]="atom.field"
|
||||||
|
[disabled]="disabled"
|
||||||
|
bindLabel="name"
|
||||||
|
bindValue="id"
|
||||||
|
(mousedown)="$event.stopImmediatePropagation()"
|
||||||
|
></ng-select>
|
||||||
|
<select class="w-25 form-select" [(ngModel)]="atom.operator" [disabled]="disabled">
|
||||||
|
<option *ngFor="let operator of getOperatorsForField(atom.field)" [ngValue]="operator.value">{{operator.label}}</option>
|
||||||
|
</select>
|
||||||
|
@switch (atom.operator) {
|
||||||
|
@case (CustomFieldQueryOperator.Exists) {
|
||||||
|
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
|
||||||
|
<option value="true" i18n>True</option>
|
||||||
|
<option value="false" i18n>False</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
@case (CustomFieldQueryOperator.IsNull) {
|
||||||
|
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
|
||||||
|
<option value="true" i18n>True</option>
|
||||||
|
<option value="false" i18n>False</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
@case (CustomFieldQueryOperator.GreaterThanOrEqual) {
|
||||||
|
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
|
||||||
|
}
|
||||||
|
@case (CustomFieldQueryOperator.LessThanOrEqual) {
|
||||||
|
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
|
||||||
|
}
|
||||||
|
@case (CustomFieldQueryOperator.GreaterThan) {
|
||||||
|
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
|
||||||
|
}
|
||||||
|
@case (CustomFieldQueryOperator.LessThan) {
|
||||||
|
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
|
||||||
|
}
|
||||||
|
@case (CustomFieldQueryOperator.Contains) {
|
||||||
|
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true"></pngx-input-document-link>
|
||||||
|
}
|
||||||
|
@case (CustomFieldQueryOperator.In) {
|
||||||
|
<ng-select
|
||||||
|
class="paperless-input-select rounded-end"
|
||||||
|
[items]="getSelectOptionsForField(atom.field)"
|
||||||
|
[(ngModel)]="atom.value"
|
||||||
|
[disabled]="disabled"
|
||||||
|
[multiple]="true"
|
||||||
|
(mousedown)="$event.stopImmediatePropagation()"
|
||||||
|
></ng-select>
|
||||||
|
}
|
||||||
|
@case (CustomFieldQueryOperator.Exact) {
|
||||||
|
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled">
|
||||||
|
<i-bs name="x-circle"></i-bs>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #queryExpression let-expression="expression">
|
||||||
|
<div class="d-flex w-100">
|
||||||
|
<div class="d-flex flex-grow-1 flex-column">
|
||||||
|
<div class="btn-group btn-group-xs" role="group">
|
||||||
|
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorOr_{{expression.id}}" name="logicalOperatorOr_{{expression.id}}" value="OR" [disabled]="expression.depth > 0 && expression.value.length < 2">
|
||||||
|
<label class="btn btn-outline-primary" for="logicalOperatorOr_{{expression.id}}" i18n>Any</label>
|
||||||
|
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorAnd_{{expression.id}}" name="logicalOperatorAnd_{{expression.id}}" value="AND" [disabled]="expression.depth > 0 && expression.value.length < 2">
|
||||||
|
<label class="btn btn-outline-primary" for="logicalOperatorAnd_{{expression.id}}" i18n>All</label>
|
||||||
|
@if (expression.negatable) {
|
||||||
|
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorNot_{{expression.id}}" name="logicalOperatorNot_{{expression.id}}" value="NOT">
|
||||||
|
<label class="btn btn-outline-secondary" for="logicalOperatorNot_{{expression.id}}" i18n>Not</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="list-group list-group-flush mb-n2">
|
||||||
|
@for (element of expression.value; track element.id; let i = $index) {
|
||||||
|
<div class="list-group-item px-0 d-flex flex-nowrap">
|
||||||
|
@switch (element.type) {
|
||||||
|
@case (CustomFieldQueryComponentType.Atom) {
|
||||||
|
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
|
||||||
|
}
|
||||||
|
@case (CustomFieldQueryComponentType.Expression) {
|
||||||
|
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group-vertical ms-2 ps-2 border-start" role="group" aria-label="Vertical button group">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary text-primary" title="Add query" i18n-title (click)="addAtom(expression)" [disabled]="disabled || expression.value.length === CUSTOM_FIELD_QUERY_MAX_ATOMS">
|
||||||
|
<i-bs name="node-plus"></i-bs>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary text-primary" title="Add expression" i18n-title (click)="addExpression(expression)" [disabled]="disabled || expression.depth === CUSTOM_FIELD_QUERY_MAX_DEPTH">
|
||||||
|
<i-bs name="braces"></i-bs>
|
||||||
|
</button>
|
||||||
|
@if (expression.depth > 0) {
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary text-danger" (click)="removeElement(expression)" [disabled]="disabled">
|
||||||
|
<i-bs name="x-circle"></i-bs>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
@@ -0,0 +1,43 @@
|
|||||||
|
.dropdown-menu {
|
||||||
|
width: 370px;
|
||||||
|
@media(min-width: 768px) {
|
||||||
|
width: 600px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .ng-select-container {
|
||||||
|
border-top-right-radius: 0 !important;
|
||||||
|
border-bottom-right-radius: 0 !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .rounded-end .ng-select-container {
|
||||||
|
border-top-right-radius: var(--bs-border-radius) !important;
|
||||||
|
border-bottom-right-radius: var(--bs-border-radius) !important;
|
||||||
|
border-top-left-radius: 0 !important;
|
||||||
|
border-bottom-left-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .ng-select {
|
||||||
|
max-width: 100px;
|
||||||
|
min-width: 35%;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .doc-link-select {
|
||||||
|
padding-top: 0 !important;
|
||||||
|
border-top-right-radius: var(--bs-border-radius) !important;
|
||||||
|
border-bottom-right-radius: var(--bs-border-radius) !important;
|
||||||
|
background-image: none !important;
|
||||||
|
|
||||||
|
.ng-select-container,
|
||||||
|
.ng-select.ng-select-opened > .ng-select-container {
|
||||||
|
border: none !important;
|
||||||
|
min-height: 34px !important;
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
.ng-select {
|
||||||
|
max-width: 200px;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,342 @@
|
|||||||
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
fakeAsync,
|
||||||
|
TestBed,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
|
import {
|
||||||
|
CustomFieldQueriesModel,
|
||||||
|
CustomFieldsQueryDropdownComponent,
|
||||||
|
} from './custom-fields-query-dropdown.component'
|
||||||
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
|
import { of } from 'rxjs'
|
||||||
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
|
import {
|
||||||
|
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
|
||||||
|
CustomFieldQueryLogicalOperator,
|
||||||
|
CustomFieldQueryOperatorGroups,
|
||||||
|
} from 'src/app/data/custom-field-query'
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import {
|
||||||
|
CustomFieldQueryExpression,
|
||||||
|
CustomFieldQueryAtom,
|
||||||
|
CustomFieldQueryElement,
|
||||||
|
} from 'src/app/utils/custom-field-query-element'
|
||||||
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
|
||||||
|
const customFields = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Field',
|
||||||
|
data_type: CustomFieldDataType.String,
|
||||||
|
extra_data: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Test Select Field',
|
||||||
|
data_type: CustomFieldDataType.Select,
|
||||||
|
extra_data: { select_options: ['Option 1', 'Option 2'] },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('CustomFieldsQueryDropdownComponent', () => {
|
||||||
|
let component: CustomFieldsQueryDropdownComponent
|
||||||
|
let fixture: ComponentFixture<CustomFieldsQueryDropdownComponent>
|
||||||
|
let customFieldsService: CustomFieldsService
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [CustomFieldsQueryDropdownComponent],
|
||||||
|
imports: [
|
||||||
|
NgbDropdownModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
NgSelectModule,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||||
|
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
|
||||||
|
of({
|
||||||
|
count: customFields.length,
|
||||||
|
all: customFields.map((f) => f.id),
|
||||||
|
results: customFields,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
fixture = TestBed.createComponent(CustomFieldsQueryDropdownComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
component.icon = 'ui-radios'
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize custom fields on creation', () => {
|
||||||
|
expect(component.customFields).toEqual(customFields)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add an expression when opened if queries are empty', () => {
|
||||||
|
component.selectionModel.clear()
|
||||||
|
component.onOpenChange(true)
|
||||||
|
expect(component.selectionModel.queries.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support reset the selection model', () => {
|
||||||
|
component.selectionModel.addExpression()
|
||||||
|
component.reset()
|
||||||
|
expect(component.selectionModel.isEmpty()).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should get operators for a field', () => {
|
||||||
|
const field: CustomField = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Field',
|
||||||
|
data_type: CustomFieldDataType.String,
|
||||||
|
extra_data: {},
|
||||||
|
}
|
||||||
|
component.customFields = [field]
|
||||||
|
const operators = component.getOperatorsForField(1)
|
||||||
|
expect(operators.length).toEqual(
|
||||||
|
[
|
||||||
|
...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
|
||||||
|
CustomFieldQueryOperatorGroups.Basic
|
||||||
|
],
|
||||||
|
...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
|
||||||
|
CustomFieldQueryOperatorGroups.String
|
||||||
|
],
|
||||||
|
].length
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fallback to basic operators if field is not found
|
||||||
|
const operators2 = component.getOperatorsForField(2)
|
||||||
|
expect(operators2.length).toEqual(
|
||||||
|
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
|
||||||
|
CustomFieldQueryOperatorGroups.Basic
|
||||||
|
].length
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should get select options for a field', () => {
|
||||||
|
const field: CustomField = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Field',
|
||||||
|
data_type: CustomFieldDataType.Select,
|
||||||
|
extra_data: { select_options: ['Option 1', 'Option 2'] },
|
||||||
|
}
|
||||||
|
component.customFields = [field]
|
||||||
|
const options = component.getSelectOptionsForField(1)
|
||||||
|
expect(options).toEqual(['Option 1', 'Option 2'])
|
||||||
|
|
||||||
|
// Fallback to empty array if field is not found
|
||||||
|
const options2 = component.getSelectOptionsForField(2)
|
||||||
|
expect(options2).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove an element from the selection model', () => {
|
||||||
|
const expression = new CustomFieldQueryExpression()
|
||||||
|
const atom = new CustomFieldQueryAtom()
|
||||||
|
;(expression.value as CustomFieldQueryElement[]).push(atom)
|
||||||
|
component.selectionModel.addExpression(expression)
|
||||||
|
component.removeElement(atom)
|
||||||
|
expect(component.selectionModel.isEmpty()).toBeTruthy()
|
||||||
|
const expression2 = new CustomFieldQueryExpression([
|
||||||
|
CustomFieldQueryLogicalOperator.And,
|
||||||
|
[
|
||||||
|
[1, 'icontains', 'test'],
|
||||||
|
[2, 'icontains', 'test'],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
component.selectionModel.addExpression(expression2)
|
||||||
|
component.removeElement(expression2)
|
||||||
|
expect(component.selectionModel.isEmpty()).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should emit selectionModelChange when model changes', () => {
|
||||||
|
const nextSpy = jest.spyOn(component.selectionModelChange, 'next')
|
||||||
|
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
|
||||||
|
component.selectionModel.addAtom(atom)
|
||||||
|
atom.changed.next(atom)
|
||||||
|
expect(nextSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should complete selection model subscription when new selection model is set', () => {
|
||||||
|
const completeSpy = jest.spyOn(component.selectionModel.changed, 'complete')
|
||||||
|
const selectionModel = new CustomFieldQueriesModel()
|
||||||
|
component.selectionModel = selectionModel
|
||||||
|
expect(completeSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support adding an atom', () => {
|
||||||
|
const expression = new CustomFieldQueryExpression()
|
||||||
|
component.addAtom(expression)
|
||||||
|
expect(expression.value.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support adding an expression', () => {
|
||||||
|
const expression = new CustomFieldQueryExpression()
|
||||||
|
component.addExpression(expression)
|
||||||
|
expect(expression.value.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support getting a custom field by ID', () => {
|
||||||
|
expect(component.getCustomFieldByID(1)).toEqual(customFields[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should sanitize name from title', () => {
|
||||||
|
component.title = 'Test Title'
|
||||||
|
expect(component.name).toBe('test_title')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add a default atom on open and focus the select field', fakeAsync(() => {
|
||||||
|
expect(component.selectionModel.queries.length).toBe(0)
|
||||||
|
component.onOpenChange(true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
tick()
|
||||||
|
expect(component.selectionModel.queries.length).toBe(1)
|
||||||
|
expect(window.document.activeElement.tagName).toBe('INPUT')
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('CustomFieldQueriesModel', () => {
|
||||||
|
let model: CustomFieldQueriesModel
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
model = new CustomFieldQueriesModel()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize with empty queries', () => {
|
||||||
|
expect(model.queries).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear queries and fire event', () => {
|
||||||
|
const nextSpy = jest.spyOn(model.changed, 'next')
|
||||||
|
model.addExpression()
|
||||||
|
model.clear()
|
||||||
|
expect(model.queries).toEqual([])
|
||||||
|
expect(nextSpy).toHaveBeenCalledWith(model)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear queries without firing event', () => {
|
||||||
|
const nextSpy = jest.spyOn(model.changed, 'next')
|
||||||
|
model.addExpression()
|
||||||
|
model.clear(false)
|
||||||
|
expect(model.queries).toEqual([])
|
||||||
|
expect(nextSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should validate an empty model as invalid', () => {
|
||||||
|
expect(model.isValid()).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should validate a model with valid expression as valid', () => {
|
||||||
|
const expression = new CustomFieldQueryExpression()
|
||||||
|
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
|
||||||
|
const atom2 = new CustomFieldQueryAtom([2, 'icontains', 'test'])
|
||||||
|
const expression2 = new CustomFieldQueryExpression()
|
||||||
|
expression2.addAtom(atom)
|
||||||
|
expression2.addAtom(atom2)
|
||||||
|
expression.addExpression(expression2)
|
||||||
|
model.addExpression(expression)
|
||||||
|
expect(model.isValid()).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should validate a model with invalid expression as invalid', () => {
|
||||||
|
const expression = new CustomFieldQueryExpression()
|
||||||
|
model.addExpression(expression)
|
||||||
|
expect(model.isValid()).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should validate an atom with in or contains operator', () => {
|
||||||
|
const atom = new CustomFieldQueryAtom([1, 'in', '[1,2,3]'])
|
||||||
|
expect(model['validateAtom'].apply(null, [atom])).toBeTruthy()
|
||||||
|
atom.operator = 'contains'
|
||||||
|
atom.value = [1, 2, 3]
|
||||||
|
expect(model['validateAtom'].apply(null, [atom])).toBeTruthy()
|
||||||
|
atom.value = null
|
||||||
|
expect(model['validateAtom'].apply(null, [atom])).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should check if model is empty', () => {
|
||||||
|
expect(model.isEmpty()).toBeTruthy()
|
||||||
|
model.addExpression()
|
||||||
|
expect(model.isEmpty()).toBeTruthy()
|
||||||
|
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
|
||||||
|
model.addAtom(atom)
|
||||||
|
expect(model.isEmpty()).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add an atom to the model', () => {
|
||||||
|
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
|
||||||
|
model.addAtom(atom)
|
||||||
|
expect(model.queries.length).toBe(1)
|
||||||
|
expect(
|
||||||
|
(model.queries[0] as CustomFieldQueryExpression).value.length
|
||||||
|
).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add an expression to the model, propagate changes', () => {
|
||||||
|
const expression = new CustomFieldQueryExpression()
|
||||||
|
model.addExpression(expression)
|
||||||
|
expect(model.queries.length).toBe(1)
|
||||||
|
const expression2 = new CustomFieldQueryExpression([
|
||||||
|
CustomFieldQueryLogicalOperator.And,
|
||||||
|
[
|
||||||
|
[1, 'icontains', 'test'],
|
||||||
|
[2, 'icontains', 'test'],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
model.addExpression(expression2)
|
||||||
|
const nextSpy = jest.spyOn(model.changed, 'next')
|
||||||
|
expression2.changed.next(expression2)
|
||||||
|
expect(nextSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove an element from the model', () => {
|
||||||
|
const expression = new CustomFieldQueryExpression([
|
||||||
|
CustomFieldQueryLogicalOperator.And,
|
||||||
|
[
|
||||||
|
[1, 'icontains', 'test'],
|
||||||
|
[2, 'icontains', 'test'],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
|
||||||
|
const expression2 = new CustomFieldQueryExpression([
|
||||||
|
CustomFieldQueryLogicalOperator.And,
|
||||||
|
[
|
||||||
|
[3, 'icontains', 'test'],
|
||||||
|
[4, 'icontains', 'test'],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
expression.addAtom(atom)
|
||||||
|
expression2.addExpression(expression)
|
||||||
|
model.addExpression(expression2)
|
||||||
|
model.removeElement(atom)
|
||||||
|
expect(model.queries.length).toBe(1)
|
||||||
|
model.removeElement(expression2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fire changed event when an atom changes', () => {
|
||||||
|
const nextSpy = jest.spyOn(model.changed, 'next')
|
||||||
|
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
|
||||||
|
model.addAtom(atom)
|
||||||
|
atom.changed.next(atom)
|
||||||
|
expect(nextSpy).toHaveBeenCalledWith(model)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should complete changed subject when element is removed', () => {
|
||||||
|
const expression = new CustomFieldQueryExpression()
|
||||||
|
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
|
||||||
|
;(expression.value as CustomFieldQueryElement[]).push(atom)
|
||||||
|
model.addExpression(expression)
|
||||||
|
const completeSpy = jest.spyOn(atom.changed, 'complete')
|
||||||
|
model.removeElement(atom)
|
||||||
|
expect(completeSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
@@ -0,0 +1,321 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
OnDestroy,
|
||||||
|
Output,
|
||||||
|
QueryList,
|
||||||
|
ViewChild,
|
||||||
|
ViewChildren,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||||
|
import { Subject, first, takeUntil } from 'rxjs'
|
||||||
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
|
import {
|
||||||
|
CustomFieldQueryElementType,
|
||||||
|
CustomFieldQueryOperator,
|
||||||
|
CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE,
|
||||||
|
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
|
||||||
|
CustomFieldQueryOperatorGroups,
|
||||||
|
CUSTOM_FIELD_QUERY_OPERATOR_LABELS,
|
||||||
|
CUSTOM_FIELD_QUERY_MAX_DEPTH,
|
||||||
|
CUSTOM_FIELD_QUERY_MAX_ATOMS,
|
||||||
|
} from 'src/app/data/custom-field-query'
|
||||||
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
|
import {
|
||||||
|
CustomFieldQueryElement,
|
||||||
|
CustomFieldQueryExpression,
|
||||||
|
CustomFieldQueryAtom,
|
||||||
|
} from 'src/app/utils/custom-field-query-element'
|
||||||
|
import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options'
|
||||||
|
|
||||||
|
export class CustomFieldQueriesModel {
|
||||||
|
public queries: CustomFieldQueryElement[] = []
|
||||||
|
|
||||||
|
public readonly changed = new Subject<CustomFieldQueriesModel>()
|
||||||
|
|
||||||
|
public clear(fireEvent = true) {
|
||||||
|
this.queries = []
|
||||||
|
if (fireEvent) {
|
||||||
|
this.changed.next(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isValid(): boolean {
|
||||||
|
return (
|
||||||
|
this.queries.length > 0 &&
|
||||||
|
this.validateExpression(this.queries[0] as CustomFieldQueryExpression)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public isEmpty(): boolean {
|
||||||
|
return (
|
||||||
|
this.queries.length === 0 ||
|
||||||
|
(this.queries.length === 1 && this.queries[0].value.length === 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateAtom(atom: CustomFieldQueryAtom) {
|
||||||
|
let valid = !!(atom.field && atom.operator && atom.value !== null)
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
CustomFieldQueryOperator.In.valueOf(),
|
||||||
|
CustomFieldQueryOperator.Contains.valueOf(),
|
||||||
|
].includes(atom.operator) &&
|
||||||
|
atom.value
|
||||||
|
) {
|
||||||
|
valid = valid && atom.value.length > 0
|
||||||
|
}
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateExpression(expression: CustomFieldQueryExpression) {
|
||||||
|
return (
|
||||||
|
expression.operator &&
|
||||||
|
expression.value.length > 0 &&
|
||||||
|
(expression.value as CustomFieldQueryElement[]).every((e) =>
|
||||||
|
e.type === CustomFieldQueryElementType.Atom
|
||||||
|
? this.validateAtom(e as CustomFieldQueryAtom)
|
||||||
|
: this.validateExpression(e as CustomFieldQueryExpression)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public addAtom(atom: CustomFieldQueryAtom) {
|
||||||
|
if (this.queries.length === 0) {
|
||||||
|
this.addExpression()
|
||||||
|
}
|
||||||
|
;(this.queries[0].value as CustomFieldQueryElement[]).push(atom)
|
||||||
|
atom.changed.subscribe(() => {
|
||||||
|
if (atom.field && atom.operator && atom.value) {
|
||||||
|
this.changed.next(this)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public addExpression(
|
||||||
|
expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
|
||||||
|
) {
|
||||||
|
if (this.queries.length > 0) {
|
||||||
|
;(
|
||||||
|
(this.queries[0] as CustomFieldQueryExpression)
|
||||||
|
.value as CustomFieldQueryElement[]
|
||||||
|
).push(expression)
|
||||||
|
} else {
|
||||||
|
this.queries.push(expression)
|
||||||
|
}
|
||||||
|
expression.changed.subscribe(() => {
|
||||||
|
this.changed.next(this)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private findElement(
|
||||||
|
queryElement: CustomFieldQueryElement,
|
||||||
|
elements: any[]
|
||||||
|
): CustomFieldQueryElement {
|
||||||
|
let foundElement
|
||||||
|
for (let i = 0; i < elements.length; i++) {
|
||||||
|
if (elements[i] === queryElement) {
|
||||||
|
foundElement = elements.splice(i, 1)[0]
|
||||||
|
} else if (elements[i].type === CustomFieldQueryElementType.Expression) {
|
||||||
|
foundElement = this.findElement(
|
||||||
|
queryElement,
|
||||||
|
elements[i].value as CustomFieldQueryElement[]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (foundElement) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return foundElement
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeElement(queryElement: CustomFieldQueryElement) {
|
||||||
|
let foundComponent
|
||||||
|
for (let i = 0; i < this.queries.length; i++) {
|
||||||
|
let query = this.queries[i]
|
||||||
|
if (query === queryElement) {
|
||||||
|
foundComponent = this.queries.splice(i, 1)[0]
|
||||||
|
break
|
||||||
|
} else if (query.type === CustomFieldQueryElementType.Expression) {
|
||||||
|
foundComponent = this.findElement(queryElement, query.value as any[])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundComponent) {
|
||||||
|
foundComponent.changed.complete()
|
||||||
|
if (this.isEmpty()) {
|
||||||
|
this.clear()
|
||||||
|
}
|
||||||
|
this.changed.next(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-custom-fields-query-dropdown',
|
||||||
|
templateUrl: './custom-fields-query-dropdown.component.html',
|
||||||
|
styleUrls: ['./custom-fields-query-dropdown.component.scss'],
|
||||||
|
})
|
||||||
|
export class CustomFieldsQueryDropdownComponent implements OnDestroy {
|
||||||
|
public CustomFieldQueryComponentType = CustomFieldQueryElementType
|
||||||
|
public CustomFieldQueryOperator = CustomFieldQueryOperator
|
||||||
|
public CustomFieldDataType = CustomFieldDataType
|
||||||
|
public CUSTOM_FIELD_QUERY_MAX_DEPTH = CUSTOM_FIELD_QUERY_MAX_DEPTH
|
||||||
|
public CUSTOM_FIELD_QUERY_MAX_ATOMS = CUSTOM_FIELD_QUERY_MAX_ATOMS
|
||||||
|
public popperOptions = popperOptionsReenablePreventOverflow
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
title: string
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
filterPlaceholder: string = ''
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
icon: string
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
allowSelectNone: boolean = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
editing = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
applyOnClose = false
|
||||||
|
|
||||||
|
get name(): string {
|
||||||
|
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
disabled: boolean = false
|
||||||
|
|
||||||
|
@ViewChild('dropdown') dropdown: NgbDropdown
|
||||||
|
|
||||||
|
@ViewChildren(NgSelectComponent) fieldSelects!: QueryList<NgSelectComponent>
|
||||||
|
|
||||||
|
private _selectionModel: CustomFieldQueriesModel
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
set selectionModel(model: CustomFieldQueriesModel) {
|
||||||
|
if (this._selectionModel) {
|
||||||
|
this._selectionModel.changed.complete()
|
||||||
|
}
|
||||||
|
model.changed.subscribe(() => {
|
||||||
|
this.onModelChange()
|
||||||
|
})
|
||||||
|
this._selectionModel = model
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectionModel(): CustomFieldQueriesModel {
|
||||||
|
return this._selectionModel
|
||||||
|
}
|
||||||
|
|
||||||
|
private onModelChange() {
|
||||||
|
if (this.selectionModel.isEmpty() || this.selectionModel.isValid()) {
|
||||||
|
this.selectionModelChange.next(this.selectionModel)
|
||||||
|
this.selectionModel.isEmpty() && this.dropdown?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Output()
|
||||||
|
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
|
||||||
|
|
||||||
|
customFields: CustomField[] = []
|
||||||
|
|
||||||
|
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
|
|
||||||
|
constructor(protected customFieldsService: CustomFieldsService) {
|
||||||
|
this.selectionModel = new CustomFieldQueriesModel()
|
||||||
|
this.getFields()
|
||||||
|
this.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.unsubscribeNotifier.next(this)
|
||||||
|
this.unsubscribeNotifier.complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
public onOpenChange(open: boolean) {
|
||||||
|
if (open) {
|
||||||
|
if (this.selectionModel.queries.length === 0) {
|
||||||
|
this.selectionModel.addAtom(
|
||||||
|
new CustomFieldQueryAtom([
|
||||||
|
null,
|
||||||
|
CustomFieldQueryOperator.Exists,
|
||||||
|
'true',
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.selectionModel.queries.length === 1 &&
|
||||||
|
(
|
||||||
|
(this.selectionModel.queries[0] as CustomFieldQueryExpression)
|
||||||
|
?.value[0] as CustomFieldQueryAtom
|
||||||
|
)?.field === null
|
||||||
|
) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.fieldSelects.first?.focus()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isActive(): boolean {
|
||||||
|
return this.selectionModel.isValid()
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFields() {
|
||||||
|
this.customFieldsService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((result) => {
|
||||||
|
this.customFields = result.results
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCustomFieldByID(id: number): CustomField {
|
||||||
|
return this.customFields.find((field) => field.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public addAtom(expression: CustomFieldQueryExpression) {
|
||||||
|
expression.addAtom()
|
||||||
|
}
|
||||||
|
|
||||||
|
public addExpression(expression: CustomFieldQueryExpression) {
|
||||||
|
expression.addExpression()
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeElement(element: CustomFieldQueryElement) {
|
||||||
|
this.selectionModel.removeElement(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset() {
|
||||||
|
this.selectionModel.clear(false)
|
||||||
|
this.selectionModel.changed.next(this.selectionModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
getOperatorsForField(
|
||||||
|
fieldID: number
|
||||||
|
): Array<{ value: string; label: string }> {
|
||||||
|
const field = this.customFields.find((field) => field.id === fieldID)
|
||||||
|
const groups: CustomFieldQueryOperatorGroups[] = field
|
||||||
|
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
|
||||||
|
: [CustomFieldQueryOperatorGroups.Basic]
|
||||||
|
const operators = groups.flatMap(
|
||||||
|
(group) => CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[group]
|
||||||
|
)
|
||||||
|
return operators.map((operator) => ({
|
||||||
|
value: operator,
|
||||||
|
label: CUSTOM_FIELD_QUERY_OPERATOR_LABELS[operator],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectOptionsForField(fieldID: number): string[] {
|
||||||
|
const field = this.customFields.find((field) => field.id === fieldID)
|
||||||
|
if (field) {
|
||||||
|
return field.extra_data['select_options']
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
@@ -1,4 +1,4 @@
|
|||||||
<div class="btn-group w-100" ngbDropdown role="group">
|
<div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions">
|
||||||
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateBefore || createdDateAfter ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
|
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateBefore || createdDateAfter ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
|
||||||
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs>
|
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs>
|
||||||
<div class="d-none d-sm-inline"> {{title}}</div>
|
<div class="d-none d-sm-inline"> {{title}}</div>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
|
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
|
||||||
<div class="pe-2 pe-lg-4">
|
<div class="pe-4">
|
||||||
{{rd.name}}
|
{{rd.name}}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted small pe-2">
|
<div class="text-muted small pe-2">
|
||||||
@@ -28,20 +28,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
|
<div class="list-group-item d-flex p-2" role="menuitem">
|
||||||
|
|
||||||
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
|
<div class="selected-icon">
|
||||||
<div i18n>After</div>
|
|
||||||
@if (createdDateAfter) {
|
@if (createdDateAfter) {
|
||||||
<a class="btn btn-link p-0 m-0" (click)="clearCreatedAfter()">
|
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedAfter()">
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||||
<small i18n>Clear</small>
|
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="input-group input-group-sm small ps-1 pe-2">
|
||||||
<div class="input-group input-group-sm">
|
<span class="input-group-text w-25 small text-muted" i18n>After</span>
|
||||||
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||||
maxlength="10" [(ngModel)]="createdDateAfter" ngbDatepicker #createdDateAfterPicker="ngbDatepicker">
|
maxlength="10" [(ngModel)]="createdDateAfter" ngbDatepicker #createdDateAfterPicker="ngbDatepicker">
|
||||||
<button class="btn btn-outline-secondary" (click)="createdDateAfterPicker.toggle()" type="button">
|
<button class="btn btn-outline-secondary" (click)="createdDateAfterPicker.toggle()" type="button">
|
||||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||||
@@ -49,20 +48,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
|
<div class="list-group-item d-flex p-2" role="menuitem">
|
||||||
|
|
||||||
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
|
<div class="selected-icon">
|
||||||
<div i18n>Before</div>
|
|
||||||
@if (createdDateBefore) {
|
@if (createdDateBefore) {
|
||||||
<a class="btn btn-link p-0 m-0" (click)="clearCreatedBefore()">
|
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedBefore()">
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||||
<small i18n>Clear</small>
|
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="input-group input-group-sm small ps-1 pe-2">
|
||||||
<div class="input-group input-group-sm">
|
<span class="input-group-text w-25 small text-muted" i18n>Before</span>
|
||||||
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||||
maxlength="10" [(ngModel)]="createdDateBefore" ngbDatepicker #createdDateBeforePicker="ngbDatepicker">
|
maxlength="10" [(ngModel)]="createdDateBefore" ngbDatepicker #createdDateBeforePicker="ngbDatepicker">
|
||||||
<button class="btn btn-outline-secondary" (click)="createdDateBeforePicker.toggle()" type="button">
|
<button class="btn btn-outline-secondary" (click)="createdDateBeforePicker.toggle()" type="button">
|
||||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||||
@@ -83,7 +81,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
|
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
|
||||||
<div class="pe-2 pe-lg-4">
|
<div class="pe-4">
|
||||||
{{rd.name}}
|
{{rd.name}}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted small pe-2">
|
<div class="text-muted small pe-2">
|
||||||
@@ -94,20 +92,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
|
<div class="list-group-item d-flex p-2" role="menuitem">
|
||||||
|
|
||||||
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
|
<div class="selected-icon">
|
||||||
<div i18n>After</div>
|
|
||||||
@if (addedDateAfter) {
|
@if (addedDateAfter) {
|
||||||
<a class="btn btn-link p-0 m-0" (click)="clearAddedAfter()">
|
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedAfter()">
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||||
<small i18n>Clear</small>
|
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="input-group input-group-sm small ps-1 pe-2">
|
||||||
<div class="input-group input-group-sm">
|
<span class="input-group-text w-25 small text-muted" i18n>After</span>
|
||||||
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||||
maxlength="10" [(ngModel)]="addedDateAfter" ngbDatepicker #addedDateAfterPicker="ngbDatepicker">
|
maxlength="10" [(ngModel)]="addedDateAfter" ngbDatepicker #addedDateAfterPicker="ngbDatepicker">
|
||||||
<button class="btn btn-outline-secondary" (click)="addedDateAfterPicker.toggle()" type="button">
|
<button class="btn btn-outline-secondary" (click)="addedDateAfterPicker.toggle()" type="button">
|
||||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||||
@@ -115,20 +112,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
|
<div class="list-group-item d-flex p-2" role="menuitem">
|
||||||
|
|
||||||
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
|
<div class="selected-icon">
|
||||||
<div i18n>Before</div>
|
|
||||||
@if (addedDateBefore) {
|
@if (addedDateBefore) {
|
||||||
<a class="btn btn-link p-0 m-0" (click)="clearAddedBefore()">
|
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedBefore()">
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||||
<small i18n>Clear</small>
|
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="input-group input-group-sm small ps-1 pe-2">
|
||||||
<div class="input-group input-group-sm">
|
<span class="input-group-text w-25 small text-muted" i18n>Before</span>
|
||||||
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||||
maxlength="10" [(ngModel)]="addedDateBefore" ngbDatepicker #addedDateBeforePicker="ngbDatepicker">
|
maxlength="10" [(ngModel)]="addedDateBefore" ngbDatepicker #addedDateBeforePicker="ngbDatepicker">
|
||||||
<button class="btn btn-outline-secondary" (click)="addedDateBeforePicker.toggle()" type="button">
|
<button class="btn btn-outline-secondary" (click)="addedDateBeforePicker.toggle()" type="button">
|
||||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||||
|
@@ -5,6 +5,12 @@
|
|||||||
--bs-dropdown-min-width: 40rem;
|
--bs-dropdown-min-width: 40rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 767px) {
|
||||||
|
.border-end {
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.btn-link {
|
.btn-link {
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -14,3 +20,24 @@
|
|||||||
min-width: 1em;
|
min-width: 1em;
|
||||||
min-height: 1em;
|
min-height: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-group-sm {
|
||||||
|
.form-control {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-variants {
|
||||||
|
.variant-focused {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover, &:focus {
|
||||||
|
.variant-unfocused {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.variant-focused {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -11,6 +11,7 @@ import { Subject, Subscription } from 'rxjs'
|
|||||||
import { debounceTime } from 'rxjs/operators'
|
import { debounceTime } from 'rxjs/operators'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||||
|
import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options'
|
||||||
|
|
||||||
export interface DateSelection {
|
export interface DateSelection {
|
||||||
createdBefore?: string
|
createdBefore?: string
|
||||||
@@ -35,6 +36,8 @@ export enum RelativeDate {
|
|||||||
providers: [{ provide: NgbDateAdapter, useClass: ISODateAdapter }],
|
providers: [{ provide: NgbDateAdapter, useClass: ISODateAdapter }],
|
||||||
})
|
})
|
||||||
export class DatesDropdownComponent implements OnInit, OnDestroy {
|
export class DatesDropdownComponent implements OnInit, OnDestroy {
|
||||||
|
public popperOptions = popperOptionsReenablePreventOverflow
|
||||||
|
|
||||||
constructor(settings: SettingsService) {
|
constructor(settings: SettingsService) {
|
||||||
this.datePlaceHolder = settings.getLocalizedDateInputFormat()
|
this.datePlaceHolder = settings.getLocalizedDateInputFormat()
|
||||||
}
|
}
|
||||||
|
@@ -28,6 +28,11 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@case (CustomFieldDataType.Monetary) {
|
||||||
|
<div class="my-3">
|
||||||
|
<pngx-input-text i18n-title title="Default Currency" hint="3-character currency code" i18n-hint formControlName="default_currency" placeholder="Use locale" i18n-placeholder autocomplete="off"></pngx-input-text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -67,7 +67,7 @@ export class CustomFieldEditDialogComponent
|
|||||||
this.selectOptionInputs.changes
|
this.selectOptionInputs.changes
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.selectOptionInputs.last.nativeElement.focus()
|
this.selectOptionInputs.last?.nativeElement.focus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +90,7 @@ export class CustomFieldEditDialogComponent
|
|||||||
data_type: new FormControl(null),
|
data_type: new FormControl(null),
|
||||||
extra_data: new FormGroup({
|
extra_data: new FormGroup({
|
||||||
select_options: new FormArray([new FormControl(null)]),
|
select_options: new FormArray([new FormControl(null)]),
|
||||||
|
default_currency: new FormControl(null),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@@ -11,7 +11,7 @@ import {
|
|||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { IMAPSecurity } from 'src/app/data/mail-account'
|
import { IMAPSecurity, MailAccountType } from 'src/app/data/mail-account'
|
||||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
@@ -82,6 +82,7 @@ describe('MailAccountEditDialogComponent', () => {
|
|||||||
imap_port: 443,
|
imap_port: 443,
|
||||||
imap_security: IMAPSecurity.SSL,
|
imap_security: IMAPSecurity.SSL,
|
||||||
is_token: false,
|
is_token: false,
|
||||||
|
account_type: MailAccountType.IMAP,
|
||||||
}
|
}
|
||||||
|
|
||||||
// success
|
// success
|
||||||
|
@@ -10,36 +10,60 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
<pngx-input-text [horizontal]="true" i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
||||||
<pngx-input-select i18n-title title="Account" [items]="accounts" formControlName="account"></pngx-input-select>
|
|
||||||
<pngx-input-text i18n-title title="Folder" formControlName="folder" i18n-hint hint="Subfolders must be separated by a delimiter, often a dot ('.') or slash ('/'), but it varies by mail server." [error]="error?.folder"></pngx-input-text>
|
|
||||||
<pngx-input-number i18n-title title="Maximum age (days)" formControlName="maximum_age" [showAdd]="false" [error]="error?.maximum_age"></pngx-input-number>
|
|
||||||
<pngx-input-select i18n-title title="Attachment type" [items]="attachmentTypeOptions" formControlName="attachment_type"></pngx-input-select>
|
|
||||||
<pngx-input-select i18n-title title="Consumption scope" [items]="consumptionScopeOptions" formControlName="consumption_scope" i18n-hint hint="See docs for .eml processing requirements"></pngx-input-select>
|
|
||||||
<pngx-input-number i18n-title title="Rule order" formControlName="order" [showAdd]="false" [error]="error?.order"></pngx-input-number>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<p class="small" i18n>Paperless will only process mails that match <em>all</em> of the filters specified below.</p>
|
<pngx-input-select [horizontal]="true" i18n-title title="Account" [items]="accounts" formControlName="account"></pngx-input-select>
|
||||||
<pngx-input-text i18n-title title="Filter from" formControlName="filter_from" [error]="error?.filter_from"></pngx-input-text>
|
|
||||||
<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 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>
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<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>
|
<pngx-input-number [horizontal]="true" i18n-title title="Order" formControlName="order" [showAdd]="false" [error]="error?.order"></pngx-input-number>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 pt-2">
|
||||||
|
<pngx-input-switch [horizontal]="true" i18n-title title="Enabled" formControlName="enabled"></pngx-input-switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="mt-0"/>
|
||||||
|
<div class="row">
|
||||||
|
<p class="small" i18n>Paperless will only process mails that match <em>all</em> of the criteria specified below.</p>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<pngx-input-text [horizontal]="true" i18n-title title="Folder" formControlName="folder" i18n-hint hint="Subfolders must be separated by a delimiter, often a dot ('.') or slash ('/'), but it varies by mail server." [error]="error?.folder"></pngx-input-text>
|
||||||
|
<pngx-input-number [horizontal]="true" i18n-title title="Maximum age (days)" formControlName="maximum_age" [showAdd]="false" [error]="error?.maximum_age"></pngx-input-number>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<pngx-input-text [horizontal]="true" i18n-title title="Filter from" formControlName="filter_from" [error]="error?.filter_from"></pngx-input-text>
|
||||||
|
<pngx-input-text [horizontal]="true" i18n-title title="Filter to" formControlName="filter_to" [error]="error?.filter_to"></pngx-input-text>
|
||||||
|
<pngx-input-text [horizontal]="true" i18n-title title="Filter subject" formControlName="filter_subject" [error]="error?.filter_subject"></pngx-input-text>
|
||||||
|
<pngx-input-text [horizontal]="true" i18n-title title="Filter body" formControlName="filter_body" [error]="error?.filter_body"></pngx-input-text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="mt-0"/>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<pngx-input-select [horizontal]="true" i18n-title title="Consumption scope" [items]="consumptionScopeOptions" formControlName="consumption_scope" i18n-hint hint="See docs for .eml processing requirements"></pngx-input-select>
|
||||||
|
<pngx-input-select [horizontal]="true" i18n-title title="Attachment type" [items]="attachmentTypeOptions" formControlName="attachment_type"></pngx-input-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<pngx-input-text [horizontal]="true" i18n-title title="Include only files matching" formControlName="filter_attachment_filename_include" i18n-hint hint="Optional. Wildcards e.g. *.pdf or *invoice* allowed. Can be comma-separated list. Case insensitive." [error]="error?.filter_attachment_filename_include"></pngx-input-text>
|
||||||
|
<pngx-input-text [horizontal]="true" i18n-title title="Exclude files matching" formControlName="filter_attachment_filename_exclude" i18n-hint hint="Optional. Wildcards e.g. *.pdf or *invoice* allowed. Can be comma-separated list. Case insensitive." [error]="error?.filter_attachment_filename_exclude"></pngx-input-text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="mt-0"/>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<pngx-input-select [horizontal]="true" i18n-title title="Action" [items]="actionOptions" formControlName="action" i18n-hint hint="Only performed if the mail is processed."></pngx-input-select>
|
||||||
@if (showActionParamField) {
|
@if (showActionParamField) {
|
||||||
<pngx-input-text i18n-title title="Action parameter" formControlName="action_parameter" [error]="error?.action_parameter"></pngx-input-text>
|
<pngx-input-text [horizontal]="true" i18n-title title="Action parameter" formControlName="action_parameter" [error]="error?.action_parameter"></pngx-input-text>
|
||||||
}
|
}
|
||||||
<pngx-input-select i18n-title title="Assign title from" [items]="metadataTitleOptions" formControlName="assign_title_from"></pngx-input-select>
|
<pngx-input-select [horizontal]="true" i18n-title title="Assign title from" [items]="metadataTitleOptions" formControlName="assign_title_from"></pngx-input-select>
|
||||||
<pngx-input-tags [allowCreate]="false" formControlName="assign_tags"></pngx-input-tags>
|
<pngx-input-check [horizontal]="true" i18n-title title="Assign owner from rule" formControlName="assign_owner_from_rule"></pngx-input-check>
|
||||||
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
|
</div>
|
||||||
<pngx-input-select i18n-title title="Assign correspondent from" [items]="metadataCorrespondentOptions" formControlName="assign_correspondent_from"></pngx-input-select>
|
<div class="col-md-6">
|
||||||
|
<pngx-input-tags [horizontal]="true" [allowCreate]="false" formControlName="assign_tags"></pngx-input-tags>
|
||||||
|
<pngx-input-select [horizontal]="true" i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
|
||||||
|
<pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent from" [items]="metadataCorrespondentOptions" formControlName="assign_correspondent_from"></pngx-input-select>
|
||||||
@if (showCorrespondentField) {
|
@if (showCorrespondentField) {
|
||||||
<pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
|
<pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
|
||||||
}
|
}
|
||||||
<pngx-input-check i18n-title title="Assign owner from rule" formControlName="assign_owner_from_rule"></pngx-input-check>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -24,6 +24,7 @@ import { TextComponent } from '../../input/text/text.component'
|
|||||||
import { EditDialogMode } from '../edit-dialog.component'
|
import { EditDialogMode } from '../edit-dialog.component'
|
||||||
import { MailRuleEditDialogComponent } from './mail-rule-edit-dialog.component'
|
import { MailRuleEditDialogComponent } from './mail-rule-edit-dialog.component'
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { SwitchComponent } from '../../input/switch/switch.component'
|
||||||
|
|
||||||
describe('MailRuleEditDialogComponent', () => {
|
describe('MailRuleEditDialogComponent', () => {
|
||||||
let component: MailRuleEditDialogComponent
|
let component: MailRuleEditDialogComponent
|
||||||
@@ -43,6 +44,7 @@ describe('MailRuleEditDialogComponent', () => {
|
|||||||
TagsComponent,
|
TagsComponent,
|
||||||
SafeHtmlPipe,
|
SafeHtmlPipe,
|
||||||
CheckComponent,
|
CheckComponent,
|
||||||
|
SwitchComponent,
|
||||||
],
|
],
|
||||||
imports: [FormsModule, ReactiveFormsModule, NgSelectModule, NgbModule],
|
imports: [FormsModule, ReactiveFormsModule, NgSelectModule, NgbModule],
|
||||||
providers: [
|
providers: [
|
||||||
|
@@ -153,6 +153,7 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
|
|||||||
return new FormGroup({
|
return new FormGroup({
|
||||||
name: new FormControl(null),
|
name: new FormControl(null),
|
||||||
account: new FormControl(null),
|
account: new FormControl(null),
|
||||||
|
enabled: new FormControl(true),
|
||||||
folder: new FormControl('INBOX'),
|
folder: new FormControl('INBOX'),
|
||||||
filter_from: new FormControl(null),
|
filter_from: new FormControl(null),
|
||||||
filter_to: new FormControl(null),
|
filter_to: new FormControl(null),
|
||||||
|
@@ -10,7 +10,57 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|
||||||
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
||||||
<pngx-input-text i18n-title title="Path" formControlName="path" [error]="error?.path" [hint]="pathHint"></pngx-input-text>
|
<pngx-input-textarea i18n-title title="Path" formControlName="path" [error]="error?.path" hint="See <a target='_blank' href='https://docs.paperless-ngx.com/advanced_usage/#file-name-handling'>the documentation</a>." i18n-hint [monospace]="true"></pngx-input-textarea>
|
||||||
|
|
||||||
|
<div ngbAccordion>
|
||||||
|
<div ngbAccordionItem>
|
||||||
|
<h2 ngbAccordionHeader>
|
||||||
|
<button ngbAccordionButton i18n>Preview</button>
|
||||||
|
</h2>
|
||||||
|
<div ngbAccordionCollapse>
|
||||||
|
<div ngbAccordionBody>
|
||||||
|
<ng-template>
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
@if (testLoading) {
|
||||||
|
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
|
||||||
|
} @else if (testResult) {
|
||||||
|
<code>{{testResult}}</code>
|
||||||
|
} @else if (testFailed) {
|
||||||
|
<div class="text-danger" i18n>Path test failed</div>
|
||||||
|
} @else {
|
||||||
|
<div class="text-muted small" i18n>No document selected</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ng-select name="testDocument"
|
||||||
|
[items]="foundDocuments$ | async"
|
||||||
|
placeholder="Search for a document" i18n-placeholder
|
||||||
|
notFoundText="No documents found" i18n-notFoundText
|
||||||
|
bindValue="id"
|
||||||
|
bindLabel="title"
|
||||||
|
[compareWith]="compareDocuments"
|
||||||
|
[trackByFn]="trackByFn"
|
||||||
|
[minTermLength]="2"
|
||||||
|
[loading]="loading"
|
||||||
|
[typeahead]="documentsInput$"
|
||||||
|
(change)="testPath($event)">
|
||||||
|
<ng-template #loadingTemplate 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>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr/>
|
||||||
|
|
||||||
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||||
@if (patternRequired) {
|
@if (patternRequired) {
|
||||||
<pngx-input-text i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
|
<pngx-input-text i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
|
||||||
|
@@ -0,0 +1,4 @@
|
|||||||
|
.accordion {
|
||||||
|
--bs-accordion-btn-padding-x: 0.75rem;
|
||||||
|
--bs-accordion-btn-padding-y: 0.375rem;
|
||||||
|
}
|
||||||
|
@@ -1,7 +1,11 @@
|
|||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import {
|
||||||
|
NgbAccordionButton,
|
||||||
|
NgbActiveModal,
|
||||||
|
NgbModule,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
@@ -10,13 +14,20 @@ import { SettingsService } from 'src/app/services/settings.service'
|
|||||||
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
|
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
|
||||||
import { SelectComponent } from '../../input/select/select.component'
|
import { SelectComponent } from '../../input/select/select.component'
|
||||||
import { TextComponent } from '../../input/text/text.component'
|
import { TextComponent } from '../../input/text/text.component'
|
||||||
|
import { TextAreaComponent } from '../../input/textarea/textarea.component'
|
||||||
import { EditDialogMode } from '../edit-dialog.component'
|
import { EditDialogMode } from '../edit-dialog.component'
|
||||||
import { StoragePathEditDialogComponent } from './storage-path-edit-dialog.component'
|
import { StoragePathEditDialogComponent } from './storage-path-edit-dialog.component'
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { of, throwError } from 'rxjs'
|
||||||
|
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
|
||||||
|
import { By } from '@angular/platform-browser'
|
||||||
|
|
||||||
describe('StoragePathEditDialogComponent', () => {
|
describe('StoragePathEditDialogComponent', () => {
|
||||||
let component: StoragePathEditDialogComponent
|
let component: StoragePathEditDialogComponent
|
||||||
let settingsService: SettingsService
|
let settingsService: SettingsService
|
||||||
|
let documentService: DocumentService
|
||||||
let fixture: ComponentFixture<StoragePathEditDialogComponent>
|
let fixture: ComponentFixture<StoragePathEditDialogComponent>
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -27,6 +38,7 @@ describe('StoragePathEditDialogComponent', () => {
|
|||||||
IfOwnerDirective,
|
IfOwnerDirective,
|
||||||
SelectComponent,
|
SelectComponent,
|
||||||
TextComponent,
|
TextComponent,
|
||||||
|
TextAreaComponent,
|
||||||
PermissionsFormComponent,
|
PermissionsFormComponent,
|
||||||
SafeHtmlPipe,
|
SafeHtmlPipe,
|
||||||
],
|
],
|
||||||
@@ -38,6 +50,7 @@ describe('StoragePathEditDialogComponent', () => {
|
|||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
documentService = TestBed.inject(DocumentService)
|
||||||
fixture = TestBed.createComponent(StoragePathEditDialogComponent)
|
fixture = TestBed.createComponent(StoragePathEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser = { id: 99, username: 'user99' }
|
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||||
@@ -57,4 +70,87 @@ describe('StoragePathEditDialogComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should support test path', () => {
|
||||||
|
const testSpy = jest.spyOn(
|
||||||
|
component['service'] as StoragePathService,
|
||||||
|
'testPath'
|
||||||
|
)
|
||||||
|
testSpy.mockReturnValueOnce(of('test/abc123'))
|
||||||
|
component.objectForm.patchValue({ path: 'test/{{title}}' })
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.testPath({ id: 1 })
|
||||||
|
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
|
||||||
|
expect(component.testResult).toBe('test/abc123')
|
||||||
|
expect(component.testFailed).toBeFalsy()
|
||||||
|
|
||||||
|
// test failed
|
||||||
|
testSpy.mockReturnValueOnce(of(''))
|
||||||
|
component.testPath({ id: 1 })
|
||||||
|
expect(component.testResult).toBeNull()
|
||||||
|
expect(component.testFailed).toBeTruthy()
|
||||||
|
|
||||||
|
component.testPath(null)
|
||||||
|
expect(component.testResult).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should compare two documents by id', () => {
|
||||||
|
const doc1 = { id: 1 }
|
||||||
|
const doc2 = { id: 2 }
|
||||||
|
expect(component.compareDocuments(doc1, doc1)).toBeTruthy()
|
||||||
|
expect(component.compareDocuments(doc1, doc2)).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use id as trackBy', () => {
|
||||||
|
expect(component.trackByFn({ id: 1 })).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search on select text input', () => {
|
||||||
|
fixture.debugElement
|
||||||
|
.query(By.directive(NgbAccordionButton))
|
||||||
|
.triggerEventHandler('click', null)
|
||||||
|
fixture.detectChanges()
|
||||||
|
const documents = [
|
||||||
|
{ id: 1, title: 'foo' },
|
||||||
|
{ id: 2, title: 'bar' },
|
||||||
|
]
|
||||||
|
const listSpy = jest.spyOn(documentService, 'listFiltered')
|
||||||
|
listSpy.mockReturnValueOnce(
|
||||||
|
of({
|
||||||
|
count: 1,
|
||||||
|
results: documents[0],
|
||||||
|
all: [1],
|
||||||
|
} as any)
|
||||||
|
)
|
||||||
|
component.documentsInput$.next('bar')
|
||||||
|
expect(listSpy).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
'created',
|
||||||
|
true,
|
||||||
|
[{ rule_type: FILTER_TITLE, value: 'bar' }],
|
||||||
|
{ truncate_content: true }
|
||||||
|
)
|
||||||
|
listSpy.mockReturnValueOnce(
|
||||||
|
of({
|
||||||
|
count: 2,
|
||||||
|
results: [...documents],
|
||||||
|
all: [1, 2],
|
||||||
|
} as any)
|
||||||
|
)
|
||||||
|
component.documentsInput$.next('ba')
|
||||||
|
listSpy.mockReturnValueOnce(throwError(() => new Error()))
|
||||||
|
component.documentsInput$.next('foo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should run path test on path change', () => {
|
||||||
|
const testSpy = jest.spyOn(component, 'testPath')
|
||||||
|
component['testDocument'] = { id: 1 } as any
|
||||||
|
component.objectForm.patchValue(
|
||||||
|
{ path: 'test/{{title}}' },
|
||||||
|
{ emitEvent: true }
|
||||||
|
)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(testSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -1,9 +1,25 @@
|
|||||||
import { Component } from '@angular/core'
|
import { Component, OnDestroy } from '@angular/core'
|
||||||
import { FormControl, FormGroup } from '@angular/forms'
|
import { FormControl, FormGroup } from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import {
|
||||||
|
Subject,
|
||||||
|
Observable,
|
||||||
|
concat,
|
||||||
|
of,
|
||||||
|
distinctUntilChanged,
|
||||||
|
takeUntil,
|
||||||
|
tap,
|
||||||
|
switchMap,
|
||||||
|
map,
|
||||||
|
catchError,
|
||||||
|
filter,
|
||||||
|
} from 'rxjs'
|
||||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||||
|
import { Document } from 'src/app/data/document'
|
||||||
|
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
|
||||||
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
|
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
|
||||||
import { StoragePath } from 'src/app/data/storage-path'
|
import { StoragePath } from 'src/app/data/storage-path'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
@@ -13,24 +29,34 @@ import { SettingsService } from 'src/app/services/settings.service'
|
|||||||
templateUrl: './storage-path-edit-dialog.component.html',
|
templateUrl: './storage-path-edit-dialog.component.html',
|
||||||
styleUrls: ['./storage-path-edit-dialog.component.scss'],
|
styleUrls: ['./storage-path-edit-dialog.component.scss'],
|
||||||
})
|
})
|
||||||
export class StoragePathEditDialogComponent extends EditDialogComponent<StoragePath> {
|
export class StoragePathEditDialogComponent
|
||||||
|
extends EditDialogComponent<StoragePath>
|
||||||
|
implements OnDestroy
|
||||||
|
{
|
||||||
|
public documentsInput$ = new Subject<string>()
|
||||||
|
public foundDocuments$: Observable<Document[]>
|
||||||
|
private testDocument: Document
|
||||||
|
public testResult: string
|
||||||
|
public testFailed: boolean = false
|
||||||
|
public loading = false
|
||||||
|
public testLoading = false
|
||||||
|
|
||||||
|
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
service: StoragePathService,
|
service: StoragePathService,
|
||||||
activeModal: NgbActiveModal,
|
activeModal: NgbActiveModal,
|
||||||
userService: UserService,
|
userService: UserService,
|
||||||
settingsService: SettingsService
|
settingsService: SettingsService,
|
||||||
|
private documentsService: DocumentService
|
||||||
) {
|
) {
|
||||||
super(service, activeModal, userService, settingsService)
|
super(service, activeModal, userService, settingsService)
|
||||||
|
this.initPathObservables()
|
||||||
}
|
}
|
||||||
|
|
||||||
get pathHint() {
|
ngOnDestroy(): void {
|
||||||
return (
|
this.unsubscribeNotifier.next(this)
|
||||||
$localize`e.g.` +
|
this.unsubscribeNotifier.complete()
|
||||||
' <code>{created_year}-{title}</code> ' +
|
|
||||||
$localize`or use slashes to add directories e.g.` +
|
|
||||||
' <code>{created_year}/{correspondent}/{title}</code>. ' +
|
|
||||||
$localize`See <a target="_blank" href="https://docs.paperless-ngx.com/advanced_usage/#file-name-handling">documentation</a> for full list.`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getCreateTitle() {
|
getCreateTitle() {
|
||||||
@@ -51,4 +77,70 @@ export class StoragePathEditDialogComponent extends EditDialogComponent<StorageP
|
|||||||
permissions_form: new FormControl(null),
|
permissions_form: new FormControl(null),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public testPath(document: Document) {
|
||||||
|
if (!document) {
|
||||||
|
this.testResult = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.testDocument = document
|
||||||
|
this.testLoading = true
|
||||||
|
;(this.service as StoragePathService)
|
||||||
|
.testPath(this.objectForm.get('path').value, document.id)
|
||||||
|
.subscribe((result) => {
|
||||||
|
if (result?.length) {
|
||||||
|
this.testResult = result
|
||||||
|
this.testFailed = false
|
||||||
|
} else {
|
||||||
|
this.testResult = null
|
||||||
|
this.testFailed = true
|
||||||
|
}
|
||||||
|
this.testLoading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
compareDocuments(document: Document, selectedDocument: Document) {
|
||||||
|
return document.id === selectedDocument.id
|
||||||
|
}
|
||||||
|
|
||||||
|
private initPathObservables() {
|
||||||
|
this.objectForm
|
||||||
|
.get('path')
|
||||||
|
.valueChanges.pipe(
|
||||||
|
takeUntil(this.unsubscribeNotifier),
|
||||||
|
filter((path) => path && !!this.testDocument)
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.testPath(this.testDocument)
|
||||||
|
})
|
||||||
|
|
||||||
|
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((result) => result.results),
|
||||||
|
catchError(() => of([])), // empty on error
|
||||||
|
tap(() => (this.loading = false))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByFn(item: Document) {
|
||||||
|
return item.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,3 +3,7 @@
|
|||||||
color: rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important;
|
color: rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.accordion-button {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)">
|
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions">
|
||||||
<button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
|
<button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
|
||||||
<i-bs name="{{icon}}"></i-bs>
|
<i-bs name="{{icon}}"></i-bs>
|
||||||
<div class="d-none d-sm-inline"> {{title}}</div>
|
<div class="d-none d-sm-inline"> {{title}}</div>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
@if (selectionModel.items) {
|
@if (selectionModel.items) {
|
||||||
<div class="items" #buttonItems>
|
<div class="items" #buttonItems>
|
||||||
@for (item of selectionModel.itemsSorted | filter: filterText; track item; let i = $index) {
|
@for (item of selectionModel.itemsSorted | filter: filterText:'name'; track item; let i = $index) {
|
||||||
@if (allowSelectNone || item.id) {
|
@if (allowSelectNone || item.id) {
|
||||||
<pngx-toggleable-dropdown-button
|
<pngx-toggleable-dropdown-button
|
||||||
[item]="item" [hideCount]="hideCount(item)" [state]="selectionModel.get(item.id)" [count]="getUpdatedDocumentCount(item.id)" (toggle)="selectionModel.toggle(item.id)" (exclude)="excludeClicked(item.id)" (click)="setButtonItemIndex(i - 1)" [disabled]="disabled">
|
[item]="item" [hideCount]="hideCount(item)" [state]="selectionModel.get(item.id)" [count]="getUpdatedDocumentCount(item.id)" (toggle)="selectionModel.toggle(item.id)" (exclude)="excludeClicked(item.id)" (click)="setButtonItemIndex(i - 1)" [disabled]="disabled">
|
||||||
@@ -45,13 +45,13 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (editing) {
|
@if (editing) {
|
||||||
@if ((selectionModel.itemsSorted | filter: filterText).length === 0 && createRef !== undefined) {
|
@if ((selectionModel.itemsSorted | filter: filterText:'name').length === 0 && createRef !== undefined) {
|
||||||
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
|
||||||
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
||||||
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if ((selectionModel.itemsSorted | filter: filterText).length > 0) {
|
@if ((selectionModel.itemsSorted | filter: filterText:'name').length > 0) {
|
||||||
<button class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
|
<button class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
|
||||||
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
|
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
|
||||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||||
|
@@ -539,15 +539,10 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
fixture.nativeElement
|
fixture.nativeElement
|
||||||
.querySelector('button')
|
.querySelector('button')
|
||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
|
||||||
tick(100)
|
tick(100)
|
||||||
component.filterText = 'FooBar'
|
component.filterText = 'FooBar'
|
||||||
fixture.detectChanges()
|
component.listFilterEnter()
|
||||||
component.listFilterTextInput.nativeElement.dispatchEvent(
|
|
||||||
new KeyboardEvent('keyup', { key: 'Enter' })
|
|
||||||
)
|
|
||||||
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
||||||
tick(300)
|
|
||||||
expect(createSpy).toHaveBeenCalled()
|
expect(createSpy).toHaveBeenCalled()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
@@ -16,6 +16,7 @@ import { Subject, filter, take, takeUntil } from 'rxjs'
|
|||||||
import { SelectionDataItem } from 'src/app/services/rest/document.service'
|
import { SelectionDataItem } from 'src/app/services/rest/document.service'
|
||||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||||
|
import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options'
|
||||||
|
|
||||||
export interface ChangedItems {
|
export interface ChangedItems {
|
||||||
itemsToAdd: MatchingModel[]
|
itemsToAdd: MatchingModel[]
|
||||||
@@ -330,6 +331,8 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
|
|||||||
@ViewChild('dropdown') dropdown: NgbDropdown
|
@ViewChild('dropdown') dropdown: NgbDropdown
|
||||||
@ViewChild('buttonItems') buttonItems: ElementRef
|
@ViewChild('buttonItems') buttonItems: ElementRef
|
||||||
|
|
||||||
|
public popperOptions = popperOptionsReenablePreventOverflow
|
||||||
|
|
||||||
filterText: string
|
filterText: string
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
@@ -483,7 +486,7 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
|
|||||||
dropdownOpenChange(open: boolean): void {
|
dropdownOpenChange(open: boolean): void {
|
||||||
if (open) {
|
if (open) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.listFilterTextInput.nativeElement.focus()
|
this.listFilterTextInput?.nativeElement.focus()
|
||||||
}, 0)
|
}, 0)
|
||||||
if (this.editing) {
|
if (this.editing) {
|
||||||
this.selectionModel.reset()
|
this.selectionModel.reset()
|
||||||
@@ -492,7 +495,7 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
|
|||||||
this.opened.next(this)
|
this.opened.next(this)
|
||||||
} else {
|
} else {
|
||||||
if (this.creating) {
|
if (this.creating) {
|
||||||
this.dropdown.open()
|
this.dropdown?.open()
|
||||||
this.creating = false
|
this.creating = false
|
||||||
} else {
|
} else {
|
||||||
this.filterText = ''
|
this.filterText = ''
|
||||||
|
@@ -1,50 +1,63 @@
|
|||||||
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
|
@if (minimal) {
|
||||||
<div class="row">
|
<ng-container *ngTemplateOutlet="select"></ng-container>
|
||||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
} @else {
|
||||||
@if (title) {
|
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
|
||||||
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
<div class="row">
|
||||||
}
|
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||||
@if (removable) {
|
@if (title) {
|
||||||
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
||||||
<i-bs name="x"></i-bs> <ng-container i18n>Remove</ng-container>
|
}
|
||||||
</button>
|
@if (removable) {
|
||||||
}
|
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||||
</div>
|
<i-bs name="x"></i-bs> <ng-container i18n>Remove</ng-container>
|
||||||
<div [class.col-md-9]="horizontal">
|
</button>
|
||||||
<div>
|
}
|
||||||
<ng-select name="inputId" [(ngModel)]="selectedDocuments"
|
</div>
|
||||||
[disabled]="disabled"
|
<div [class.col-md-9]="horizontal">
|
||||||
[items]="foundDocuments$ | async"
|
<ng-container *ngTemplateOutlet="select"></ng-container>
|
||||||
placeholder="Search for documents"
|
@if (hint) {
|
||||||
[notFoundText]="notFoundText"
|
<small class="form-text text-muted">{{hint}}</small>
|
||||||
[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">
|
|
||||||
<button class="btn p-0 lh-1" (click)="unselect(document)" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
|
|
||||||
<a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title>
|
|
||||||
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs> <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>
|
</div>
|
||||||
@if (hint) {
|
|
||||||
<small class="form-text text-muted">{{hint}}</small>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
|
<ng-template #select>
|
||||||
|
<ng-select name="inputId" [(ngModel)]="selectedDocuments"
|
||||||
|
[disabled]="disabled"
|
||||||
|
[items]="foundDocuments$ | async"
|
||||||
|
[placeholder]="placeholder"
|
||||||
|
[notFoundText]="notFoundText"
|
||||||
|
[multiple]="true"
|
||||||
|
bindValue="id"
|
||||||
|
[compareWith]="compareDocuments"
|
||||||
|
[trackByFn]="trackByFn"
|
||||||
|
[minTermLength]="2"
|
||||||
|
[loading]="loading"
|
||||||
|
[typeahead]="documentsInput$"
|
||||||
|
(mousedown)="$event.stopImmediatePropagation()"
|
||||||
|
(change)="onChange(selectedDocuments)">
|
||||||
|
<ng-template ng-label-tmp let-document="item">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<button class="btn p-0 lh-1" *ngIf="!disabled" (click)="unselect(document)" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
|
||||||
|
@if (document.title) {
|
||||||
|
<a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title>
|
||||||
|
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs> <span>{{document.title}}</span>
|
||||||
|
</a>
|
||||||
|
} @else {
|
||||||
|
<span class="badge bg-light text-muted" (click)="unselect(document)" title="Remove link" i18n-title>
|
||||||
|
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill"></i-bs> <span i18n>Not found</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
</ng-template>
|
||||||
|
@@ -3,7 +3,19 @@
|
|||||||
|
|
||||||
.ng-value {
|
.ng-value {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
border-color: transparent;
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperless-input-select.disabled {
|
||||||
|
--bs-btn-disabled-border-color: transparent;
|
||||||
|
::ng-deep ng-select {
|
||||||
|
.ng-select-container {
|
||||||
|
div, .ng-arrow-wrapper, input {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
background-color: var(--pngx-bg-alt) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -46,6 +46,12 @@ export class DocumentLinkComponent
|
|||||||
@Input()
|
@Input()
|
||||||
parentDocumentID: number
|
parentDocumentID: number
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
minimal: boolean = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
placeholder: string = $localize`Search for documents`
|
||||||
|
|
||||||
constructor(private documentsService: DocumentService) {
|
constructor(private documentsService: DocumentService) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
@@ -108,7 +114,7 @@ export class DocumentLinkComponent
|
|||||||
|
|
||||||
unselect(document: Document): void {
|
unselect(document: Document): void {
|
||||||
this.selectedDocuments = this.selectedDocuments.filter(
|
this.selectedDocuments = this.selectedDocuments.filter(
|
||||||
(d) => d.id !== document.id
|
(d) => d && d.id !== document.id
|
||||||
)
|
)
|
||||||
this.onChange(this.selectedDocuments.map((d) => d.id))
|
this.onChange(this.selectedDocuments.map((d) => d.id))
|
||||||
}
|
}
|
||||||
|
@@ -38,7 +38,9 @@ export class DragDropSelectComponent extends AbstractInputComponent<string[]> {
|
|||||||
writeValue(newValue: string[]): void {
|
writeValue(newValue: string[]): void {
|
||||||
super.writeValue(newValue)
|
super.writeValue(newValue)
|
||||||
this.selectedItems =
|
this.selectedItems =
|
||||||
newValue?.map((id) => this.items.find((i) => i.id === id)) ?? []
|
newValue
|
||||||
|
?.map((id) => this.items.find((i) => i.id === id))
|
||||||
|
.filter((item) => item) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
public drop(event: CdkDragDrop<string[]>) {
|
public drop(event: CdkDragDrop<string[]>) {
|
||||||
|
@@ -52,6 +52,11 @@ describe('MonetaryComponent', () => {
|
|||||||
expect(component.defaultCurrencyCode).toEqual('BRL')
|
expect(component.defaultCurrencyCode).toEqual('BRL')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should support setting a default currency code', () => {
|
||||||
|
component.defaultCurrency = 'EUR'
|
||||||
|
expect(component.defaultCurrencyCode).toEqual('EUR')
|
||||||
|
})
|
||||||
|
|
||||||
it('should parse monetary value correctly', () => {
|
it('should parse monetary value correctly', () => {
|
||||||
expect(component['parseMonetaryValue']('123.4')).toEqual('123.4')
|
expect(component['parseMonetaryValue']('123.4')).toEqual('123.4')
|
||||||
expect(component['parseMonetaryValue']('123.4', true)).toEqual('123.40')
|
expect(component['parseMonetaryValue']('123.4', true)).toEqual('123.40')
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { Component, forwardRef, Inject, LOCALE_ID } from '@angular/core'
|
import { Component, forwardRef, Inject, Input, LOCALE_ID } from '@angular/core'
|
||||||
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||||
import { AbstractInputComponent } from '../abstract-input'
|
import { AbstractInputComponent } from '../abstract-input'
|
||||||
import { getLocaleCurrencyCode } from '@angular/common'
|
import { getLocaleCurrencyCode } from '@angular/common'
|
||||||
@@ -29,11 +29,16 @@ export class MonetaryComponent extends AbstractInputComponent<string> {
|
|||||||
|
|
||||||
defaultCurrencyCode: string
|
defaultCurrencyCode: string
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
set defaultCurrency(currency: string) {
|
||||||
|
if (currency) this.defaultCurrencyCode = currency
|
||||||
|
}
|
||||||
|
|
||||||
constructor(@Inject(LOCALE_ID) currentLocale: string) {
|
constructor(@Inject(LOCALE_ID) currentLocale: string) {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
this.currency = this.defaultCurrencyCode =
|
this.currency = this.defaultCurrencyCode =
|
||||||
getLocaleCurrencyCode(currentLocale)
|
this.defaultCurrency ?? getLocaleCurrencyCode(currentLocale)
|
||||||
}
|
}
|
||||||
|
|
||||||
writeValue(newValue: any): void {
|
writeValue(newValue: any): void {
|
||||||
|
@@ -27,3 +27,11 @@
|
|||||||
background-position: right calc(0.375em + 0.1875rem) center !important;
|
background-position: right calc(0.375em + 0.1875rem) center !important;
|
||||||
background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem) !important;
|
background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-group .ng-select-taggable:first-child:nth-last-child(2) {
|
||||||
|
max-width: calc(100% - 45px); // fudge factor for (1x) ng-select button width
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group .ng-select-taggable:first-child:nth-last-child(3) {
|
||||||
|
max-width: calc(100% - 90px); // fudge factor for (2x) ng-select button width
|
||||||
|
}
|
||||||
|
@@ -32,7 +32,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-select>
|
</ng-select>
|
||||||
@if (allowCreate) {
|
@if (allowCreate && !hideAddButton) {
|
||||||
<button class="btn btn-outline-secondary" type="button" (click)="createTag()" [disabled]="disabled">
|
<button class="btn btn-outline-secondary" type="button" (click)="createTag()" [disabled]="disabled">
|
||||||
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
|
@@ -74,6 +74,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
@Input()
|
@Input()
|
||||||
allowCreate: boolean = true
|
allowCreate: boolean = true
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
hideAddButton: boolean = false
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
showFilter: boolean = false
|
showFilter: boolean = false
|
||||||
|
|
||||||
|
@@ -11,7 +11,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="position-relative" [class.col-md-9]="horizontal">
|
<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" [autocomplete]="autocomplete">
|
<input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete" [placeholder]="placeholder">
|
||||||
@if (hint) {
|
@if (hint) {
|
||||||
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
|
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
|
||||||
}
|
}
|
||||||
|
@@ -18,6 +18,9 @@ export class TextComponent extends AbstractInputComponent<string> {
|
|||||||
@Input()
|
@Input()
|
||||||
autocomplete: string
|
autocomplete: string
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
placeholder: string = ''
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,33 @@
|
|||||||
|
<div class="mb-3" [class.pb-3]="error">
|
||||||
|
<div class="row">
|
||||||
|
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||||
|
@if (title) {
|
||||||
|
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
||||||
|
}
|
||||||
|
@if (removable) {
|
||||||
|
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||||
|
<i-bs name="x"></i-bs> <ng-container i18n>Remove</ng-container>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="position-relative" [class.col-md-9]="horizontal">
|
||||||
|
<textarea #inputField
|
||||||
|
[id]="inputId"
|
||||||
|
class="form-control"
|
||||||
|
[class.is-invalid]="error"
|
||||||
|
[class.font-monospace]="monospace"
|
||||||
|
[(ngModel)]="value"
|
||||||
|
(change)="onChange(value)"
|
||||||
|
[disabled]="disabled"
|
||||||
|
[placeholder]="placeholder"
|
||||||
|
rows="4">
|
||||||
|
</textarea>
|
||||||
|
@if (hint) {
|
||||||
|
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
|
||||||
|
}
|
||||||
|
<div class="invalid-feedback position-absolute top-100">
|
||||||
|
{{error}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,31 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import {
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
NG_VALUE_ACCESSOR,
|
||||||
|
} from '@angular/forms'
|
||||||
|
import { TextAreaComponent } from './textarea.component'
|
||||||
|
|
||||||
|
describe('TextComponent', () => {
|
||||||
|
let component: TextAreaComponent
|
||||||
|
let fixture: ComponentFixture<TextAreaComponent>
|
||||||
|
let input: HTMLTextAreaElement
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [TextAreaComponent],
|
||||||
|
providers: [],
|
||||||
|
imports: [FormsModule, ReactiveFormsModule],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(TextAreaComponent)
|
||||||
|
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
input = component.inputField.nativeElement
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support use of input field', () => {
|
||||||
|
expect(component.value).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
@@ -0,0 +1,27 @@
|
|||||||
|
import { Component, Input, forwardRef } from '@angular/core'
|
||||||
|
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||||
|
import { AbstractInputComponent } from '../abstract-input'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
useExisting: forwardRef(() => TextAreaComponent),
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selector: 'pngx-input-textarea',
|
||||||
|
templateUrl: './textarea.component.html',
|
||||||
|
styleUrls: ['./textarea.component.scss'],
|
||||||
|
})
|
||||||
|
export class TextAreaComponent extends AbstractInputComponent<string> {
|
||||||
|
@Input()
|
||||||
|
placeholder: string = ''
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
monospace: boolean = false
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
}
|
@@ -56,30 +56,28 @@
|
|||||||
<small i18n>Unowned</small>
|
<small i18n>Unowned</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
|
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.OTHERS) {
|
@if (selectionModel.ownerFilter === OwnerFilterType.OTHERS) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.User)) {
|
<div class="me-1 w-100">
|
||||||
<div class="me-1 w-100">
|
<ng-select
|
||||||
<ng-select
|
name="user"
|
||||||
name="user"
|
class="user-select small"
|
||||||
class="user-select small"
|
[(ngModel)]="selectionModel.includeUsers"
|
||||||
[(ngModel)]="selectionModel.includeUsers"
|
[disabled]="disabled"
|
||||||
[disabled]="disabled"
|
[clearable]="false"
|
||||||
[clearable]="false"
|
[items]="users"
|
||||||
[items]="users"
|
bindLabel="username"
|
||||||
bindLabel="username"
|
multiple="true"
|
||||||
multiple="true"
|
bindValue="id"
|
||||||
bindValue="id"
|
placeholder="Users"
|
||||||
placeholder="Users"
|
i18n-placeholder
|
||||||
i18n-placeholder
|
(change)="onUserSelect()">
|
||||||
(change)="onUserSelect()">
|
</ng-select>
|
||||||
</ng-select>
|
</div>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</button>
|
</button>
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
||||||
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
||||||
|
@@ -7,3 +7,32 @@
|
|||||||
::ng-deep .popover.popover-preview {
|
::ng-deep .popover.popover-preview {
|
||||||
max-width: 32rem;
|
max-width: 32rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://github.com/paperless-ngx/paperless-ngx/issues/7920
|
||||||
|
// TODO: remove me
|
||||||
|
@mixin ff_txt {
|
||||||
|
.preview-popup-container {
|
||||||
|
width: 30rem !important;
|
||||||
|
height: 22rem !important;
|
||||||
|
background-color: #e7e7e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
object {
|
||||||
|
mix-blend-mode: difference;
|
||||||
|
&.p-2 {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@-moz-document url-prefix() {
|
||||||
|
html[data-bs-theme='dark'] {
|
||||||
|
@include ff_txt;
|
||||||
|
}
|
||||||
|
html[data-bs-theme='auto'] {
|
||||||
|
@media screen and (prefers-color-scheme: dark) {
|
||||||
|
@include ff_txt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
@@ -65,6 +65,7 @@ const savedView: SavedView = {
|
|||||||
DisplayField.CORRESPONDENT,
|
DisplayField.CORRESPONDENT,
|
||||||
DisplayField.DOCUMENT_TYPE,
|
DisplayField.DOCUMENT_TYPE,
|
||||||
DisplayField.STORAGE_PATH,
|
DisplayField.STORAGE_PATH,
|
||||||
|
DisplayField.PAGE_COUNT,
|
||||||
`${DisplayField.CUSTOM_FIELD}11` as any,
|
`${DisplayField.CUSTOM_FIELD}11` as any,
|
||||||
`${DisplayField.CUSTOM_FIELD}15` as any,
|
`${DisplayField.CUSTOM_FIELD}15` as any,
|
||||||
],
|
],
|
||||||
@@ -344,6 +345,7 @@ describe('SavedViewWidgetComponent', () => {
|
|||||||
expect(component.getColumnTitle(DisplayField.STORAGE_PATH)).toEqual(
|
expect(component.getColumnTitle(DisplayField.STORAGE_PATH)).toEqual(
|
||||||
'Storage path'
|
'Storage path'
|
||||||
)
|
)
|
||||||
|
expect(component.getColumnTitle(DisplayField.PAGE_COUNT)).toEqual('Pages')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should get correct column title for custom field', () => {
|
it('should get correct column title for custom field', () => {
|
||||||
|
@@ -71,6 +71,13 @@ describe('StatisticsWidgetComponent', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not call statistics endpoint on reload if already loading', () => {
|
||||||
|
httpTestingController.expectOne(`${environment.apiBaseUrl}statistics/`)
|
||||||
|
component.loading = true
|
||||||
|
component.reload()
|
||||||
|
httpTestingController.expectNone(`${environment.apiBaseUrl}statistics/`)
|
||||||
|
})
|
||||||
|
|
||||||
it('should display inbox link with count', () => {
|
it('should display inbox link with count', () => {
|
||||||
const mockStats = {
|
const mockStats = {
|
||||||
documents_total: 200,
|
documents_total: 200,
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { HttpClient } from '@angular/common/http'
|
import { HttpClient } from '@angular/common/http'
|
||||||
import { Component, OnDestroy, OnInit } from '@angular/core'
|
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||||
import { Observable, Subscription } from 'rxjs'
|
import { first, Observable, Subject, Subscription, takeUntil } from 'rxjs'
|
||||||
import { FILTER_HAS_TAGS_ANY } from 'src/app/data/filter-rule-type'
|
import { FILTER_HAS_TAGS_ANY } from 'src/app/data/filter-rule-type'
|
||||||
import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
|
import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
@@ -35,7 +35,7 @@ export class StatisticsWidgetComponent
|
|||||||
extends ComponentWithPermissions
|
extends ComponentWithPermissions
|
||||||
implements OnInit, OnDestroy
|
implements OnInit, OnDestroy
|
||||||
{
|
{
|
||||||
loading: boolean = true
|
loading: boolean = false
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private http: HttpClient,
|
private http: HttpClient,
|
||||||
@@ -48,31 +48,32 @@ export class StatisticsWidgetComponent
|
|||||||
statistics: Statistics = {}
|
statistics: Statistics = {}
|
||||||
|
|
||||||
subscription: Subscription
|
subscription: Subscription
|
||||||
|
private unsubscribeNotifer: Subject<any> = new Subject()
|
||||||
private getStatistics(): Observable<Statistics> {
|
|
||||||
return this.http.get(`${environment.apiBaseUrl}statistics/`)
|
|
||||||
}
|
|
||||||
|
|
||||||
reload() {
|
reload() {
|
||||||
|
if (this.loading) return
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.getStatistics().subscribe((statistics) => {
|
this.http
|
||||||
this.loading = false
|
.get<Statistics>(`${environment.apiBaseUrl}statistics/`)
|
||||||
const fileTypeMax = 5
|
.pipe(takeUntil(this.unsubscribeNotifer), first())
|
||||||
if (statistics.document_file_type_counts?.length > fileTypeMax) {
|
.subscribe((statistics) => {
|
||||||
const others = statistics.document_file_type_counts.slice(fileTypeMax)
|
this.loading = false
|
||||||
statistics.document_file_type_counts =
|
const fileTypeMax = 5
|
||||||
statistics.document_file_type_counts.slice(0, fileTypeMax)
|
if (statistics.document_file_type_counts?.length > fileTypeMax) {
|
||||||
statistics.document_file_type_counts.push({
|
const others = statistics.document_file_type_counts.slice(fileTypeMax)
|
||||||
mime_type: $localize`Other`,
|
statistics.document_file_type_counts =
|
||||||
mime_type_count: others.reduce(
|
statistics.document_file_type_counts.slice(0, fileTypeMax)
|
||||||
(currentValue, documentFileType) =>
|
statistics.document_file_type_counts.push({
|
||||||
documentFileType.mime_type_count + currentValue,
|
mime_type: $localize`Other`,
|
||||||
0
|
mime_type_count: others.reduce(
|
||||||
),
|
(currentValue, documentFileType) =>
|
||||||
})
|
documentFileType.mime_type_count + currentValue,
|
||||||
}
|
0
|
||||||
this.statistics = statistics
|
),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
this.statistics = statistics
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getFileTypeExtension(filetype: DocumentFileType): string {
|
getFileTypeExtension(filetype: DocumentFileType): string {
|
||||||
@@ -105,6 +106,8 @@ export class StatisticsWidgetComponent
|
|||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.subscription.unsubscribe()
|
this.subscription.unsubscribe()
|
||||||
|
this.unsubscribeNotifer.next(true)
|
||||||
|
this.unsubscribeNotifer.complete()
|
||||||
}
|
}
|
||||||
|
|
||||||
goToInbox() {
|
goToInbox() {
|
||||||
|
@@ -6,8 +6,8 @@
|
|||||||
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
||||||
</form>
|
</form>
|
||||||
@if (getStatus().length > 0) {
|
@if (getStatus().length > 0) {
|
||||||
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none max-vh100-40 overflow-y-scroll" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
|
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none max-vh100-40" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
|
||||||
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto">
|
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll">
|
||||||
<div class="card shadow-sm consumer-status-card">
|
<div class="card shadow-sm consumer-status-card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
@@ -12,7 +12,7 @@ import { DragDropModule } from '@angular/cdk/drag-drop'
|
|||||||
<button
|
<button
|
||||||
*pngxIfObjectPermissions="{
|
*pngxIfObjectPermissions="{
|
||||||
object: { id: 2, owner: user1 },
|
object: { id: 2, owner: user1 },
|
||||||
action: 'view'
|
action: 'view',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
Some Text
|
Some Text
|
||||||
|
@@ -110,12 +110,12 @@
|
|||||||
<pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
|
<pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
|
||||||
[error]="error?.created_date"></pngx-input-date>
|
[error]="error?.created_date"></pngx-input-date>
|
||||||
<pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Correspondent)"
|
<pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Correspondent)"
|
||||||
(createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
|
(createNew)="createCorrespondent($event)" [hideAddButton]="createDisabled(DataType.Correspondent)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
|
||||||
<pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.DocumentType)"
|
<pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.DocumentType)"
|
||||||
(createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
|
(createNew)="createDocumentType($event)" [hideAddButton]="createDisabled(DataType.DocumentType)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
|
||||||
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.StoragePath)"
|
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.StoragePath)"
|
||||||
(createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
|
(createNew)="createStoragePath($event)" [hideAddButton]="createDisabled(DataType.StoragePath)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
|
||||||
<pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Tag)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
|
<pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Tag)" [hideAddButton]="createDisabled(DataType.Tag)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
|
||||||
@for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) {
|
@for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) {
|
||||||
<div [formGroup]="customFieldFormFields.controls[i]">
|
<div [formGroup]="customFieldFormFields.controls[i]">
|
||||||
@switch (getCustomFieldFromInstance(fieldInstance)?.data_type) {
|
@switch (getCustomFieldFromInstance(fieldInstance)?.data_type) {
|
||||||
@@ -157,6 +157,7 @@
|
|||||||
@case (CustomFieldDataType.Monetary) {
|
@case (CustomFieldDataType.Monetary) {
|
||||||
<pngx-input-monetary formControlName="value"
|
<pngx-input-monetary formControlName="value"
|
||||||
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
|
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
|
||||||
|
[defaultCurrency]="getCustomFieldFromInstance(fieldInstance)?.extra_data?.default_currency"
|
||||||
[removable]="userIsOwner"
|
[removable]="userIsOwner"
|
||||||
(removed)="removeField(fieldInstance)"
|
(removed)="removeField(fieldInstance)"
|
||||||
[horizontal]="true"
|
[horizontal]="true"
|
||||||
@@ -343,8 +344,8 @@
|
|||||||
@if (!hasNext()) {
|
@if (!hasNext()) {
|
||||||
<button type="button" class="order-2 btn btn-sm btn-outline-primary" (click)="save(true)" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save & close</button>
|
<button type="button" class="order-2 btn btn-sm btn-outline-primary" (click)="save(true)" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save & close</button>
|
||||||
}
|
}
|
||||||
|
<button type="button" class="order-0 btn btn-sm btn-outline-secondary" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<button type="button" class="order-0 btn btn-sm btn-outline-secondary" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>
|
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
@@ -378,7 +379,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@case (ContentRenderType.Text) {
|
@case (ContentRenderType.Text) {
|
||||||
<div class="preview-sticky bg-light p-3 overflow-auto" width="100%">{{previewText}}</div>
|
<div class="preview-sticky bg-light p-3 overflow-auto whitespace-preserve" width="100%">{{previewText}}</div>
|
||||||
}
|
}
|
||||||
@case (ContentRenderType.Image) {
|
@case (ContentRenderType.Image) {
|
||||||
<div class="preview-sticky">
|
<div class="preview-sticky">
|
||||||
|
@@ -19,10 +19,6 @@
|
|||||||
--page-border: 0;
|
--page-border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep form .ng-select-taggable {
|
|
||||||
max-width: calc(100% - 90px); // fudge factor for (2x) ng-select button width
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-group .dropdown-toggle-split {
|
.btn-group .dropdown-toggle-split {
|
||||||
border-top-right-radius: inherit;
|
border-top-right-radius: inherit;
|
||||||
border-bottom-right-radius: inherit;
|
border-bottom-right-radius: inherit;
|
||||||
@@ -66,3 +62,7 @@ textarea.rtl {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.whitespace-preserve {
|
||||||
|
white-space: preserve;
|
||||||
|
}
|
||||||
|
@@ -85,6 +85,7 @@ import { PdfViewerModule } from 'ng2-pdf-viewer'
|
|||||||
import { DataType } from 'src/app/data/datatype'
|
import { DataType } from 'src/app/data/datatype'
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
|
import { TextAreaComponent } from '../common/input/textarea/textarea.component'
|
||||||
|
|
||||||
const doc: Document = {
|
const doc: Document = {
|
||||||
id: 3,
|
id: 3,
|
||||||
@@ -183,6 +184,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
SplitConfirmDialogComponent,
|
SplitConfirmDialogComponent,
|
||||||
RotateConfirmDialogComponent,
|
RotateConfirmDialogComponent,
|
||||||
DeletePagesConfirmDialogComponent,
|
DeletePagesConfirmDialogComponent,
|
||||||
|
TextAreaComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
RouterModule.forRoot(routes),
|
RouterModule.forRoot(routes),
|
||||||
@@ -1244,4 +1246,20 @@ describe('DocumentDetailComponent', () => {
|
|||||||
)
|
)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('createDisabled should return true if the user does not have permission to add the specified data type', () => {
|
||||||
|
currentUserCan = false
|
||||||
|
expect(component.createDisabled(DataType.Correspondent)).toBeTruthy()
|
||||||
|
expect(component.createDisabled(DataType.DocumentType)).toBeTruthy()
|
||||||
|
expect(component.createDisabled(DataType.StoragePath)).toBeTruthy()
|
||||||
|
expect(component.createDisabled(DataType.Tag)).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createDisabled should return false if the user has permission to add the specified data type', () => {
|
||||||
|
currentUserCan = true
|
||||||
|
expect(component.createDisabled(DataType.Correspondent)).toBeFalsy()
|
||||||
|
expect(component.createDisabled(DataType.DocumentType)).toBeFalsy()
|
||||||
|
expect(component.createDisabled(DataType.StoragePath)).toBeFalsy()
|
||||||
|
expect(component.createDisabled(DataType.Tag)).toBeFalsy()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user