mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-08-22 01:01:04 +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
|
||||
comment:
|
||||
require_changes: true
|
||||
# https://docs.codecov.com/docs/javascript-bundle-analysis
|
||||
require_bundle_changes: true
|
||||
bundle_change_threshold: "50Kb"
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
@@ -22,7 +25,12 @@ coverage:
|
||||
threshold: 1%
|
||||
patch:
|
||||
default:
|
||||
# For the changed lines only, target 75% covered, but
|
||||
# allow as low as 50%
|
||||
target: 75%
|
||||
# For the changed lines only, target 100% covered, but
|
||||
# allow as low as 75%
|
||||
target: 100%
|
||||
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:
|
||||
- label: I believe this issue is a bug that affects all users of Paperless-ngx, not something specific to my installation.
|
||||
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.
|
||||
required: true
|
||||
- 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:
|
||||
# This is the version of pipenv all the steps will use
|
||||
# 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
|
||||
DEFAULT_PYTHON_VERSION: "3.10"
|
||||
DEFAULT_PYTHON_VERSION: "3.11"
|
||||
|
||||
jobs:
|
||||
pre-commit:
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
- pre-commit
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.9', '3.10', '3.11']
|
||||
python-version: ['3.10', '3.11', '3.12']
|
||||
fail-fast: false
|
||||
steps:
|
||||
-
|
||||
@@ -260,7 +260,7 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
tests-coverage-upload:
|
||||
name: "Upload Coverage"
|
||||
name: "Upload to Codecov"
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- tests-backend
|
||||
@@ -306,6 +306,30 @@ jobs:
|
||||
# future expansion
|
||||
flags: backend
|
||||
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:
|
||||
name: Build Docker image for ${{ github.ref_name }}
|
||||
@@ -458,12 +482,6 @@ jobs:
|
||||
name: Install Python dependencies
|
||||
run: |
|
||||
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
|
||||
run: |
|
||||
@@ -631,7 +649,9 @@ jobs:
|
||||
git checkout ${{ needs.publish-release.outputs.version }}-changelog
|
||||
echo -e "# Changelog\n\n${{ needs.publish-release.outputs.changelog }}\n" > changelog-new.md
|
||||
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`
|
||||
echo -e "$CURRENT_CHANGELOG" >> changelog-new.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
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.7.0
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.8.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "${{ github.repository_owner }}"
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
-
|
||||
name: Clean untagged images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.7.0
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.8.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "${{ github.repository_owner }}"
|
||||
|
1
.github/workflows/crowdin.yml
vendored
1
.github/workflows/crowdin.yml
vendored
@@ -15,6 +15,7 @@ on:
|
||||
jobs:
|
||||
synchronize-with-crowdin:
|
||||
name: Crowdin Sync
|
||||
if: github.repository_owner == 'paperless-ngx'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
21
.github/workflows/repo-maintenance.yml
vendored
21
.github/workflows/repo-maintenance.yml
vendored
@@ -16,6 +16,7 @@ concurrency:
|
||||
jobs:
|
||||
stale:
|
||||
name: 'Stale'
|
||||
if: github.repository_owner == 'paperless-ngx'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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.
|
||||
lock-threads:
|
||||
name: 'Lock Old Threads'
|
||||
if: github.repository_owner == 'paperless-ngx'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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.
|
||||
close-answered-discussions:
|
||||
name: 'Close Answered Discussions'
|
||||
if: github.repository_owner == 'paperless-ngx'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
@@ -112,6 +115,7 @@ jobs:
|
||||
}
|
||||
close-outdated-discussions:
|
||||
name: 'Close Outdated Discussions'
|
||||
if: github.repository_owner == 'paperless-ngx'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
@@ -203,6 +207,7 @@ jobs:
|
||||
}
|
||||
close-unsupported-feature-requests:
|
||||
name: 'Close Unsupported Feature Requests'
|
||||
if: github.repository_owner == 'paperless-ngx'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
@@ -212,15 +217,20 @@ jobs:
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const CUTOFF_MAX_COUNT = 80;
|
||||
const CUTOFF_1_DAYS = 180;
|
||||
const CUTOFF_1_COUNT = 5;
|
||||
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();
|
||||
cutoff1Date.setDate(cutoff1Date.getDate() - CUTOFF_1_DAYS);
|
||||
const cutoff2Date = new Date();
|
||||
cutoff2Date.setDate(cutoff2Date.getDate() - CUTOFF_2_DAYS);
|
||||
const cutoff3Date = new Date();
|
||||
cutoff3Date.setDate(cutoff3Date.getDate() - CUTOFF_3_DAYS);
|
||||
|
||||
const query = `query(
|
||||
$owner:String!,
|
||||
@@ -250,9 +260,12 @@ jobs:
|
||||
const result = await github.graphql(query, variables);
|
||||
|
||||
for (const discussion of result.repository.discussions.nodes) {
|
||||
const discussionDate = new Date(discussion.updatedAt);
|
||||
if ((discussionDate < cutoff1Date && discussion.upvoteCount < CUTOFF_1_COUNT) ||
|
||||
(discussionDate < cutoff2Date && discussion.upvoteCount < CUTOFF_2_COUNT)) {
|
||||
const discussionUpdatedDate = new Date(discussion.updatedAt);
|
||||
const discussionCreatedDate = new Date(discussion.createdAt);
|
||||
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}`);
|
||||
const addCommentMutation = `mutation($discussion:ID!, $body:String!) {
|
||||
addDiscussionComment(input:{discussionId:$discussion, body:$body}) {
|
||||
|
@@ -36,8 +36,9 @@ repos:
|
||||
exclude_types:
|
||||
- pofile
|
||||
- json
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: 'v3.1.0'
|
||||
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
||||
- repo: https://github.com/rbubley/mirrors-prettier
|
||||
rev: 'v3.3.3'
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or:
|
||||
@@ -47,7 +48,7 @@ repos:
|
||||
exclude: "(^Pipfile\\.lock$)"
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: 'v0.5.4'
|
||||
rev: 'v0.6.8'
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
@@ -61,6 +62,8 @@ repos:
|
||||
rev: v6.2.1
|
||||
hooks:
|
||||
- id: beautysh
|
||||
additional_dependencies:
|
||||
- setuptools
|
||||
args:
|
||||
- "--tab"
|
||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||
|
@@ -7,9 +7,9 @@
|
||||
"trailingComma": "es5",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["index.md", "administration.md"],
|
||||
"files": ["docs/*.md"],
|
||||
"options": {
|
||||
"tabWidth": 4
|
||||
"tabWidth": 4,
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@@ -1 +1 @@
|
||||
3.9.19
|
||||
3.10.15
|
||||
|
@@ -2,7 +2,7 @@ fix = true
|
||||
line-length = 88
|
||||
respect-gitignore = true
|
||||
src = ["src"]
|
||||
target-version = "py39"
|
||||
target-version = "py310"
|
||||
output-format = "grouped"
|
||||
show-fixes = true
|
||||
|
||||
|
@@ -11,7 +11,7 @@ If you want to implement something big:
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
- Discussions with a marked answer will be automatically closed.
|
||||
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
|
||||
- Feature requests that do not meet the following thresholds will be closed: 5 "up-votes" after 180 days of inactivity or 10 "up-votes" after 365 days.
|
||||
- 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.
|
||||
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
|
||||
RUN set -eux && \
|
||||
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 \
|
||||
;; \
|
||||
esac
|
||||
@@ -31,7 +31,7 @@ RUN set -eux \
|
||||
# Comments:
|
||||
# - pipenv dependencies are not left in the final image
|
||||
# - 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
|
||||
|
||||
@@ -39,7 +39,7 @@ COPY Pipfile* ./
|
||||
|
||||
RUN set -eux \
|
||||
&& 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" \
|
||||
&& pipenv requirements > requirements.txt
|
||||
|
||||
@@ -47,7 +47,7 @@ RUN set -eux \
|
||||
# Purpose: The final image
|
||||
# Comments:
|
||||
# - 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.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 \
|
||||
&& echo "Installing Python requirements" \
|
||||
&& curl --fail --silent --show-error --location \
|
||||
--output psycopg_c-3.2.1-cp311-cp311-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 \
|
||||
--output psycopg_c-3.2.2-cp312-cp312-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 \
|
||||
--output psycopg_c-3.2.1-cp311-cp311-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 \
|
||||
--output psycopg_c-3.2.2-cp312-cp312-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 \
|
||||
&& 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" \
|
||||
&& 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" punkt \
|
||||
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" punkt_tab \
|
||||
&& echo "Cleaning up image" \
|
||||
&& apt-get --yes purge ${BUILD_PACKAGES} \
|
||||
&& 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/consume \
|
||||
&& mkdir --parents --verbose /usr/src/paperless/export \
|
||||
&& echo "Creating gnupg directory" \
|
||||
&& mkdir -m700 --verbose /usr/src/paperless/.gnupg \
|
||||
&& echo "Adjusting all permissions" \
|
||||
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
|
||||
&& echo "Collecting static files" \
|
||||
|
13
Pipfile
13
Pipfile
@@ -7,14 +7,14 @@ name = "pypi"
|
||||
dateparser = "~=1.2"
|
||||
# WARNING: django does not use semver.
|
||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||
django = "~=4.2.14"
|
||||
django = "~=5.1.1"
|
||||
django-allauth = {extras = ["socialaccount"], version = "*"}
|
||||
django-auditlog = "*"
|
||||
django-celery-results = "*"
|
||||
django-compression-middleware = "*"
|
||||
django-cors-headers = "*"
|
||||
django-extensions = "*"
|
||||
django-filter = "~=24.2"
|
||||
django-filter = "~=24.3"
|
||||
django-guardian = "*"
|
||||
django-multiselectfield = "*"
|
||||
django-soft-delete = "*"
|
||||
@@ -30,12 +30,14 @@ filelock = "*"
|
||||
flower = "*"
|
||||
gotenberg-client = "*"
|
||||
gunicorn = "*"
|
||||
httpx-oauth = "*"
|
||||
imap-tools = "*"
|
||||
inotifyrecursive = "~=0.3"
|
||||
jinja2 = "~=3.1"
|
||||
langdetect = "*"
|
||||
mysqlclient = "*"
|
||||
nltk = "*"
|
||||
ocrmypdf = "~=15.4"
|
||||
ocrmypdf = "~=16.5"
|
||||
pathvalidate = "*"
|
||||
pdf2image = "*"
|
||||
psycopg = {version = "*", extras = ["c"]}
|
||||
@@ -54,16 +56,17 @@ tqdm = "*"
|
||||
# See https://github.com/paperless-ngx/paperless-ngx/issues/5494
|
||||
uvicorn = {extras = ["standard"], version = "==0.25.0"}
|
||||
watchdog = "~=4.0"
|
||||
whitenoise = "~=6.7"
|
||||
whitenoise = "~=6.8"
|
||||
whoosh = "~=2.7"
|
||||
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
||||
|
||||
|
||||
[dev-packages]
|
||||
# Linting
|
||||
pre-commit = "*"
|
||||
ruff = "*"
|
||||
# Testing
|
||||
factory-boy = "*"
|
||||
# Testing
|
||||
pytest = "*"
|
||||
pytest-cov = "*"
|
||||
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;">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_white.svg" width="140px">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
|
||||
<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">
|
||||
</picture>
|
||||
</a>
|
||||
|
@@ -122,18 +122,28 @@ install_languages() {
|
||||
if [ ${#langs[@]} -eq 0 ]; then
|
||||
return
|
||||
fi
|
||||
apt-get update
|
||||
|
||||
# Build list of packages to install
|
||||
to_install=()
|
||||
for lang in "${langs[@]}"; do
|
||||
pkg="tesseract-ocr-$lang"
|
||||
|
||||
if dpkg --status "$pkg" &>/dev/null; then
|
||||
echo "Package $pkg already installed!"
|
||||
continue
|
||||
else
|
||||
to_install+=("$pkg")
|
||||
fi
|
||||
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 "Package $pkg not found! :("
|
||||
echo "Skipped $pkg: Package not found! :("
|
||||
continue
|
||||
fi
|
||||
|
||||
@@ -143,6 +153,7 @@ install_languages() {
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Paperless-ngx docker container starting..."
|
||||
|
@@ -16,7 +16,7 @@ do
|
||||
# Check if it starts with "PAPERLESS_" and ends in "_FILE"
|
||||
if [[ ${env_name} == PAPERLESS_*_FILE ]]; then
|
||||
# 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
|
||||
fi
|
||||
# Extract the value of the environment
|
||||
|
@@ -14,7 +14,8 @@ for command in decrypt_documents \
|
||||
document_thumbnails \
|
||||
document_sanity_checker \
|
||||
document_fuzzy_match \
|
||||
manage_superuser;
|
||||
manage_superuser \
|
||||
convert_mariadb_uuid;
|
||||
do
|
||||
echo "installing $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
|
||||
possible.
|
||||
|
||||
The exporter does not include API tokens and they will need to be re-generated after importing.
|
||||
|
||||
!!! caution
|
||||
|
||||
You cannot import the export generated with one version of paperless in
|
||||
@@ -248,6 +250,7 @@ optional arguments:
|
||||
-z, --zip
|
||||
-zn, --zip-name
|
||||
--data-only
|
||||
--no-progress-bar
|
||||
--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
|
||||
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
|
||||
must be provided to import. If this value is lost, the export cannot be imported.
|
||||
|
||||
@@ -332,8 +339,9 @@ document_importer source
|
||||
```
|
||||
|
||||
| Option | Required | Default | Description |
|
||||
| -------------- | -------- | ------- | ------------------------------------------------------------------------- |
|
||||
| ------------------- | -------- | ------- | ------------------------------------------------------------------------- |
|
||||
| source | Yes | N/A | The directory containing an export |
|
||||
| `--no-progress-bar` | No | False | If provided, the progress bar will be hidden |
|
||||
| `--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 |
|
||||
|
||||
|
@@ -265,7 +265,7 @@ This variable allows you to configure the filename (folders are allowed)
|
||||
using placeholders. For example, configuring this to
|
||||
|
||||
```bash
|
||||
PAPERLESS_FILENAME_FORMAT={created_year}/{correspondent}/{title}
|
||||
PAPERLESS_FILENAME_FORMAT={{ created_year }}/{{ correspondent }}/{{ title }}
|
||||
```
|
||||
|
||||
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
|
||||
[`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".
|
||||
- `{correspondent}`: The name of the correspondent, or "none".
|
||||
- `{document_type}`: The name of the document type, or "none".
|
||||
- `{tag_list}`: A comma separated list of all tags assigned to the
|
||||
- `{{ asn }}`: The archive serial number of the document, or "none".
|
||||
- `{{ correspondent }}`: The name of the correspondent, or "none".
|
||||
- `{{ document_type }}`: The name of the document type, or "none".
|
||||
- `{{ tag_list }}`: A comma separated list of all tags assigned to the
|
||||
document.
|
||||
- `{title}`: The title of the document.
|
||||
- `{created}`: The full date (ISO format) the document was created.
|
||||
- `{created_year}`: Year created only, formatted as the year with
|
||||
- `{{ title }}`: The title of the document.
|
||||
- `{{ created }}`: The full date (ISO format) the document was created.
|
||||
- `{{ created_year }}`: Year created only, formatted as the year with
|
||||
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.
|
||||
- `{created_month}`: Month created only (number 01-12).
|
||||
- `{created_month_name}`: Month created name, as per locale
|
||||
- `{created_month_name_short}`: Month created abbreviated name, as per
|
||||
- `{{ created_month }}`: Month created only (number 01-12).
|
||||
- `{{ created_month_name }}`: Month created name, as per locale
|
||||
- `{{ created_month_name_short }}`: Month created abbreviated name, as per
|
||||
locale
|
||||
- `{created_day}`: Day created only (number 01-31).
|
||||
- `{added}`: The full date (ISO format) the document was added to
|
||||
- `{{ created_day }}`: Day created only (number 01-31).
|
||||
- `{{ added }}`: The full date (ISO format) the document was added to
|
||||
paperless.
|
||||
- `{added_year}`: Year added only.
|
||||
- `{added_year_short}`: Year added only, formatted as the year without
|
||||
- `{{ added_year }}`: Year added only.
|
||||
- `{{ added_year_short }}`: Year added only, formatted as the year without
|
||||
century, zero padded.
|
||||
- `{added_month}`: Month added only (number 01-12).
|
||||
- `{added_month_name}`: Month added name, as per locale
|
||||
- `{added_month_name_short}`: Month added abbreviated name, as per
|
||||
- `{{ added_month }}`: Month added only (number 01-12).
|
||||
- `{{ added_month_name }}`: Month added name, as per locale
|
||||
- `{{ added_month_name_short }}`: Month added abbreviated name, as per
|
||||
locale
|
||||
- `{added_day}`: Day added only (number 01-31).
|
||||
- `{owner_username}`: Username of document owner, 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.
|
||||
- `{{ added_day }}`: Day added only (number 01-31).
|
||||
- `{{ owner_username }}`: Username of document owner, 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.
|
||||
|
||||
!!! 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.
|
||||
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
|
||||
much as possible. However, some characters that you can use in document
|
||||
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
|
||||
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
|
||||
[`PAPERLESS_FILENAME_FORMAT_REMOVE_NONE`](configuration.md#PAPERLESS_FILENAME_FORMAT_REMOVE_NONE) setting.
|
||||
@@ -390,8 +395,8 @@ For example, you could define the following two storage paths:
|
||||
the correspondence.
|
||||
|
||||
```
|
||||
By Year = {created_year}/{correspondent}/{title}
|
||||
Insurances = Insurances/{correspondent}/{created_year}-{created_month}-{created_day} {title}
|
||||
By Year = {{ created_year }}/{{ correspondent }}/{{ 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
|
||||
@@ -418,6 +423,97 @@ Insurances/ # Insurances
|
||||
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.
|
||||
|
||||
### 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}
|
||||
|
||||
The monitoring tool
|
||||
@@ -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
|
||||
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
|
||||
```
|
||||
|
52
docs/api.md
52
docs/api.md
@@ -54,6 +54,7 @@ fields:
|
||||
- `archived_file_name`: Verbose filename of the archived document.
|
||||
Read-only. Null if no archived document is available.
|
||||
- `notes`: Array of notes associated with the document.
|
||||
- `page_count`: Number of pages.
|
||||
- `set_permissions`: Allows setting document permissions. Optional,
|
||||
write-only. See [below](#permissions).
|
||||
- `custom_fields`: Array of custom fields & values, specified as
|
||||
@@ -235,12 +236,6 @@ results:
|
||||
Pagination works exactly the same as it does for normal requests on this
|
||||
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__`
|
||||
attribute with various information about the search results:
|
||||
|
||||
@@ -280,6 +275,51 @@ attribute with various information about the search results:
|
||||
- `rank` is the index of the search results. The first result will
|
||||
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/`
|
||||
|
||||
Get auto completions for a partial search term.
|
||||
|
@@ -10,7 +10,7 @@
|
||||
--md-hue: 222;
|
||||
}
|
||||
|
||||
@media (min-width: 400px) {
|
||||
@media (min-width: 768px) {
|
||||
.grid-left {
|
||||
width: 33%;
|
||||
float: left;
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@ matcher.
|
||||
`redis://<username>:<password>@<host>:<port>/<DBIndex>`
|
||||
|
||||
[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`.
|
||||
|
||||
@@ -600,7 +600,7 @@ You can optionally also automatically redirect users to the SSO login with [PAPE
|
||||
|
||||
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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
: 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)
|
||||
|
||||
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}
|
||||
|
||||
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
|
||||
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}
|
||||
|
||||
#### [`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.
|
||||
|
||||
## 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}
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_ENABLE_BARCODES=<bool>`](#PAPERLESS_CONSUMER_ENABLE_BARCODES) {#PAPERLESS_CONSUMER_ENABLE_BARCODES}
|
||||
@@ -1230,6 +1287,12 @@ change this.
|
||||
|
||||
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}
|
||||
|
||||
: 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"
|
||||
|
||||
#### [`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}
|
||||
|
||||
: Enables the detection of barcodes in the scanned document and
|
||||
|
@@ -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
|
||||
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
|
||||
|
@@ -132,3 +132,11 @@ Multiple options for ASGI servers exist:
|
||||
- `daphne` as a standalone server, which is the reference
|
||||
implementation for ASGI.
|
||||
- `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.
|
||||
|
@@ -250,7 +250,7 @@ a minimal installation of Debian/Buster, which is the current stable
|
||||
release at the time of writing. Windows is not and will never be
|
||||
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.
|
||||
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.
|
||||
@@ -269,14 +269,13 @@ are released, dependency support is confirmed, etc.
|
||||
- `libpq-dev` for PostgreSQL
|
||||
- `libmagic-dev` for mime type detection
|
||||
- `mariadb-client` for MariaDB compile time
|
||||
- `mime-support` for mime type detection
|
||||
- `libzbar0` for barcode detection
|
||||
- `poppler-utils` for barcode detection
|
||||
|
||||
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
|
||||
@@ -304,6 +303,7 @@ are released, dependency support is confirmed, etc.
|
||||
|
||||
- `libatlas-base-dev`
|
||||
- `libxslt1-dev`
|
||||
- `mime-support`
|
||||
|
||||
You will also need these for installing some of the python dependencies:
|
||||
|
||||
@@ -540,8 +540,7 @@ are released, dependency support is confirmed, etc.
|
||||
15. Optional: If using the NLTK machine learning processing (see
|
||||
[`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) for details),
|
||||
download the NLTK data for the Snowball
|
||||
Stemmer, Stopwords and Punkt tokenizer to your
|
||||
`PAPERLESS_DATA_DIR/nltk`. Refer to the [NLTK
|
||||
Stemmer, Stopwords and Punkt tokenizer to `/usr/share/nltk_data`. Refer to the [NLTK
|
||||
instructions](https://www.nltk.org/data.html) for details on how to
|
||||
download the data.
|
||||
|
||||
|
@@ -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'
|
||||
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
|
||||
|
||||
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).
|
||||
|
@@ -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
|
||||
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
|
||||
accounts. This is a very flexible and powerful feature, if you regularly
|
||||
@@ -136,7 +136,8 @@ These rules perform the following:
|
||||
|
||||
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
|
||||
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
|
||||
different means. These are as follows:
|
||||
@@ -199,6 +200,14 @@ different means. These are as follows:
|
||||
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)
|
||||
|
||||
#### 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
|
||||
|
||||
You can also submit a document using the REST API, see [POSTing documents](api.md#file-uploads)
|
||||
|
@@ -6,6 +6,12 @@ theme:
|
||||
text: Roboto
|
||||
code: Roboto Mono
|
||||
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
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
@@ -18,7 +24,7 @@ theme:
|
||||
scheme: slate
|
||||
toggle:
|
||||
icon: material/brightness-4
|
||||
name: Switch to light mode
|
||||
name: Switch to system preference
|
||||
features:
|
||||
- navigation.tabs
|
||||
- navigation.top
|
||||
|
@@ -18,6 +18,7 @@
|
||||
# Paths and folders
|
||||
|
||||
#PAPERLESS_CONSUMPTION_DIR=../consume
|
||||
#PAPERLESS_CONSUMPTION_FAILED_DIR=../consume/failed
|
||||
#PAPERLESS_DATA_DIR=../data
|
||||
#PAPERLESS_EMPTY_TRASH_DIR=
|
||||
#PAPERLESS_MEDIA_ROOT=../media
|
||||
|
@@ -33,6 +33,7 @@
|
||||
"it-IT": "src/locale/messages.it_IT.xlf",
|
||||
"ja-JP": "src/locale/messages.ja_JP.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",
|
||||
"no-NO": "src/locale/messages.no_NO.xlf",
|
||||
"pl-PL": "src/locale/messages.pl_PL.xlf",
|
||||
@@ -51,8 +52,11 @@
|
||||
},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"builder": "@angular-builders/custom-webpack:browser",
|
||||
"options": {
|
||||
"customWebpackConfig": {
|
||||
"path": "./extra-webpack.config.ts"
|
||||
},
|
||||
"outputPath": "dist/paperless-ui",
|
||||
"outputHashing": "none",
|
||||
"index": "src/index.html",
|
||||
@@ -66,8 +70,8 @@
|
||||
"src/assets",
|
||||
"src/manifest.webmanifest",
|
||||
{
|
||||
"glob": "{pdf.worker.min.js,pdf.min.js}",
|
||||
"input": "node_modules/pdfjs-dist/build/",
|
||||
"glob": "{pdf.worker.min.mjs,pdf.min.mjs}",
|
||||
"input": "node_modules/pdfjs-dist/legacy/build/",
|
||||
"output": "/assets/js/"
|
||||
}
|
||||
],
|
||||
@@ -124,7 +128,7 @@
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"builder": "@angular-builders/custom-webpack:dev-server",
|
||||
"options": {
|
||||
"buildTarget": "paperless-ui:build:en-US"
|
||||
},
|
||||
@@ -135,7 +139,7 @@
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"builder": "@angular-builders/custom-webpack:extract-i18n",
|
||||
"options": {
|
||||
"buildTarget": "paperless-ui:build"
|
||||
}
|
||||
|
@@ -84,13 +84,8 @@ test('date filtering', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Dates' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click()
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
||||
await page.getByRole('button', { name: 'Dates Clear selected' }).click()
|
||||
await page.getByRole('button', { name: 'Dates' }).click()
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'After mm/dd/yyyy' })
|
||||
.getByRole('button')
|
||||
.first()
|
||||
.click()
|
||||
await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click()
|
||||
await page.getByLabel('Datesselected').getByRole('button').first().click()
|
||||
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
|
||||
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
|
||||
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,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^18.0.6",
|
||||
"@angular/common": "~18.0.6",
|
||||
"@angular/compiler": "~18.0.6",
|
||||
"@angular/core": "~18.0.6",
|
||||
"@angular/forms": "~18.0.6",
|
||||
"@angular/localize": "~18.0.6",
|
||||
"@angular/platform-browser": "~18.0.6",
|
||||
"@angular/platform-browser-dynamic": "~18.0.6",
|
||||
"@angular/router": "~18.0.6",
|
||||
"@ng-bootstrap/ng-bootstrap": "^17.0.0",
|
||||
"@ng-select/ng-select": "^13.4.1",
|
||||
"@angular/cdk": "^18.2.6",
|
||||
"@angular/common": "~18.2.6",
|
||||
"@angular/compiler": "~18.2.6",
|
||||
"@angular/core": "~18.2.6",
|
||||
"@angular/forms": "~18.2.6",
|
||||
"@angular/localize": "~18.2.6",
|
||||
"@angular/platform-browser": "~18.2.6",
|
||||
"@angular/platform-browser-dynamic": "~18.2.6",
|
||||
"@angular/router": "~18.2.6",
|
||||
"@ng-bootstrap/ng-bootstrap": "^17.0.1",
|
||||
"@ng-select/ng-select": "^13.9.0",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"mime-names": "^1.0.0",
|
||||
"ng2-pdf-viewer": "^10.2.2",
|
||||
"ng2-pdf-viewer": "^10.3.1",
|
||||
"ngx-bootstrap-icons": "^1.9.3",
|
||||
"ngx-color": "^9.0.0",
|
||||
"ngx-cookie-service": "^18.0.0",
|
||||
"ngx-file-drop": "^16.0.0",
|
||||
"ngx-ui-tour-ng-bootstrap": "^15.0.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"tslib": "^2.6.2",
|
||||
"uuid": "^9.0.1",
|
||||
"zone.js": "^0.14.4"
|
||||
"tslib": "^2.7.0",
|
||||
"uuid": "^10.0.0",
|
||||
"zone.js": "^0.14.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/custom-webpack": "^18.0.0",
|
||||
"@angular-builders/jest": "^18.0.0",
|
||||
"@angular-devkit/build-angular": "^18.0.7",
|
||||
"@angular-devkit/core": "^18.0.7",
|
||||
"@angular-devkit/schematics": "^18.0.7",
|
||||
"@angular-eslint/builder": "18.1.0",
|
||||
"@angular-eslint/eslint-plugin": "18.1.0",
|
||||
"@angular-eslint/eslint-plugin-template": "18.1.0",
|
||||
"@angular-eslint/schematics": "18.1.0",
|
||||
"@angular-eslint/template-parser": "18.1.0",
|
||||
"@angular/cli": "~18.0.7",
|
||||
"@angular/compiler-cli": "~18.0.3",
|
||||
"@playwright/test": "^1.42.1",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.12.2",
|
||||
"@typescript-eslint/eslint-plugin": "^7.4.0",
|
||||
"@typescript-eslint/parser": "^7.4.0",
|
||||
"@typescript-eslint/utils": "^7.13.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.57.0",
|
||||
"@angular-devkit/build-angular": "^18.2.2",
|
||||
"@angular-devkit/core": "^18.2.6",
|
||||
"@angular-devkit/schematics": "^18.2.6",
|
||||
"@angular-eslint/builder": "18.3.1",
|
||||
"@angular-eslint/eslint-plugin": "18.3.1",
|
||||
"@angular-eslint/eslint-plugin-template": "18.3.1",
|
||||
"@angular-eslint/schematics": "18.3.1",
|
||||
"@angular-eslint/template-parser": "18.3.1",
|
||||
"@angular/cli": "~18.2.6",
|
||||
"@angular/compiler-cli": "~18.2.2",
|
||||
"@codecov/webpack-plugin": "^1.2.0",
|
||||
"@playwright/test": "^1.47.2",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^22.7.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.8.0",
|
||||
"@typescript-eslint/parser": "^8.8.0",
|
||||
"@typescript-eslint/utils": "^8.0.0",
|
||||
"eslint": "^9.11.1",
|
||||
"jest": "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",
|
||||
"patch-package": "^8.0.0",
|
||||
"ts-node": "~10.9.1",
|
||||
"typescript": "^5.3.3",
|
||||
"wait-on": "^7.2.0"
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
|
@@ -24,6 +24,7 @@ import localeFr from '@angular/common/locales/fr'
|
||||
import localeHu from '@angular/common/locales/hu'
|
||||
import localeIt from '@angular/common/locales/it'
|
||||
import localeJa from '@angular/common/locales/ja'
|
||||
import localeKo from '@angular/common/locales/ko'
|
||||
import localeLb from '@angular/common/locales/lb'
|
||||
import localeNl from '@angular/common/locales/nl'
|
||||
import localeNo from '@angular/common/locales/no'
|
||||
@@ -55,6 +56,7 @@ registerLocaleData(localeFr)
|
||||
registerLocaleData(localeHu)
|
||||
registerLocaleData(localeIt)
|
||||
registerLocaleData(localeJa)
|
||||
registerLocaleData(localeKo)
|
||||
registerLocaleData(localeLb)
|
||||
registerLocaleData(localeNl)
|
||||
registerLocaleData(localeNo)
|
||||
|
@@ -36,7 +36,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
private hotKeyService: HotKeyService
|
||||
) {
|
||||
let anyWindow = window as any
|
||||
anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js'
|
||||
anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.mjs'
|
||||
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 { NgxFileDropModule } from 'ngx-file-drop'
|
||||
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 { CheckComponent } from './components/common/input/check/check.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 { 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 { 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 { PdfViewerModule } from 'ng2-pdf-viewer'
|
||||
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
|
||||
@@ -141,7 +143,9 @@ import {
|
||||
arrowRightShort,
|
||||
arrowUpRight,
|
||||
asterisk,
|
||||
braces,
|
||||
bodyText,
|
||||
boxArrowInRight,
|
||||
boxArrowUp,
|
||||
boxArrowUpRight,
|
||||
boxes,
|
||||
@@ -172,6 +176,7 @@ import {
|
||||
download,
|
||||
envelope,
|
||||
envelopeAt,
|
||||
envelopeAtFill,
|
||||
exclamationCircleFill,
|
||||
exclamationTriangle,
|
||||
exclamationTriangleFill,
|
||||
@@ -188,6 +193,7 @@ import {
|
||||
folderFill,
|
||||
funnel,
|
||||
gear,
|
||||
google,
|
||||
grid,
|
||||
gripVertical,
|
||||
hash,
|
||||
@@ -198,6 +204,8 @@ import {
|
||||
link,
|
||||
listTask,
|
||||
listUl,
|
||||
microsoft,
|
||||
nodePlus,
|
||||
pencil,
|
||||
people,
|
||||
peopleFill,
|
||||
@@ -227,6 +235,7 @@ import {
|
||||
uiRadios,
|
||||
upcScan,
|
||||
x,
|
||||
xCircle,
|
||||
xLg,
|
||||
} from 'ngx-bootstrap-icons'
|
||||
|
||||
@@ -242,7 +251,9 @@ const icons = {
|
||||
arrowRightShort,
|
||||
arrowUpRight,
|
||||
asterisk,
|
||||
braces,
|
||||
bodyText,
|
||||
boxArrowInRight,
|
||||
boxArrowUp,
|
||||
boxArrowUpRight,
|
||||
boxes,
|
||||
@@ -273,6 +284,7 @@ const icons = {
|
||||
download,
|
||||
envelope,
|
||||
envelopeAt,
|
||||
envelopeAtFill,
|
||||
exclamationCircleFill,
|
||||
exclamationTriangle,
|
||||
exclamationTriangleFill,
|
||||
@@ -289,6 +301,7 @@ const icons = {
|
||||
folderFill,
|
||||
funnel,
|
||||
gear,
|
||||
google,
|
||||
grid,
|
||||
gripVertical,
|
||||
hash,
|
||||
@@ -299,6 +312,8 @@ const icons = {
|
||||
link,
|
||||
listTask,
|
||||
listUl,
|
||||
microsoft,
|
||||
nodePlus,
|
||||
pencil,
|
||||
people,
|
||||
peopleFill,
|
||||
@@ -328,6 +343,7 @@ const icons = {
|
||||
uiRadios,
|
||||
upcScan,
|
||||
x,
|
||||
xCircle,
|
||||
xLg,
|
||||
}
|
||||
|
||||
@@ -347,6 +363,7 @@ import localeFr from '@angular/common/locales/fr'
|
||||
import localeHu from '@angular/common/locales/hu'
|
||||
import localeIt from '@angular/common/locales/it'
|
||||
import localeJa from '@angular/common/locales/ja'
|
||||
import localeKo from '@angular/common/locales/ko'
|
||||
import localeLb from '@angular/common/locales/lb'
|
||||
import localeNl from '@angular/common/locales/nl'
|
||||
import localeNo from '@angular/common/locales/no'
|
||||
@@ -378,6 +395,7 @@ registerLocaleData(localeFr)
|
||||
registerLocaleData(localeHu)
|
||||
registerLocaleData(localeIt)
|
||||
registerLocaleData(localeJa)
|
||||
registerLocaleData(localeKo)
|
||||
registerLocaleData(localeLb)
|
||||
registerLocaleData(localeNl)
|
||||
registerLocaleData(localeNo)
|
||||
@@ -431,6 +449,7 @@ function initializeApp(settings: SettingsService) {
|
||||
DocumentCardSmallComponent,
|
||||
BulkEditorComponent,
|
||||
TextComponent,
|
||||
TextAreaComponent,
|
||||
SelectComponent,
|
||||
CheckComponent,
|
||||
UrlComponent,
|
||||
@@ -483,6 +502,7 @@ function initializeApp(settings: SettingsService) {
|
||||
CustomFieldsComponent,
|
||||
CustomFieldEditDialogComponent,
|
||||
CustomFieldsDropdownComponent,
|
||||
CustomFieldsQueryDropdownComponent,
|
||||
ProfileEditDialogComponent,
|
||||
DocumentLinkComponent,
|
||||
PreviewPopupComponent,
|
||||
|
@@ -332,7 +332,7 @@
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="SettingsNavIDs.SavedViews">
|
||||
<li [ngbNavItem]="SettingsNavIDs.SavedViews" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.SavedView }">
|
||||
<a ngbNavLink i18n>Saved views</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
|
@@ -81,6 +81,9 @@
|
||||
</td>
|
||||
<td scope="row">
|
||||
<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 }">
|
||||
<i-bs name="check"></i-bs> <ng-container i18n>Dismiss</ng-container>
|
||||
</button>
|
||||
@@ -153,3 +156,25 @@
|
||||
</li>
|
||||
</ul>
|
||||
<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;
|
||||
}
|
||||
}
|
||||
|
||||
.retry-dropdown {
|
||||
width: 300px;
|
||||
}
|
||||
|
@@ -31,6 +31,9 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
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[] = [
|
||||
{
|
||||
@@ -115,6 +118,7 @@ describe('TasksComponent', () => {
|
||||
let modalService: NgbModal
|
||||
let router: Router
|
||||
let httpTestingController: HttpTestingController
|
||||
let toastService: ToastService
|
||||
let reloadSpy
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -125,6 +129,7 @@ describe('TasksComponent', () => {
|
||||
IfPermissionsDirective,
|
||||
CustomDatePipe,
|
||||
ConfirmDialogComponent,
|
||||
CheckComponent,
|
||||
],
|
||||
imports: [
|
||||
NgbModule,
|
||||
@@ -152,6 +157,7 @@ describe('TasksComponent', () => {
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
router = TestBed.inject(Router)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
fixture = TestBed.createComponent(TasksComponent)
|
||||
component = fixture.componentInstance
|
||||
jest.useFakeTimers()
|
||||
@@ -173,8 +179,10 @@ describe('TasksComponent', () => {
|
||||
`Failed${currentTasksLength}`
|
||||
)
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.css('table input[type="checkbox"]'))
|
||||
).toHaveLength(currentTasksLength + 1)
|
||||
fixture.debugElement.queryAll(
|
||||
By.css('table td > .form-check input[type="checkbox"]')
|
||||
)
|
||||
).toHaveLength(currentTasksLength)
|
||||
|
||||
currentTasksLength = tasks.filter(
|
||||
(t) => t.status === PaperlessTaskStatus.Complete
|
||||
@@ -289,4 +297,20 @@ describe('TasksComponent', () => {
|
||||
jest.advanceTimersByTime(6000)
|
||||
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 { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
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 { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-tasks',
|
||||
@@ -16,6 +17,7 @@ export class TasksComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
public PaperlessTaskStatus = PaperlessTaskStatus
|
||||
public activeTab: string
|
||||
public selectedTasks: Set<number> = new Set()
|
||||
public togggleAll: boolean = false
|
||||
@@ -26,6 +28,8 @@ export class TasksComponent
|
||||
|
||||
public autoRefreshInterval: any
|
||||
|
||||
public retryClean: boolean = false
|
||||
|
||||
get dismissButtonText(): string {
|
||||
return this.selectedTasks.size > 0
|
||||
? $localize`Dismiss selected`
|
||||
@@ -35,6 +39,7 @@ export class TasksComponent
|
||||
constructor(
|
||||
public tasksService: TasksService,
|
||||
private modalService: NgbModal,
|
||||
private toastService: ToastService,
|
||||
private readonly router: Router
|
||||
) {
|
||||
super()
|
||||
@@ -70,11 +75,11 @@ export class TasksComponent
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.close()
|
||||
this.tasksService.dismissTasks(tasks)
|
||||
this.selectedTasks.clear()
|
||||
this.clearSelection()
|
||||
})
|
||||
} else {
|
||||
this.tasksService.dismissTasks(tasks)
|
||||
this.selectedTasks.clear()
|
||||
this.clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +88,17 @@ export class TasksComponent
|
||||
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) {
|
||||
this.expandedTask = this.expandedTask == task.id ? undefined : task.id
|
||||
}
|
||||
|
@@ -43,7 +43,7 @@
|
||||
<div class="dropdown-divider"></div>
|
||||
</div>
|
||||
<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>
|
||||
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }">
|
||||
@@ -93,14 +93,15 @@
|
||||
</ul>
|
||||
|
||||
<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">
|
||||
<span i18n>Saved views</span>
|
||||
@if (savedViewService.loading) {
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
||||
}
|
||||
</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"
|
||||
@@ -120,6 +121,7 @@
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
|
||||
<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 */
|
||||
padding: 50px 0 0; /* Height of navbar */
|
||||
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 {
|
||||
width: 0.8em;
|
||||
@@ -24,15 +27,15 @@
|
||||
|
||||
// These come from the col-* classes for non-slim sidebar, needed for animation
|
||||
@media (min-width: 768px) {
|
||||
max-width: 25%;
|
||||
--pngx-sidebar-width: 25%;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
max-width: 16.66666667%;
|
||||
--pngx-sidebar-width: 16.66666667%;
|
||||
}
|
||||
|
||||
@media (min-width: 2400px) {
|
||||
max-width: 8.33333333%;
|
||||
--pngx-sidebar-width: 8.33333333%;
|
||||
}
|
||||
|
||||
transition: all .2s ease;
|
||||
@@ -109,12 +112,17 @@ main {
|
||||
|
||||
.sidebar-slim-toggler {
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: -12px;
|
||||
position: fixed;
|
||||
left: calc(var(--pngx-sidebar-width) - 12px);
|
||||
top: 60px;
|
||||
z-index: 996;
|
||||
--bs-btn-padding-x: 0.35rem;
|
||||
--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,
|
||||
useValue: {
|
||||
initialize: () => {},
|
||||
reload: () => {},
|
||||
listAll: () =>
|
||||
of({
|
||||
all: [saved_views.map((v) => v.id)],
|
||||
@@ -170,7 +170,7 @@ describe('AppFrameComponent', () => {
|
||||
.mockReturnValue('Hello World')
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
|
||||
savedViewSpy = jest.spyOn(savedViewService, 'initialize')
|
||||
savedViewSpy = jest.spyOn(savedViewService, 'reload')
|
||||
|
||||
fixture = TestBed.createComponent(AppFrameComponent)
|
||||
component = fixture.componentInstance
|
||||
|
@@ -35,7 +35,6 @@ import {
|
||||
} from '@angular/cdk/drag-drop'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-app-frame',
|
||||
@@ -74,7 +73,7 @@ export class AppFrameComponent
|
||||
PermissionType.SavedView
|
||||
)
|
||||
) {
|
||||
this.savedViewService.initialize()
|
||||
this.savedViewService.reload()
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -49,14 +49,14 @@
|
||||
[disabled]="disablePrimaryButton(type, item)"
|
||||
(mouseenter)="onButtonHover($event)">
|
||||
@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>
|
||||
} @else if (type === DataType.SavedView) {
|
||||
<i-bs width="1em" height="1em" name="eye"></i-bs>
|
||||
<span> <ng-container i18n>Open</ng-container></span>
|
||||
} @else if (type === DataType.Workflow || type === DataType.CustomField || type === DataType.Group || type === DataType.User || type === DataType.MailAccount || type === DataType.MailRule) {
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
||||
<span> <ng-container i18n>Edit</ng-container></span>
|
||||
<span> <ng-container i18n>Open</ng-container></span>
|
||||
} @else {
|
||||
<i-bs width="1em" height="1em" name="filter"></i-bs>
|
||||
<span> <ng-container i18n>Filter documents</ng-container></span>
|
||||
@@ -72,8 +72,8 @@
|
||||
<i-bs width="1em" height="1em" name="download"></i-bs>
|
||||
<span> <ng-container i18n>Download</ng-container></span>
|
||||
} @else {
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
||||
<span> <ng-container i18n>Edit</ng-container></span>
|
||||
<i-bs width="1em" height="1em" name="box-arrow-in-right"></i-bs>
|
||||
<span> <ng-container i18n>Open</ng-container></span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
@@ -65,10 +65,6 @@ form {
|
||||
--pngx-focus-alpha: 0;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mh-75 {
|
||||
max-height: 75vh;
|
||||
}
|
||||
|
@@ -18,10 +18,12 @@
|
||||
@case (CustomFieldDataType.DocumentLink) {
|
||||
<div [ngbTooltip]="nameTooltip" class="d-flex gap-1 flex-wrap">
|
||||
@for (docId of value; track docId) {
|
||||
@if (getDocumentTitle(docId)) {
|
||||
<a routerLink="/documents/{{docId}}" class="badge bg-body text-primary" title="View" i18n-title>
|
||||
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs> <span>{{ getDocumentTitle(docId) }}</span>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case (CustomFieldDataType.Boolean) {
|
||||
|
@@ -20,6 +20,12 @@ const customFields: CustomField[] = [
|
||||
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 = {
|
||||
id: 1,
|
||||
@@ -112,6 +118,18 @@ describe('CustomFieldDisplayComponent', () => {
|
||||
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', () => {
|
||||
expect(component.getSelectValue(customFields[3], 2)).toEqual('Option 3')
|
||||
})
|
||||
|
@@ -90,7 +90,9 @@ export class CustomFieldDisplayComponent implements OnInit, OnDestroy {
|
||||
)?.value
|
||||
if (this.value && this.field.data_type === CustomFieldDataType.Monetary) {
|
||||
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, ''))
|
||||
} else if (
|
||||
this.value?.length &&
|
||||
@@ -105,9 +107,9 @@ export class CustomFieldDisplayComponent implements OnInit, OnDestroy {
|
||||
.getFew(this.value, { fields: 'id,title' })
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result: Results<Document>) => {
|
||||
this.docLinkDocuments = this.value.map((id) =>
|
||||
result.results.find((d) => d.id === id)
|
||||
)
|
||||
this.docLinkDocuments = this.value
|
||||
.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">
|
||||
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs>
|
||||
<div class="d-none d-sm-inline"> {{title}}</div>
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
</div>
|
||||
<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}}
|
||||
</div>
|
||||
<div class="text-muted small pe-2">
|
||||
@@ -28,20 +28,19 @@
|
||||
</div>
|
||||
</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 i18n>After</div>
|
||||
<div class="selected-icon">
|
||||
@if (createdDateAfter) {
|
||||
<a class="btn btn-link p-0 m-0" (click)="clearCreatedAfter()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
<small i18n>Clear</small>
|
||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedAfter()">
|
||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
<div class="input-group input-group-sm small ps-1 pe-2">
|
||||
<span class="input-group-text w-25 small text-muted" i18n>After</span>
|
||||
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
maxlength="10" [(ngModel)]="createdDateAfter" ngbDatepicker #createdDateAfterPicker="ngbDatepicker">
|
||||
<button class="btn btn-outline-secondary" (click)="createdDateAfterPicker.toggle()" type="button">
|
||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||
@@ -49,20 +48,19 @@
|
||||
</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 i18n>Before</div>
|
||||
<div class="selected-icon">
|
||||
@if (createdDateBefore) {
|
||||
<a class="btn btn-link p-0 m-0" (click)="clearCreatedBefore()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
<small i18n>Clear</small>
|
||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedBefore()">
|
||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
<div class="input-group input-group-sm small ps-1 pe-2">
|
||||
<span class="input-group-text w-25 small text-muted" i18n>Before</span>
|
||||
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
maxlength="10" [(ngModel)]="createdDateBefore" ngbDatepicker #createdDateBeforePicker="ngbDatepicker">
|
||||
<button class="btn btn-outline-secondary" (click)="createdDateBeforePicker.toggle()" type="button">
|
||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||
@@ -83,7 +81,7 @@
|
||||
}
|
||||
</div>
|
||||
<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}}
|
||||
</div>
|
||||
<div class="text-muted small pe-2">
|
||||
@@ -94,20 +92,19 @@
|
||||
</div>
|
||||
</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 i18n>After</div>
|
||||
<div class="selected-icon">
|
||||
@if (addedDateAfter) {
|
||||
<a class="btn btn-link p-0 m-0" (click)="clearAddedAfter()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
<small i18n>Clear</small>
|
||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedAfter()">
|
||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
<div class="input-group input-group-sm small ps-1 pe-2">
|
||||
<span class="input-group-text w-25 small text-muted" i18n>After</span>
|
||||
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
maxlength="10" [(ngModel)]="addedDateAfter" ngbDatepicker #addedDateAfterPicker="ngbDatepicker">
|
||||
<button class="btn btn-outline-secondary" (click)="addedDateAfterPicker.toggle()" type="button">
|
||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||
@@ -115,20 +112,19 @@
|
||||
</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 i18n>Before</div>
|
||||
<div class="selected-icon">
|
||||
@if (addedDateBefore) {
|
||||
<a class="btn btn-link p-0 m-0" (click)="clearAddedBefore()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
<small i18n>Clear</small>
|
||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedBefore()">
|
||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
<div class="input-group input-group-sm small ps-1 pe-2">
|
||||
<span class="input-group-text w-25 small text-muted" i18n>Before</span>
|
||||
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
maxlength="10" [(ngModel)]="addedDateBefore" ngbDatepicker #addedDateBeforePicker="ngbDatepicker">
|
||||
<button class="btn btn-outline-secondary" (click)="addedDateBeforePicker.toggle()" type="button">
|
||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||
|
@@ -5,6 +5,12 @@
|
||||
--bs-dropdown-min-width: 40rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.border-end {
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -14,3 +20,24 @@
|
||||
min-width: 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 { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||
import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options'
|
||||
|
||||
export interface DateSelection {
|
||||
createdBefore?: string
|
||||
@@ -35,6 +36,8 @@ export enum RelativeDate {
|
||||
providers: [{ provide: NgbDateAdapter, useClass: ISODateAdapter }],
|
||||
})
|
||||
export class DatesDropdownComponent implements OnInit, OnDestroy {
|
||||
public popperOptions = popperOptionsReenablePreventOverflow
|
||||
|
||||
constructor(settings: SettingsService) {
|
||||
this.datePlaceHolder = settings.getLocalizedDateInputFormat()
|
||||
}
|
||||
|
@@ -28,6 +28,11 @@
|
||||
}
|
||||
</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>
|
||||
|
@@ -67,7 +67,7 @@ export class CustomFieldEditDialogComponent
|
||||
this.selectOptionInputs.changes
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.selectOptionInputs.last.nativeElement.focus()
|
||||
this.selectOptionInputs.last?.nativeElement.focus()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ export class CustomFieldEditDialogComponent
|
||||
data_type: new FormControl(null),
|
||||
extra_data: new FormGroup({
|
||||
select_options: new FormArray([new FormControl(null)]),
|
||||
default_currency: new FormControl(null),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
@@ -11,7 +11,7 @@ import {
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
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 { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
@@ -82,6 +82,7 @@ describe('MailAccountEditDialogComponent', () => {
|
||||
imap_port: 443,
|
||||
imap_security: IMAPSecurity.SSL,
|
||||
is_token: false,
|
||||
account_type: MailAccountType.IMAP,
|
||||
}
|
||||
|
||||
// success
|
||||
|
@@ -10,36 +10,60 @@
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<pngx-input-text 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>
|
||||
<pngx-input-text [horizontal]="true" i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<p class="small" i18n>Paperless will only process mails that match <em>all</em> of the filters specified below.</p>
|
||||
<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 class="col-md-3">
|
||||
<pngx-input-select [horizontal]="true" i18n-title title="Account" [items]="accounts" formControlName="account"></pngx-input-select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<pngx-input-select i18n-title title="Action" [items]="actionOptions" formControlName="action" i18n-hint hint="Action is only performed when documents are consumed from the mail. Mails without attachments remain entirely untouched."></pngx-input-select>
|
||||
<div class="col-md-3">
|
||||
<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) {
|
||||
<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-tags [allowCreate]="false" formControlName="assign_tags"></pngx-input-tags>
|
||||
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Assign correspondent from" [items]="metadataCorrespondentOptions" formControlName="assign_correspondent_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-check [horizontal]="true" i18n-title title="Assign owner from rule" formControlName="assign_owner_from_rule"></pngx-input-check>
|
||||
</div>
|
||||
<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) {
|
||||
<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>
|
||||
|
@@ -24,6 +24,7 @@ import { TextComponent } from '../../input/text/text.component'
|
||||
import { EditDialogMode } from '../edit-dialog.component'
|
||||
import { MailRuleEditDialogComponent } from './mail-rule-edit-dialog.component'
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { SwitchComponent } from '../../input/switch/switch.component'
|
||||
|
||||
describe('MailRuleEditDialogComponent', () => {
|
||||
let component: MailRuleEditDialogComponent
|
||||
@@ -43,6 +44,7 @@ describe('MailRuleEditDialogComponent', () => {
|
||||
TagsComponent,
|
||||
SafeHtmlPipe,
|
||||
CheckComponent,
|
||||
SwitchComponent,
|
||||
],
|
||||
imports: [FormsModule, ReactiveFormsModule, NgSelectModule, NgbModule],
|
||||
providers: [
|
||||
|
@@ -153,6 +153,7 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
|
||||
return new FormGroup({
|
||||
name: new FormControl(null),
|
||||
account: new FormControl(null),
|
||||
enabled: new FormControl(true),
|
||||
folder: new FormControl('INBOX'),
|
||||
filter_from: new FormControl(null),
|
||||
filter_to: new FormControl(null),
|
||||
|
@@ -10,7 +10,57 @@
|
||||
<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="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>
|
||||
@if (patternRequired) {
|
||||
<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 { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
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 { IfOwnerDirective } from 'src/app/directives/if-owner.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 { SelectComponent } from '../../input/select/select.component'
|
||||
import { TextComponent } from '../../input/text/text.component'
|
||||
import { TextAreaComponent } from '../../input/textarea/textarea.component'
|
||||
import { EditDialogMode } from '../edit-dialog.component'
|
||||
import { StoragePathEditDialogComponent } from './storage-path-edit-dialog.component'
|
||||
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', () => {
|
||||
let component: StoragePathEditDialogComponent
|
||||
let settingsService: SettingsService
|
||||
let documentService: DocumentService
|
||||
let fixture: ComponentFixture<StoragePathEditDialogComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -27,6 +38,7 @@ describe('StoragePathEditDialogComponent', () => {
|
||||
IfOwnerDirective,
|
||||
SelectComponent,
|
||||
TextComponent,
|
||||
TextAreaComponent,
|
||||
PermissionsFormComponent,
|
||||
SafeHtmlPipe,
|
||||
],
|
||||
@@ -38,6 +50,7 @@ describe('StoragePathEditDialogComponent', () => {
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
fixture = TestBed.createComponent(StoragePathEditDialogComponent)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||
@@ -57,4 +70,87 @@ describe('StoragePathEditDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
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 { 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 { 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 { 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 { UserService } from 'src/app/services/rest/user.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',
|
||||
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(
|
||||
service: StoragePathService,
|
||||
activeModal: NgbActiveModal,
|
||||
userService: UserService,
|
||||
settingsService: SettingsService
|
||||
settingsService: SettingsService,
|
||||
private documentsService: DocumentService
|
||||
) {
|
||||
super(service, activeModal, userService, settingsService)
|
||||
this.initPathObservables()
|
||||
}
|
||||
|
||||
get pathHint() {
|
||||
return (
|
||||
$localize`e.g.` +
|
||||
' <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.`
|
||||
)
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeNotifier.next(this)
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
|
||||
getCreateTitle() {
|
||||
@@ -51,4 +77,70 @@ export class StoragePathEditDialogComponent extends EditDialogComponent<StorageP
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.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">
|
||||
<i-bs name="{{icon}}"></i-bs>
|
||||
<div class="d-none d-sm-inline"> {{title}}</div>
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
@if (selectionModel.items) {
|
||||
<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) {
|
||||
<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">
|
||||
@@ -45,13 +45,13 @@
|
||||
</div>
|
||||
}
|
||||
@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">
|
||||
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
||||
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
||||
</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">
|
||||
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
|
||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||
|
@@ -539,15 +539,10 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
fixture.nativeElement
|
||||
.querySelector('button')
|
||||
.dispatchEvent(new MouseEvent('click')) // open
|
||||
fixture.detectChanges()
|
||||
tick(100)
|
||||
component.filterText = 'FooBar'
|
||||
fixture.detectChanges()
|
||||
component.listFilterTextInput.nativeElement.dispatchEvent(
|
||||
new KeyboardEvent('keyup', { key: 'Enter' })
|
||||
)
|
||||
component.listFilterEnter()
|
||||
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
||||
tick(300)
|
||||
expect(createSpy).toHaveBeenCalled()
|
||||
}))
|
||||
|
||||
|
@@ -16,6 +16,7 @@ import { Subject, filter, take, takeUntil } from 'rxjs'
|
||||
import { SelectionDataItem } from 'src/app/services/rest/document.service'
|
||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||
import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options'
|
||||
|
||||
export interface ChangedItems {
|
||||
itemsToAdd: MatchingModel[]
|
||||
@@ -330,6 +331,8 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
|
||||
@ViewChild('dropdown') dropdown: NgbDropdown
|
||||
@ViewChild('buttonItems') buttonItems: ElementRef
|
||||
|
||||
public popperOptions = popperOptionsReenablePreventOverflow
|
||||
|
||||
filterText: string
|
||||
|
||||
@Input()
|
||||
@@ -483,7 +486,7 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
|
||||
dropdownOpenChange(open: boolean): void {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
this.listFilterTextInput.nativeElement.focus()
|
||||
this.listFilterTextInput?.nativeElement.focus()
|
||||
}, 0)
|
||||
if (this.editing) {
|
||||
this.selectionModel.reset()
|
||||
@@ -492,7 +495,7 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
|
||||
this.opened.next(this)
|
||||
} else {
|
||||
if (this.creating) {
|
||||
this.dropdown.open()
|
||||
this.dropdown?.open()
|
||||
this.creating = false
|
||||
} else {
|
||||
this.filterText = ''
|
||||
|
@@ -1,3 +1,6 @@
|
||||
@if (minimal) {
|
||||
<ng-container *ngTemplateOutlet="select"></ng-container>
|
||||
} @else {
|
||||
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
|
||||
<div class="row">
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||
@@ -11,11 +14,20 @@
|
||||
}
|
||||
</div>
|
||||
<div [class.col-md-9]="horizontal">
|
||||
<div>
|
||||
<ng-container *ngTemplateOutlet="select"></ng-container>
|
||||
@if (hint) {
|
||||
<small class="form-text text-muted">{{hint}}</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-template #select>
|
||||
<ng-select name="inputId" [(ngModel)]="selectedDocuments"
|
||||
[disabled]="disabled"
|
||||
[items]="foundDocuments$ | async"
|
||||
placeholder="Search for documents"
|
||||
[placeholder]="placeholder"
|
||||
[notFoundText]="notFoundText"
|
||||
[multiple]="true"
|
||||
bindValue="id"
|
||||
@@ -24,13 +36,20 @@
|
||||
[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" (click)="unselect(document)" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
|
||||
<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>
|
||||
@@ -41,10 +60,4 @@
|
||||
<div>{{document.title}} <small class="text-muted">({{document.created | customDate:'shortDate'}})</small></div>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
</div>
|
||||
@if (hint) {
|
||||
<small class="form-text text-muted">{{hint}}</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
@@ -3,7 +3,19 @@
|
||||
|
||||
.ng-value {
|
||||
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()
|
||||
parentDocumentID: number
|
||||
|
||||
@Input()
|
||||
minimal: boolean = false
|
||||
|
||||
@Input()
|
||||
placeholder: string = $localize`Search for documents`
|
||||
|
||||
constructor(private documentsService: DocumentService) {
|
||||
super()
|
||||
}
|
||||
@@ -108,7 +114,7 @@ export class DocumentLinkComponent
|
||||
|
||||
unselect(document: Document): void {
|
||||
this.selectedDocuments = this.selectedDocuments.filter(
|
||||
(d) => d.id !== document.id
|
||||
(d) => d && d.id !== document.id
|
||||
)
|
||||
this.onChange(this.selectedDocuments.map((d) => d.id))
|
||||
}
|
||||
|
@@ -38,7 +38,9 @@ export class DragDropSelectComponent extends AbstractInputComponent<string[]> {
|
||||
writeValue(newValue: string[]): void {
|
||||
super.writeValue(newValue)
|
||||
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[]>) {
|
||||
|
@@ -52,6 +52,11 @@ describe('MonetaryComponent', () => {
|
||||
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', () => {
|
||||
expect(component['parseMonetaryValue']('123.4')).toEqual('123.4')
|
||||
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 { AbstractInputComponent } from '../abstract-input'
|
||||
import { getLocaleCurrencyCode } from '@angular/common'
|
||||
@@ -29,11 +29,16 @@ export class MonetaryComponent extends AbstractInputComponent<string> {
|
||||
|
||||
defaultCurrencyCode: string
|
||||
|
||||
@Input()
|
||||
set defaultCurrency(currency: string) {
|
||||
if (currency) this.defaultCurrencyCode = currency
|
||||
}
|
||||
|
||||
constructor(@Inject(LOCALE_ID) currentLocale: string) {
|
||||
super()
|
||||
|
||||
this.currency = this.defaultCurrencyCode =
|
||||
getLocaleCurrencyCode(currentLocale)
|
||||
this.defaultCurrency ?? getLocaleCurrencyCode(currentLocale)
|
||||
}
|
||||
|
||||
writeValue(newValue: any): void {
|
||||
|
@@ -27,3 +27,11 @@
|
||||
background-position: right calc(0.375em + 0.1875rem) center !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>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
@if (allowCreate) {
|
||||
@if (allowCreate && !hideAddButton) {
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="createTag()" [disabled]="disabled">
|
||||
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
|
||||
</button>
|
||||
|
@@ -74,6 +74,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
@Input()
|
||||
allowCreate: boolean = true
|
||||
|
||||
@Input()
|
||||
hideAddButton: boolean = false
|
||||
|
||||
@Input()
|
||||
showFilter: boolean = false
|
||||
|
||||
|
@@ -11,7 +11,7 @@
|
||||
}
|
||||
</div>
|
||||
<div class="position-relative" [class.col-md-9]="horizontal">
|
||||
<input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled" [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) {
|
||||
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
|
||||
}
|
||||
|
@@ -18,6 +18,9 @@ export class TextComponent extends AbstractInputComponent<string> {
|
||||
@Input()
|
||||
autocomplete: string
|
||||
|
||||
@Input()
|
||||
placeholder: string = ''
|
||||
|
||||
constructor() {
|
||||
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,13 +56,12 @@
|
||||
<small i18n>Unowned</small>
|
||||
</div>
|
||||
</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">
|
||||
@if (selectionModel.ownerFilter === OwnerFilterType.OTHERS) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.User)) {
|
||||
<div class="me-1 w-100">
|
||||
<ng-select
|
||||
name="user"
|
||||
@@ -79,7 +78,6 @@
|
||||
(change)="onUserSelect()">
|
||||
</ng-select>
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
@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">
|
||||
|
@@ -7,3 +7,32 @@
|
||||
::ng-deep .popover.popover-preview {
|
||||
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.DOCUMENT_TYPE,
|
||||
DisplayField.STORAGE_PATH,
|
||||
DisplayField.PAGE_COUNT,
|
||||
`${DisplayField.CUSTOM_FIELD}11` as any,
|
||||
`${DisplayField.CUSTOM_FIELD}15` as any,
|
||||
],
|
||||
@@ -344,6 +345,7 @@ describe('SavedViewWidgetComponent', () => {
|
||||
expect(component.getColumnTitle(DisplayField.STORAGE_PATH)).toEqual(
|
||||
'Storage path'
|
||||
)
|
||||
expect(component.getColumnTitle(DisplayField.PAGE_COUNT)).toEqual('Pages')
|
||||
})
|
||||
|
||||
it('should get correct column title for custom field', () => {
|
||||
|
@@ -71,6 +71,13 @@ describe('StatisticsWidgetComponent', () => {
|
||||
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', () => {
|
||||
const mockStats = {
|
||||
documents_total: 200,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
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 { ConsumerStatusService } from 'src/app/services/consumer-status.service'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
@@ -35,7 +35,7 @@ export class StatisticsWidgetComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
loading: boolean = true
|
||||
loading: boolean = false
|
||||
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
@@ -48,14 +48,15 @@ export class StatisticsWidgetComponent
|
||||
statistics: Statistics = {}
|
||||
|
||||
subscription: Subscription
|
||||
|
||||
private getStatistics(): Observable<Statistics> {
|
||||
return this.http.get(`${environment.apiBaseUrl}statistics/`)
|
||||
}
|
||||
private unsubscribeNotifer: Subject<any> = new Subject()
|
||||
|
||||
reload() {
|
||||
if (this.loading) return
|
||||
this.loading = true
|
||||
this.getStatistics().subscribe((statistics) => {
|
||||
this.http
|
||||
.get<Statistics>(`${environment.apiBaseUrl}statistics/`)
|
||||
.pipe(takeUntil(this.unsubscribeNotifer), first())
|
||||
.subscribe((statistics) => {
|
||||
this.loading = false
|
||||
const fileTypeMax = 5
|
||||
if (statistics.document_file_type_counts?.length > fileTypeMax) {
|
||||
@@ -105,6 +106,8 @@ export class StatisticsWidgetComponent
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subscription.unsubscribe()
|
||||
this.unsubscribeNotifer.next(true)
|
||||
this.unsubscribeNotifer.complete()
|
||||
}
|
||||
|
||||
goToInbox() {
|
||||
|
@@ -6,8 +6,8 @@
|
||||
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
||||
</form>
|
||||
@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="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto">
|
||||
<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 overflow-y-scroll">
|
||||
<div class="card shadow-sm consumer-status-card">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
|
@@ -12,7 +12,7 @@ import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||
<button
|
||||
*pngxIfObjectPermissions="{
|
||||
object: { id: 2, owner: user1 },
|
||||
action: 'view'
|
||||
action: 'view',
|
||||
}"
|
||||
>
|
||||
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)"
|
||||
[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)"
|
||||
(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)"
|
||||
(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)"
|
||||
(createNew)="createStoragePath($event)" [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>
|
||||
(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)" [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) {
|
||||
<div [formGroup]="customFieldFormFields.controls[i]">
|
||||
@switch (getCustomFieldFromInstance(fieldInstance)?.data_type) {
|
||||
@@ -157,6 +157,7 @@
|
||||
@case (CustomFieldDataType.Monetary) {
|
||||
<pngx-input-monetary formControlName="value"
|
||||
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
|
||||
[defaultCurrency]="getCustomFieldFromInstance(fieldInstance)?.extra_data?.default_currency"
|
||||
[removable]="userIsOwner"
|
||||
(removed)="removeField(fieldInstance)"
|
||||
[horizontal]="true"
|
||||
@@ -343,8 +344,8 @@
|
||||
@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>
|
||||
}
|
||||
</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>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -378,7 +379,7 @@
|
||||
}
|
||||
}
|
||||
@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) {
|
||||
<div class="preview-sticky">
|
||||
|
@@ -19,10 +19,6 @@
|
||||
--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 {
|
||||
border-top-right-radius: inherit;
|
||||
border-bottom-right-radius: inherit;
|
||||
@@ -66,3 +62,7 @@ textarea.rtl {
|
||||
height: 100%;
|
||||
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 { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { TextAreaComponent } from '../common/input/textarea/textarea.component'
|
||||
|
||||
const doc: Document = {
|
||||
id: 3,
|
||||
@@ -183,6 +184,7 @@ describe('DocumentDetailComponent', () => {
|
||||
SplitConfirmDialogComponent,
|
||||
RotateConfirmDialogComponent,
|
||||
DeletePagesConfirmDialogComponent,
|
||||
TextAreaComponent,
|
||||
],
|
||||
imports: [
|
||||
RouterModule.forRoot(routes),
|
||||
@@ -1244,4 +1246,20 @@ describe('DocumentDetailComponent', () => {
|
||||
)
|
||||
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