mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-08-07 19:08:32 -05:00
Compare commits
172 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9885ca5103 | ||
![]() |
2f22beaaee | ||
![]() |
fb2c6282a4 | ||
![]() |
8c5b5d3948 | ||
![]() |
4e5135fe70 | ||
![]() |
579c35a3fe | ||
![]() |
4aedcb856d | ||
![]() |
0b34e70f6c | ||
![]() |
7afc91e7b1 | ||
![]() |
56b17ce6a2 | ||
![]() |
954912cac3 | ||
![]() |
e46f6b1156 | ||
![]() |
1d85caa8d0 | ||
![]() |
622fcf96a0 | ||
![]() |
654cc05f0e | ||
![]() |
974dd24e69 | ||
![]() |
fe824e0faa | ||
![]() |
377d89ae06 | ||
![]() |
629e24e031 | ||
![]() |
38414025c8 | ||
![]() |
1dc5b7a707 | ||
![]() |
f076418c50 | ||
![]() |
bbaad2cdfb | ||
![]() |
ef01658335 | ||
![]() |
9f4a6c3b42 | ||
![]() |
5450bfb67b | ||
![]() |
ae2b302962 | ||
![]() |
957691c454 | ||
![]() |
6b17ba2934 | ||
![]() |
4d3616cda9 | ||
![]() |
c4a9697e02 | ||
![]() |
c57b7520b9 | ||
![]() |
46bd09227f | ||
![]() |
971f92a05c | ||
![]() |
2c43b06910 | ||
![]() |
0f8b2e69c9 | ||
![]() |
00b04c2e86 | ||
![]() |
b3c66cae06 | ||
![]() |
6a79d417b4 | ||
![]() |
98ef68f720 | ||
![]() |
ed3b7aa8f2 | ||
![]() |
e536600052 | ||
![]() |
bb820a2127 | ||
![]() |
129933ff30 | ||
![]() |
41fc11efff | ||
![]() |
a712bc72ca | ||
![]() |
fbe7acc6b0 | ||
![]() |
c4153b6fbf | ||
![]() |
1f355a22e0 | ||
![]() |
4af8070450 | ||
![]() |
d6d0071175 | ||
![]() |
ef51633b2c | ||
![]() |
d4963b9cbe | ||
![]() |
01dabf7c05 | ||
![]() |
fc68f79cc8 | ||
![]() |
ebe1479503 | ||
![]() |
8c9fe4da06 | ||
![]() |
b2ef51af55 | ||
![]() |
32b35d8e4b | ||
![]() |
c8bda18cf2 | ||
![]() |
0a944975cc | ||
![]() |
48eaa31ecf | ||
![]() |
1540e88a06 | ||
![]() |
b1aa57abcb | ||
![]() |
df359730fe | ||
![]() |
43ec154bc2 | ||
![]() |
2c4a664df4 | ||
![]() |
a196c14a58 | ||
![]() |
6f549506d6 | ||
![]() |
8d463e05ae | ||
![]() |
373c91911d | ||
![]() |
1d3ac99c02 | ||
![]() |
cda4c8f87e | ||
![]() |
ef4f589094 | ||
![]() |
3aeb45bf34 | ||
![]() |
b91da77a8a | ||
![]() |
33357a3fc2 | ||
![]() |
025001499d | ||
![]() |
ccd6ad9936 | ||
![]() |
979fcb0570 | ||
![]() |
58afec98f1 | ||
![]() |
91434a5c6f | ||
![]() |
37c4545444 | ||
![]() |
7b7b257725 | ||
![]() |
1eff4b306f | ||
![]() |
700af8caa2 | ||
![]() |
90b43e154a | ||
![]() |
a3892302b0 | ||
![]() |
d2ee319684 | ||
![]() |
5a6923a9aa | ||
![]() |
0a634883a7 | ||
![]() |
428f9cd761 | ||
![]() |
d828c1a2ff | ||
![]() |
25b49db7c0 | ||
![]() |
55a40708a6 | ||
![]() |
dae5bca883 | ||
![]() |
fc74da9b82 | ||
![]() |
2b006907d5 | ||
![]() |
1ba1afdce5 | ||
![]() |
a98317c52a | ||
![]() |
ffddd0f323 | ||
![]() |
4cb2f0acef | ||
![]() |
98663e902f | ||
![]() |
0f5e935214 | ||
![]() |
b9636a3def | ||
![]() |
35574f3b86 | ||
![]() |
00a8f0cd6e | ||
![]() |
6779042242 | ||
![]() |
6379e7b54f | ||
![]() |
2fa742c94b | ||
![]() |
aa0da2f516 | ||
![]() |
f07441a408 | ||
![]() |
f6084acfc8 | ||
![]() |
23ceb2a5ec | ||
![]() |
a698791059 | ||
![]() |
41c1f38ab2 | ||
![]() |
83c85dc10e | ||
![]() |
a020d807d4 | ||
![]() |
464ee51de8 | ||
![]() |
c57c1d5389 | ||
![]() |
af16bb3934 | ||
![]() |
fba416e8e1 | ||
![]() |
3d8de50b5a | ||
![]() |
86263a52ea | ||
![]() |
754627681c | ||
![]() |
bf11dc8d1b | ||
![]() |
84721b001f | ||
![]() |
f48a20c75f | ||
![]() |
16f4552e0e | ||
![]() |
86811d0733 | ||
![]() |
d2f9b5d5e5 | ||
![]() |
f5e1675107 | ||
![]() |
ae016cae4b | ||
![]() |
c7f6d03508 | ||
![]() |
955f2d0db9 | ||
![]() |
ba32684df6 | ||
![]() |
2f7adf40ac | ||
![]() |
a52031161b | ||
![]() |
3e2c541b7b | ||
![]() |
967fc98090 | ||
![]() |
22e95f45bd | ||
![]() |
38c777ec0f | ||
![]() |
ccbc97399a | ||
![]() |
f43013a746 | ||
![]() |
1335ab5f1b | ||
![]() |
90b4691f16 | ||
![]() |
f053ee3191 | ||
![]() |
86748c1e96 | ||
![]() |
22ded7d4c3 | ||
![]() |
ff5063849a | ||
![]() |
ec49284274 | ||
![]() |
6bd5c34b54 | ||
![]() |
db0a2eb1a3 | ||
![]() |
4948438378 | ||
![]() |
76064178f5 | ||
![]() |
c772bd94b0 | ||
![]() |
7f8f7fbb15 | ||
![]() |
388d821f45 | ||
![]() |
9c15623a89 | ||
![]() |
966eb00de0 | ||
![]() |
1c699278a3 | ||
![]() |
4c6c976f63 | ||
![]() |
ebc9ce17b5 | ||
![]() |
8039ce3c2b | ||
![]() |
385d48f644 | ||
![]() |
f682fe25fc | ||
![]() |
7924bf8611 | ||
![]() |
d75b909d28 | ||
![]() |
47dfe85a7c | ||
![]() |
4d0e8a338f | ||
![]() |
cfc64d37bb | ||
![]() |
2db66280cc |
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -47,6 +47,8 @@ updates:
|
||||
# Add reviewers
|
||||
reviewers:
|
||||
- "paperless-ngx/backend"
|
||||
ignore:
|
||||
- dependency-name: "uvicorn"
|
||||
groups:
|
||||
development:
|
||||
patterns:
|
||||
|
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
-
|
||||
name: Check files
|
||||
uses: pre-commit/action@v3.0.0
|
||||
uses: pre-commit/action@v3.0.1
|
||||
|
||||
documentation:
|
||||
name: "Build & Deploy Documentation"
|
||||
@@ -577,7 +577,7 @@ jobs:
|
||||
-
|
||||
name: Create Release and Changelog
|
||||
id: create-release
|
||||
uses: release-drafter/release-drafter@v5
|
||||
uses: release-drafter/release-drafter@v6
|
||||
with:
|
||||
name: Paperless-ngx ${{ steps.get_version.outputs.version }}
|
||||
tag: ${{ steps.get_version.outputs.version }}
|
||||
|
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.4.0
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.5.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.4.0
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.5.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "${{ github.repository_owner }}"
|
||||
|
2
.github/workflows/project-actions.yml
vendored
2
.github/workflows/project-actions.yml
vendored
@@ -22,6 +22,6 @@ jobs:
|
||||
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
|
||||
steps:
|
||||
- name: Label PR with release-drafter
|
||||
uses: release-drafter/release-drafter@v5
|
||||
uses: release-drafter/release-drafter@v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
87
.github/workflows/repo-maintenance.yml
vendored
87
.github/workflows/repo-maintenance.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
stale-issue-message: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
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'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -43,14 +43,17 @@ jobs:
|
||||
This issue has been automatically locked since there
|
||||
has not been any recent activity after it was closed.
|
||||
Please open a new discussion or issue for related concerns.
|
||||
See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
||||
pr-comment: >
|
||||
This pull request has been automatically locked since there
|
||||
has not been any recent activity after it was closed.
|
||||
Please open a new discussion or issue for related concerns.
|
||||
See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
||||
discussion-comment: >
|
||||
This discussion has been automatically locked since there
|
||||
has not been any recent activity after it was closed.
|
||||
Please open a new discussion for related concerns.
|
||||
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'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -90,7 +93,7 @@ jobs:
|
||||
}`;
|
||||
const commentVariables = {
|
||||
discussion: discussion.id,
|
||||
body: 'This discussion has been automatically closed because it was marked as answered.',
|
||||
body: 'This discussion has been automatically closed because it was marked as answered. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.',
|
||||
}
|
||||
await github.graphql(addCommentMutation, commentVariables)
|
||||
|
||||
@@ -180,7 +183,85 @@ jobs:
|
||||
}`;
|
||||
const commentVariables = {
|
||||
discussion: discussion.id,
|
||||
body: 'This discussion has been automatically closed due to inactivity.',
|
||||
body: 'This discussion has been automatically closed due to inactivity. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.',
|
||||
}
|
||||
await github.graphql(addCommentMutation, commentVariables);
|
||||
|
||||
const closeDiscussionMutation = `mutation($discussion:ID!, $reason:DiscussionCloseReason!) {
|
||||
closeDiscussion(input:{discussionId:$discussion, reason:$reason}) {
|
||||
clientMutationId
|
||||
}
|
||||
}`;
|
||||
const closeVariables = {
|
||||
discussion: discussion.id,
|
||||
reason: "OUTDATED",
|
||||
}
|
||||
await github.graphql(closeDiscussionMutation, closeVariables);
|
||||
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
close-unsupported-feature-requests:
|
||||
name: 'Close Unsupported Feature Requests'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const CUTOFF_1_DAYS = 180;
|
||||
const CUTOFF_1_COUNT = 5;
|
||||
const CUTOFF_2_DAYS = 365;
|
||||
const CUTOFF_2_COUNT = 10;
|
||||
|
||||
const cutoff1Date = new Date();
|
||||
cutoff1Date.setDate(cutoff1Date.getDate() - CUTOFF_1_DAYS);
|
||||
const cutoff2Date = new Date();
|
||||
cutoff2Date.setDate(cutoff2Date.getDate() - CUTOFF_2_DAYS);
|
||||
|
||||
const query = `query(
|
||||
$owner:String!,
|
||||
$name:String!,
|
||||
$featureRequestsCategory:ID!,
|
||||
) {
|
||||
repository(owner:$owner, name:$name){
|
||||
discussions(
|
||||
categoryId:$featureRequestsCategory,
|
||||
last:100,
|
||||
states:[OPEN],
|
||||
) {
|
||||
nodes {
|
||||
id,
|
||||
number,
|
||||
updatedAt,
|
||||
upvoteCount,
|
||||
}
|
||||
},
|
||||
}
|
||||
}`;
|
||||
const variables = {
|
||||
owner: context.repo.owner,
|
||||
name: context.repo.repo,
|
||||
featureRequestsCategory: "DIC_kwDOG1Zs184CBNr4"
|
||||
}
|
||||
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)) {
|
||||
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}) {
|
||||
clientMutationId
|
||||
}
|
||||
}`;
|
||||
const commentVariables = {
|
||||
discussion: discussion.id,
|
||||
body: 'This discussion has been automatically closed due to lack of community support. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.',
|
||||
}
|
||||
await github.graphql(addCommentMutation, commentVariables);
|
||||
|
||||
|
@@ -47,11 +47,11 @@ repos:
|
||||
exclude: "(^Pipfile\\.lock$)"
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: 'v0.2.1'
|
||||
rev: 'v0.3.5'
|
||||
hooks:
|
||||
- id: ruff
|
||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||
rev: 24.1.1
|
||||
rev: 24.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
# Dockerfile hooks
|
||||
@@ -67,6 +67,6 @@ repos:
|
||||
args:
|
||||
- "--tab"
|
||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||
rev: "v0.9.0.6"
|
||||
rev: "v0.10.0.1"
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
|
@@ -137,3 +137,19 @@ All team members are notified when mentioned or assigned to a relevant issue or
|
||||
We are not overly strict with inviting people to the organization. If you have read the [team permissions](#permissions) and think having additional access would enhance your contributions, please reach out to an [admin](#structure) of the team.
|
||||
|
||||
The admins occasionally invite contributors directly if we believe having them on a team will accelerate their work.
|
||||
|
||||
# Automatic Repository Maintenance
|
||||
|
||||
The Paperless-ngx team appreciates all effort and interest from the community in filing bug reports, creating feature requests, sharing ideas and helping other
|
||||
community members. That said, in an effort to keep the repository organized and managebale the project uses automatic handling of certain areas:
|
||||
|
||||
- Issues that cannot be reproduced will be marked 'stale' after 7 days of inactivity and closed after 14 further days of inactivity.
|
||||
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
|
||||
- Discussions with a marked answer will be automatically closed.
|
||||
- Discussions 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.
|
||||
|
||||
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.
|
||||
|
||||
Thank you all for your contributions.
|
||||
|
@@ -59,7 +59,8 @@ ARG GS_VERSION=10.02.1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
# Ignore warning from Whitenoise
|
||||
PYTHONWARNINGS="ignore:::django.http.response:517"
|
||||
PYTHONWARNINGS="ignore:::django.http.response:517" \
|
||||
PNGX_CONTAINERIZED=1
|
||||
|
||||
#
|
||||
# Begin installation and configuration
|
||||
|
6
Pipfile
6
Pipfile
@@ -7,17 +7,17 @@ name = "pypi"
|
||||
dateparser = "~=1.2"
|
||||
# WARNING: django does not use semver.
|
||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||
django = "~=4.2.10"
|
||||
django = "~=4.2.11"
|
||||
django-allauth = "*"
|
||||
django-auditlog = "*"
|
||||
django-celery-results = "*"
|
||||
django-compression-middleware = "*"
|
||||
django-cors-headers = "*"
|
||||
django-extensions = "*"
|
||||
django-filter = "~=23.5"
|
||||
django-filter = "~=24.1"
|
||||
django-guardian = "*"
|
||||
django-multiselectfield = "*"
|
||||
djangorestframework = "~=3.14"
|
||||
djangorestframework = "==3.14.0"
|
||||
djangorestframework-guardian = "*"
|
||||
drf-writable-nested = "*"
|
||||
bleach = "*"
|
||||
|
1528
Pipfile.lock
generated
1528
Pipfile.lock
generated
File diff suppressed because it is too large
Load Diff
9
SECURITY.md
Normal file
9
SECURITY.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
The Paperless-ngx team and community take security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
||||
|
||||
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/paperless-ngx/paperless-ngx/security/advisories/new) tab.
|
||||
|
||||
The team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
@@ -90,13 +90,13 @@ initialize() {
|
||||
fi
|
||||
done
|
||||
|
||||
local -r tmp_dir="/tmp/paperless"
|
||||
echo "Creating directory ${tmp_dir}"
|
||||
local -r tmp_dir="${PAPERLESS_SCRATCH_DIR:=/tmp/paperless}"
|
||||
echo "Creating directory scratch directory ${tmp_dir}"
|
||||
mkdir --parents "${tmp_dir}"
|
||||
|
||||
set +e
|
||||
echo "Adjusting permissions of paperless files. This may take a while."
|
||||
chown -R paperless:paperless ${tmp_dir}
|
||||
chown -R paperless:paperless "${tmp_dir}"
|
||||
for dir in \
|
||||
"${export_dir}" \
|
||||
"${DATA_DIR}" \
|
||||
|
@@ -349,11 +349,17 @@ When you use the provided docker compose script, put the export inside
|
||||
the `export` folder in your paperless source directory. Specify
|
||||
`../export` as the `source`.
|
||||
|
||||
Note that .zip files (as can be generated from the exporter) are not supported.
|
||||
|
||||
!!! note
|
||||
|
||||
Importing from a previous version of Paperless may work, but for best
|
||||
results it is suggested to match the versions.
|
||||
|
||||
!!! warning
|
||||
|
||||
The importer should be run against a completely empty installation (database and directories) of Paperless-ngx.
|
||||
|
||||
### Document retagger {#retagger}
|
||||
|
||||
Say you've imported a few hundred documents and now want to introduce a
|
||||
|
@@ -256,7 +256,8 @@ document. You will end up getting files like `0000123.pdf` in your media
|
||||
directory. This isn't necessarily a bad thing, because you normally
|
||||
don't have to access these files manually. However, if you wish to name
|
||||
your files differently, you can do that by adjusting the
|
||||
[`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) configuration option. Paperless adds the
|
||||
[`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) configuration option
|
||||
or using [storage paths (see below)](#storage-paths). Paperless adds the
|
||||
correct file extension e.g. `.pdf`, `.jpg` automatically.
|
||||
|
||||
This variable allows you to configure the filename (folders are allowed)
|
||||
@@ -289,6 +290,15 @@ will create a directory structure as follows:
|
||||
paperless will report your files as missing and won't be able to find
|
||||
them.
|
||||
|
||||
!!! tip
|
||||
|
||||
Paperless checks the filename of a document whenever it is saved. Changing (or deleting)
|
||||
a [storage path](#storage-paths) will automatically be reflected in the file system. However,
|
||||
when changing `PAPERLESS_FILENAME_FORMAT` you will need to manually run the
|
||||
[`document renamer`](administration.md#renamer) to move any existing documents.
|
||||
|
||||
#### Placeholders
|
||||
|
||||
Paperless provides the following placeholders within filenames:
|
||||
|
||||
- `{asn}`: The archive serial number of the document, or "none".
|
||||
@@ -321,6 +331,12 @@ Paperless provides the following placeholders within filenames:
|
||||
- `{original_name}`: Document original filename, minus the extension, if any, or "none"
|
||||
- `{doc_pk}`: The paperless identifier (primary key) for the document.
|
||||
|
||||
!!! warning
|
||||
|
||||
When using file name placeholders, in particular when using `{tag_list}`,
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -331,34 +347,12 @@ paperless will automatically append `_01`, `_02`, etc to the filename.
|
||||
This happens if all the placeholders in a filename evaluate to the same
|
||||
value.
|
||||
|
||||
!!! tip
|
||||
|
||||
You can affect how empty placeholders are treated by changing the
|
||||
following setting to `true`.
|
||||
|
||||
```
|
||||
PAPERLESS_FILENAME_FORMAT_REMOVE_NONE=True
|
||||
```
|
||||
|
||||
Doing this results in all empty placeholders resolving to "" instead
|
||||
of "none" as stated above. Spaces before empty placeholders are
|
||||
removed as well, empty directories are omitted.
|
||||
|
||||
!!! tip
|
||||
|
||||
Paperless checks the filename of a document whenever it is saved.
|
||||
Therefore, you need to update the filenames of your documents and move
|
||||
them after altering this setting by invoking the
|
||||
[`document renamer`](administration.md#renamer).
|
||||
|
||||
!!! warning
|
||||
|
||||
Make absolutely sure you get the spelling of the placeholders right, or
|
||||
else paperless will use the default naming scheme instead.
|
||||
If there are any errors in the placeholders included in `PAPERLESS_FILENAME_FORMAT`,
|
||||
paperless will fall back to using the default naming scheme instead.
|
||||
|
||||
!!! caution
|
||||
|
||||
As of now, you could totally tell paperless to store your files anywhere
|
||||
As of now, you could potentially tell paperless to store your files anywhere
|
||||
outside the media directory by setting
|
||||
|
||||
```
|
||||
@@ -366,28 +360,25 @@ value.
|
||||
```
|
||||
|
||||
However, keep in mind that inside docker, if files get stored outside of
|
||||
the predefined volumes, they will be lost after a restart of paperless.
|
||||
the predefined volumes, they will be lost after a restart.
|
||||
|
||||
!!! warning
|
||||
##### Empty placeholders
|
||||
|
||||
When file naming handling, in particular when using `{tag_list}`,
|
||||
you may run into the limits of your operating system's maximum
|
||||
path lengths. Files will retain the previous path instead and
|
||||
the issue logged.
|
||||
You can affect how empty placeholders are treated by changing the
|
||||
[`PAPERLESS_FILENAME_FORMAT_REMOVE_NONE`](configuration.md#PAPERLESS_FILENAME_FORMAT_REMOVE_NONE) setting.
|
||||
|
||||
## Storage paths
|
||||
Enabling this results in all empty placeholders resolving to "" instead of "none" as stated above. Spaces
|
||||
before empty placeholders are removed as well, empty directories are omitted.
|
||||
|
||||
One of the best things in Paperless is that you can not only access the
|
||||
documents via the web interface, but also via the file system.
|
||||
### Storage paths
|
||||
|
||||
When a single storage layout is not sufficient for your use case,
|
||||
storage paths come to the rescue. Storage paths allow you to configure
|
||||
more precisely where each document is stored in the file system.
|
||||
When a single storage layout is not sufficient for your use case, storage paths allow for more complex
|
||||
structure to set precisely where each document is stored in the file system.
|
||||
|
||||
- Each storage path is a [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) and
|
||||
follows the rules described above
|
||||
- Each document is assigned a storage path using the matching
|
||||
algorithms described above, but can be overwritten at any time
|
||||
- Each document is assigned a storage path using the matching algorithms described above, but can be
|
||||
overwritten at any time
|
||||
|
||||
For example, you could define the following two storage paths:
|
||||
|
||||
@@ -437,7 +428,7 @@ with Prometheus, as it exports metrics. For details on its capabilities,
|
||||
refer to the [Flower](https://flower.readthedocs.io/en/latest/index.html)
|
||||
documentation.
|
||||
|
||||
Flower can be enabled with the setting [PAPERLESS_ENABLE_FLOWER](configuration/#PAPERLESS_ENABLE_FLOWER).
|
||||
Flower can be enabled with the setting [PAPERLESS_ENABLE_FLOWER](configuration.md#PAPERLESS_ENABLE_FLOWER).
|
||||
To configure Flower further, create a `flowerconfig.py` and
|
||||
place it into the `src/paperless` directory. For a Docker
|
||||
installation, you can use volumes to accomplish this:
|
||||
@@ -658,8 +649,9 @@ external authentication solution using one of the following methods:
|
||||
|
||||
This is a simple option that uses remote user authentication made available by certain SSO
|
||||
applications. See the relevant configuration options for more information:
|
||||
[PAPERLESS_ENABLE_HTTP_REMOTE_USER](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER) and
|
||||
[PAPERLESS_ENABLE_HTTP_REMOTE_USER](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER),
|
||||
[PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME](configuration.md#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME)
|
||||
and [PAPERLESS_LOGOUT_REDIRECT_URL](configuration.md#PAPERLESS_LOGOUT_REDIRECT_URL)
|
||||
|
||||
### OpenID Connect and social authentication
|
||||
|
||||
@@ -692,3 +684,7 @@ PAPERLESS_SOCIALACCOUNT_PROVIDERS='
|
||||
```
|
||||
|
||||
More details about configuration option for various providers can be found in the [allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html#provider-specifics).
|
||||
|
||||
### Disabling Regular Login
|
||||
|
||||
Once external auth is set up, 'regular' login can be disabled with the [PAPERLESS_DISABLE_REGULAR_LOGIN](configuration.md#PAPERLESS_DISABLE_REGULAR_LOGIN) setting.
|
||||
|
20
docs/api.md
20
docs/api.md
@@ -58,6 +58,10 @@ fields:
|
||||
- `custom_fields`: Array of custom fields & values, specified as
|
||||
`{ field: CUSTOM_FIELD_ID, value: VALUE }`
|
||||
|
||||
!!! note
|
||||
|
||||
Note that all endpoint URLs must end with a `/`slash.
|
||||
|
||||
## Downloading documents
|
||||
|
||||
In addition to that, the document endpoint offers these additional
|
||||
@@ -284,6 +288,8 @@ The endpoint supports the following optional form fields:
|
||||
- `tags`: Similar to correspondent. Specify this multiple times to
|
||||
have multiple tags added to the document.
|
||||
- `archive_serial_number`: An optional archive serial number to set.
|
||||
- `custom_fields`: An array of custom field ids to assign (with an empty
|
||||
value) to the document.
|
||||
|
||||
The endpoint will immediately return HTTP 200 if the document consumption
|
||||
process was started successfully, with the UUID of the consumption task
|
||||
@@ -336,7 +342,7 @@ The API supports various bulk-editing operations which are executed asynchronous
|
||||
|
||||
### Documents
|
||||
|
||||
For bulk operations on documents, use the endpoint `/api/bulk_edit/` which accepts
|
||||
For bulk operations on documents, use the endpoint `/api/documents/bulk_edit/` which accepts
|
||||
a json payload of the format:
|
||||
|
||||
```json
|
||||
@@ -372,6 +378,18 @@ The following methods are supported:
|
||||
- `"merge": true or false` (defaults to false)
|
||||
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
||||
removing them) or be merged with existing permissions.
|
||||
- `merge`
|
||||
- No additional `parameters` required.
|
||||
- The ordering of the merged document is determined by the list of IDs.
|
||||
- Optional `parameters`:
|
||||
- `"metadata_document_id": DOC_ID` apply metadata (tags, correspondent, etc.) from this document to the merged document.
|
||||
- `split`
|
||||
- Requires `parameters`:
|
||||
- `"pages": [..]` The list should be a list of pages and/or a ranges, separated by commas e.g. `"[1,2-3,4,5-7]"`
|
||||
- The split operation only accepts a single document.
|
||||
- `rotate`
|
||||
- Requires `parameters`:
|
||||
- `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270
|
||||
|
||||
### Objects
|
||||
|
||||
|
@@ -1,5 +1,296 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 2.7.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: Only disable split button if pages = 1 [@shamoon](https://github.com/shamoon) ([#6304](https://github.com/paperless-ngx/paperless-ngx/pull/6304))
|
||||
- Fix: Use correct custom field id when splitting [@shamoon](https://github.com/shamoon) ([#6303](https://github.com/paperless-ngx/paperless-ngx/pull/6303))
|
||||
- Fix: Rotation fails due to celery chord [@stumpylog](https://github.com/stumpylog) ([#6306](https://github.com/paperless-ngx/paperless-ngx/pull/6306))
|
||||
- Fix: split user / group objects error [@shamoon](https://github.com/shamoon) ([#6302](https://github.com/paperless-ngx/paperless-ngx/pull/6302))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>4 changes</summary>
|
||||
|
||||
- Fix: Only disable split button if pages = 1 [@shamoon](https://github.com/shamoon) ([#6304](https://github.com/paperless-ngx/paperless-ngx/pull/6304))
|
||||
- Fix: Use correct custom field id when splitting [@shamoon](https://github.com/shamoon) ([#6303](https://github.com/paperless-ngx/paperless-ngx/pull/6303))
|
||||
- Fix: Rotation fails due to celery chord [@stumpylog](https://github.com/stumpylog) ([#6306](https://github.com/paperless-ngx/paperless-ngx/pull/6306))
|
||||
- Fix: split user / group objects error [@shamoon](https://github.com/shamoon) ([#6302](https://github.com/paperless-ngx/paperless-ngx/pull/6302))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.7.0
|
||||
|
||||
### Notable Changes
|
||||
|
||||
- Feature: PDF actions - merge, split \& rotate @shamoon ([#6094](https://github.com/paperless-ngx/paperless-ngx/pull/6094))
|
||||
- Change: enable auditlog by default, fix import / export @shamoon ([#6267](https://github.com/paperless-ngx/paperless-ngx/pull/6267))
|
||||
|
||||
### Enhancements
|
||||
|
||||
- Enhancement: always place search term first in autocomplete results @shamoon ([#6142](https://github.com/paperless-ngx/paperless-ngx/pull/6142))
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Chore: Standardize subprocess running and logging [@stumpylog](https://github.com/stumpylog) ([#6275](https://github.com/paperless-ngx/paperless-ngx/pull/6275))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: Escape the secret key when writing it to the env file [@stumpylog](https://github.com/stumpylog) ([#6243](https://github.com/paperless-ngx/paperless-ngx/pull/6243))
|
||||
- Fix: Hide sidebar labels if group is empty [@shamoon](https://github.com/shamoon) ([#6254](https://github.com/paperless-ngx/paperless-ngx/pull/6254))
|
||||
- Fix: management list clear all should clear header checkbox [@shamoon](https://github.com/shamoon) ([#6253](https://github.com/paperless-ngx/paperless-ngx/pull/6253))
|
||||
- Fix: start-align object names in some UI lists [@shamoon](https://github.com/shamoon) ([#6188](https://github.com/paperless-ngx/paperless-ngx/pull/6188))
|
||||
- Fix: allow scroll long upload files alerts list [@shamoon](https://github.com/shamoon) ([#6184](https://github.com/paperless-ngx/paperless-ngx/pull/6184))
|
||||
- Fix: document_renamer fails with audit_log enabled [@shamoon](https://github.com/shamoon) ([#6175](https://github.com/paperless-ngx/paperless-ngx/pull/6175))
|
||||
- Fix: catch sessionStorage errors for large documents [@shamoon](https://github.com/shamoon) ([#6150](https://github.com/paperless-ngx/paperless-ngx/pull/6150))
|
||||
|
||||
### Dependencies
|
||||
|
||||
<details>
|
||||
<summary>9 changes</summary>
|
||||
|
||||
- Chore(deps): Bump pillow from 10.2.0 to 10.3.0 [@dependabot](https://github.com/dependabot) ([#6268](https://github.com/paperless-ngx/paperless-ngx/pull/6268))
|
||||
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#6276](https://github.com/paperless-ngx/paperless-ngx/pull/6276))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 17 updates [@dependabot](https://github.com/dependabot) ([#6248](https://github.com/paperless-ngx/paperless-ngx/pull/6248))
|
||||
- Chore(deps-dev): Bump [@<!---->playwright/test from 1.42.0 to 1.42.1 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.42.0 to 1.42.1 in /src-ui @dependabot) ([#6250](https://github.com/paperless-ngx/paperless-ngx/pull/6250))
|
||||
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.24 to 20.12.2 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.24 to 20.12.2 in /src-ui @dependabot) ([#6251](https://github.com/paperless-ngx/paperless-ngx/pull/6251))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 2 updates [@dependabot](https://github.com/dependabot) ([#6249](https://github.com/paperless-ngx/paperless-ngx/pull/6249))
|
||||
- Chore(deps-dev): Bump express from 4.18.3 to 4.19.2 in /src-ui [@dependabot](https://github.com/dependabot) ([#6207](https://github.com/paperless-ngx/paperless-ngx/pull/6207))
|
||||
- Chore(deps-dev): Bump webpack-dev-middleware from 5.3.3 to 5.3.4 in /src-ui [@dependabot](https://github.com/dependabot) ([#6161](https://github.com/paperless-ngx/paperless-ngx/pull/6161))
|
||||
- Chore(deps-dev): Bump the development group with 4 updates [@dependabot](https://github.com/dependabot) ([#6131](https://github.com/paperless-ngx/paperless-ngx/pull/6131))
|
||||
</details>
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>20 changes</summary>
|
||||
|
||||
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#6276](https://github.com/paperless-ngx/paperless-ngx/pull/6276))
|
||||
- Chore: Standardize subprocess running and logging [@stumpylog](https://github.com/stumpylog) ([#6275](https://github.com/paperless-ngx/paperless-ngx/pull/6275))
|
||||
- Change: enable auditlog by default, fix import / export [@shamoon](https://github.com/shamoon) ([#6267](https://github.com/paperless-ngx/paperless-ngx/pull/6267))
|
||||
- Fix: Hide sidebar labels if group is empty [@shamoon](https://github.com/shamoon) ([#6254](https://github.com/paperless-ngx/paperless-ngx/pull/6254))
|
||||
- Fix: management list clear all should clear header checkbox [@shamoon](https://github.com/shamoon) ([#6253](https://github.com/paperless-ngx/paperless-ngx/pull/6253))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 17 updates [@dependabot](https://github.com/dependabot) ([#6248](https://github.com/paperless-ngx/paperless-ngx/pull/6248))
|
||||
- Chore(deps-dev): Bump [@<!---->playwright/test from 1.42.0 to 1.42.1 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.42.0 to 1.42.1 in /src-ui @dependabot) ([#6250](https://github.com/paperless-ngx/paperless-ngx/pull/6250))
|
||||
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.24 to 20.12.2 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.24 to 20.12.2 in /src-ui @dependabot) ([#6251](https://github.com/paperless-ngx/paperless-ngx/pull/6251))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 2 updates [@dependabot](https://github.com/dependabot) ([#6249](https://github.com/paperless-ngx/paperless-ngx/pull/6249))
|
||||
- Enhancement: support custom fields in post_document endpoint [@shamoon](https://github.com/shamoon) ([#6222](https://github.com/paperless-ngx/paperless-ngx/pull/6222))
|
||||
- Enhancement: add ASN to consume rejection message [@eliasp](https://github.com/eliasp) ([#6217](https://github.com/paperless-ngx/paperless-ngx/pull/6217))
|
||||
- Chore(deps-dev): Bump express from 4.18.3 to 4.19.2 in /src-ui [@dependabot](https://github.com/dependabot) ([#6207](https://github.com/paperless-ngx/paperless-ngx/pull/6207))
|
||||
- Feature: PDF actions - merge, split \& rotate [@shamoon](https://github.com/shamoon) ([#6094](https://github.com/paperless-ngx/paperless-ngx/pull/6094))
|
||||
- Fix: start-align object names in some UI lists [@shamoon](https://github.com/shamoon) ([#6188](https://github.com/paperless-ngx/paperless-ngx/pull/6188))
|
||||
- Fix: allow scroll long upload files alerts list [@shamoon](https://github.com/shamoon) ([#6184](https://github.com/paperless-ngx/paperless-ngx/pull/6184))
|
||||
- Fix: document_renamer fails with audit_log enabled [@shamoon](https://github.com/shamoon) ([#6175](https://github.com/paperless-ngx/paperless-ngx/pull/6175))
|
||||
- Chore(deps-dev): Bump webpack-dev-middleware from 5.3.3 to 5.3.4 in /src-ui [@dependabot](https://github.com/dependabot) ([#6161](https://github.com/paperless-ngx/paperless-ngx/pull/6161))
|
||||
- Enhancement: always place search term first in autocomplete results [@shamoon](https://github.com/shamoon) ([#6142](https://github.com/paperless-ngx/paperless-ngx/pull/6142))
|
||||
- Fix: catch sessionStorage errors for large documents [@shamoon](https://github.com/shamoon) ([#6150](https://github.com/paperless-ngx/paperless-ngx/pull/6150))
|
||||
- Chore(deps-dev): Bump the development group with 4 updates [@dependabot](https://github.com/dependabot) ([#6131](https://github.com/paperless-ngx/paperless-ngx/pull/6131))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.6.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: allow setting allauth [@shamoon](https://github.com/shamoon) ([#6105](https://github.com/paperless-ngx/paperless-ngx/pull/6105))
|
||||
- Change: dont require empty bulk edit parameters [@shamoon](https://github.com/shamoon) ([#6059](https://github.com/paperless-ngx/paperless-ngx/pull/6059))
|
||||
|
||||
### Dependencies
|
||||
|
||||
<details>
|
||||
<summary>4 changes</summary>
|
||||
|
||||
- Chore(deps-dev): Bump follow-redirects from 1.15.5 to 1.15.6 in /src-ui [@dependabot](https://github.com/dependabot) ([#6120](https://github.com/paperless-ngx/paperless-ngx/pull/6120))
|
||||
- Chore(deps-dev): Bump the development group with 3 updates [@dependabot](https://github.com/dependabot) ([#6079](https://github.com/paperless-ngx/paperless-ngx/pull/6079))
|
||||
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#6080](https://github.com/paperless-ngx/paperless-ngx/pull/6080))
|
||||
- Chore(deps): Bump the small-changes group with 2 updates [@dependabot](https://github.com/dependabot) ([#6081](https://github.com/paperless-ngx/paperless-ngx/pull/6081))
|
||||
</details>
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>8 changes</summary>
|
||||
|
||||
- Chore(deps-dev): Bump follow-redirects from 1.15.5 to 1.15.6 in /src-ui [@dependabot](https://github.com/dependabot) ([#6120](https://github.com/paperless-ngx/paperless-ngx/pull/6120))
|
||||
- Fix: allow setting allauth [@shamoon](https://github.com/shamoon) ([#6105](https://github.com/paperless-ngx/paperless-ngx/pull/6105))
|
||||
- Change: remove credentials from redis url in system status [@shamoon](https://github.com/shamoon) ([#6104](https://github.com/paperless-ngx/paperless-ngx/pull/6104))
|
||||
- Chore(deps-dev): Bump the development group with 3 updates [@dependabot](https://github.com/dependabot) ([#6079](https://github.com/paperless-ngx/paperless-ngx/pull/6079))
|
||||
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#6080](https://github.com/paperless-ngx/paperless-ngx/pull/6080))
|
||||
- Chore(deps): Bump the small-changes group with 2 updates [@dependabot](https://github.com/dependabot) ([#6081](https://github.com/paperless-ngx/paperless-ngx/pull/6081))
|
||||
- Change: dont require empty bulk edit parameters [@shamoon](https://github.com/shamoon) ([#6059](https://github.com/paperless-ngx/paperless-ngx/pull/6059))
|
||||
- Fix: missing translation string [@DimitriDR](https://github.com/DimitriDR) ([#6054](https://github.com/paperless-ngx/paperless-ngx/pull/6054))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.6.2
|
||||
|
||||
### Features
|
||||
|
||||
- Enhancement: move and rename files when storage paths deleted, update file handling docs [@shamoon](https://github.com/shamoon) ([#6033](https://github.com/paperless-ngx/paperless-ngx/pull/6033))
|
||||
- Enhancement: better detection of default currency code [@shamoon](https://github.com/shamoon) ([#6020](https://github.com/paperless-ngx/paperless-ngx/pull/6020))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: make document counts in object lists permissions-aware [@shamoon](https://github.com/shamoon) ([#6019](https://github.com/paperless-ngx/paperless-ngx/pull/6019))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>3 changes</summary>
|
||||
|
||||
- Enhancement: move and rename files when storage paths deleted, update file handling docs [@shamoon](https://github.com/shamoon) ([#6033](https://github.com/paperless-ngx/paperless-ngx/pull/6033))
|
||||
- Fix: make document counts in object lists permissions-aware [@shamoon](https://github.com/shamoon) ([#6019](https://github.com/paperless-ngx/paperless-ngx/pull/6019))
|
||||
- Enhancement: better detection of default currency code [@shamoon](https://github.com/shamoon) ([#6020](https://github.com/paperless-ngx/paperless-ngx/pull/6020))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.6.1
|
||||
|
||||
### All App Changes
|
||||
|
||||
- Change: tweaks to system status [@shamoon](https://github.com/shamoon) ([#6008](https://github.com/paperless-ngx/paperless-ngx/pull/6008))
|
||||
|
||||
## paperless-ngx 2.6.0
|
||||
|
||||
### Features
|
||||
|
||||
- Feature: Allow user to control PIL image pixel limit [@stumpylog](https://github.com/stumpylog) ([#5997](https://github.com/paperless-ngx/paperless-ngx/pull/5997))
|
||||
- Feature: Allow a user to disable the pixel limit for OCR entirely [@stumpylog](https://github.com/stumpylog) ([#5996](https://github.com/paperless-ngx/paperless-ngx/pull/5996))
|
||||
- Feature: workflow removal action [@shamoon](https://github.com/shamoon) ([#5928](https://github.com/paperless-ngx/paperless-ngx/pull/5928))
|
||||
- Feature: system status [@shamoon](https://github.com/shamoon) ([#5743](https://github.com/paperless-ngx/paperless-ngx/pull/5743))
|
||||
- Enhancement: better monetary field with currency code [@shamoon](https://github.com/shamoon) ([#5858](https://github.com/paperless-ngx/paperless-ngx/pull/5858))
|
||||
- Enhancement: support disabling regular login [@shamoon](https://github.com/shamoon) ([#5816](https://github.com/paperless-ngx/paperless-ngx/pull/5816))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: refactor base path settings, correct logout redirect [@shamoon](https://github.com/shamoon) ([#5976](https://github.com/paperless-ngx/paperless-ngx/pull/5976))
|
||||
- Fix: always pass from UI, dont require in API [@shamoon](https://github.com/shamoon) ([#5962](https://github.com/paperless-ngx/paperless-ngx/pull/5962))
|
||||
- Fix: Clear metadata cache when the filename(s) change [@stumpylog](https://github.com/stumpylog) ([#5957](https://github.com/paperless-ngx/paperless-ngx/pull/5957))
|
||||
- Fix: include monetary, float and doc link values in search filters [@shamoon](https://github.com/shamoon) ([#5951](https://github.com/paperless-ngx/paperless-ngx/pull/5951))
|
||||
- Fix: Better handling of a corrupted index [@stumpylog](https://github.com/stumpylog) ([#5950](https://github.com/paperless-ngx/paperless-ngx/pull/5950))
|
||||
- Fix: Don't assume the location of scratch directory in Docker [@stumpylog](https://github.com/stumpylog) ([#5948](https://github.com/paperless-ngx/paperless-ngx/pull/5948))
|
||||
- Fix: ensure document title always limited to 128 chars [@shamoon](https://github.com/shamoon) ([#5934](https://github.com/paperless-ngx/paperless-ngx/pull/5934))
|
||||
- Fix: use for password reset emails, if set [@shamoon](https://github.com/shamoon) ([#5902](https://github.com/paperless-ngx/paperless-ngx/pull/5902))
|
||||
- Fix: Correct docker compose check in install script [@ShanSanear](https://github.com/ShanSanear) ([#5917](https://github.com/paperless-ngx/paperless-ngx/pull/5917))
|
||||
- Fix: respect global permissions for UI settings [@shamoon](https://github.com/shamoon) ([#5919](https://github.com/paperless-ngx/paperless-ngx/pull/5919))
|
||||
- Fix: allow disable email verification during signup [@shamoon](https://github.com/shamoon) ([#5895](https://github.com/paperless-ngx/paperless-ngx/pull/5895))
|
||||
- Fix: refactor accounts templates and create signup template [@shamoon](https://github.com/shamoon) ([#5899](https://github.com/paperless-ngx/paperless-ngx/pull/5899))
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Chore(deps): Bump the actions group with 3 updates [@dependabot](https://github.com/dependabot) ([#5907](https://github.com/paperless-ngx/paperless-ngx/pull/5907))
|
||||
- Chore: Ignores uvicorn updates in dependabot [@stumpylog](https://github.com/stumpylog) ([#5906](https://github.com/paperless-ngx/paperless-ngx/pull/5906))
|
||||
|
||||
### Dependencies
|
||||
|
||||
<details>
|
||||
<summary>15 changes</summary>
|
||||
|
||||
- Chore(deps): Bump the small-changes group with 3 updates [@dependabot](https://github.com/dependabot) ([#6001](https://github.com/paperless-ngx/paperless-ngx/pull/6001))
|
||||
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#5998](https://github.com/paperless-ngx/paperless-ngx/pull/5998))
|
||||
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#6000](https://github.com/paperless-ngx/paperless-ngx/pull/6000))
|
||||
- Chore(deps-dev): Bump [@<!---->playwright/test from 1.41.2 to 1.42.0 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.41.2 to 1.42.0 in /src-ui @dependabot) ([#5964](https://github.com/paperless-ngx/paperless-ngx/pull/5964))
|
||||
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.20 to 20.11.24 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.20 to 20.11.24 in /src-ui @dependabot) ([#5965](https://github.com/paperless-ngx/paperless-ngx/pull/5965))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 11 updates [@dependabot](https://github.com/dependabot) ([#5963](https://github.com/paperless-ngx/paperless-ngx/pull/5963))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 3 updates [@dependabot](https://github.com/dependabot) ([#5918](https://github.com/paperless-ngx/paperless-ngx/pull/5918))
|
||||
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.16 to 20.11.20 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.16 to 20.11.20 in /src-ui @dependabot) ([#5912](https://github.com/paperless-ngx/paperless-ngx/pull/5912))
|
||||
- Chore(deps): Bump zone.js from 0.14.3 to 0.14.4 in /src-ui [@dependabot](https://github.com/dependabot) ([#5913](https://github.com/paperless-ngx/paperless-ngx/pull/5913))
|
||||
- Chore(deps): Bump bootstrap from 5.3.2 to 5.3.3 in /src-ui [@dependabot](https://github.com/dependabot) ([#5911](https://github.com/paperless-ngx/paperless-ngx/pull/5911))
|
||||
- Chore(deps-dev): Bump typescript from 5.2.2 to 5.3.3 in /src-ui [@dependabot](https://github.com/dependabot) ([#5915](https://github.com/paperless-ngx/paperless-ngx/pull/5915))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 15 updates [@dependabot](https://github.com/dependabot) ([#5908](https://github.com/paperless-ngx/paperless-ngx/pull/5908))
|
||||
- Chore(deps): Bump the small-changes group with 4 updates [@dependabot](https://github.com/dependabot) ([#5916](https://github.com/paperless-ngx/paperless-ngx/pull/5916))
|
||||
- Chore(deps-dev): Bump the development group with 4 updates [@dependabot](https://github.com/dependabot) ([#5914](https://github.com/paperless-ngx/paperless-ngx/pull/5914))
|
||||
- Chore(deps): Bump the actions group with 3 updates [@dependabot](https://github.com/dependabot) ([#5907](https://github.com/paperless-ngx/paperless-ngx/pull/5907))
|
||||
</details>
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>33 changes</summary>
|
||||
|
||||
- Feature: Allow user to control PIL image pixel limit [@stumpylog](https://github.com/stumpylog) ([#5997](https://github.com/paperless-ngx/paperless-ngx/pull/5997))
|
||||
- Enhancement: show ID when editing objects [@shamoon](https://github.com/shamoon) ([#6003](https://github.com/paperless-ngx/paperless-ngx/pull/6003))
|
||||
- Feature: Allow a user to disable the pixel limit for OCR entirely [@stumpylog](https://github.com/stumpylog) ([#5996](https://github.com/paperless-ngx/paperless-ngx/pull/5996))
|
||||
- Chore(deps): Bump the small-changes group with 3 updates [@dependabot](https://github.com/dependabot) ([#6001](https://github.com/paperless-ngx/paperless-ngx/pull/6001))
|
||||
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#5998](https://github.com/paperless-ngx/paperless-ngx/pull/5998))
|
||||
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#6000](https://github.com/paperless-ngx/paperless-ngx/pull/6000))
|
||||
- Feature: workflow removal action [@shamoon](https://github.com/shamoon) ([#5928](https://github.com/paperless-ngx/paperless-ngx/pull/5928))
|
||||
- Feature: system status [@shamoon](https://github.com/shamoon) ([#5743](https://github.com/paperless-ngx/paperless-ngx/pull/5743))
|
||||
- Fix: refactor base path settings, correct logout redirect [@shamoon](https://github.com/shamoon) ([#5976](https://github.com/paperless-ngx/paperless-ngx/pull/5976))
|
||||
- Chore(deps-dev): Bump [@<!---->playwright/test from 1.41.2 to 1.42.0 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.41.2 to 1.42.0 in /src-ui @dependabot) ([#5964](https://github.com/paperless-ngx/paperless-ngx/pull/5964))
|
||||
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.20 to 20.11.24 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.20 to 20.11.24 in /src-ui @dependabot) ([#5965](https://github.com/paperless-ngx/paperless-ngx/pull/5965))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 11 updates [@dependabot](https://github.com/dependabot) ([#5963](https://github.com/paperless-ngx/paperless-ngx/pull/5963))
|
||||
- Fix: always pass from UI, dont require in API [@shamoon](https://github.com/shamoon) ([#5962](https://github.com/paperless-ngx/paperless-ngx/pull/5962))
|
||||
- Fix: Clear metadata cache when the filename(s) change [@stumpylog](https://github.com/stumpylog) ([#5957](https://github.com/paperless-ngx/paperless-ngx/pull/5957))
|
||||
- Fix: include monetary, float and doc link values in search filters [@shamoon](https://github.com/shamoon) ([#5951](https://github.com/paperless-ngx/paperless-ngx/pull/5951))
|
||||
- Fix: Better handling of a corrupted index [@stumpylog](https://github.com/stumpylog) ([#5950](https://github.com/paperless-ngx/paperless-ngx/pull/5950))
|
||||
- Chore: Includes OCRMyPdf logging into the log file [@stumpylog](https://github.com/stumpylog) ([#5947](https://github.com/paperless-ngx/paperless-ngx/pull/5947))
|
||||
- Fix: ensure document title always limited to 128 chars [@shamoon](https://github.com/shamoon) ([#5934](https://github.com/paperless-ngx/paperless-ngx/pull/5934))
|
||||
- Enhancement: better monetary field with currency code [@shamoon](https://github.com/shamoon) ([#5858](https://github.com/paperless-ngx/paperless-ngx/pull/5858))
|
||||
- Change: add Thumbs.db to default ignores [@DennisGaida](https://github.com/DennisGaida) ([#5924](https://github.com/paperless-ngx/paperless-ngx/pull/5924))
|
||||
- Fix: use for password reset emails, if set [@shamoon](https://github.com/shamoon) ([#5902](https://github.com/paperless-ngx/paperless-ngx/pull/5902))
|
||||
- Fix: respect global permissions for UI settings [@shamoon](https://github.com/shamoon) ([#5919](https://github.com/paperless-ngx/paperless-ngx/pull/5919))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 3 updates [@dependabot](https://github.com/dependabot) ([#5918](https://github.com/paperless-ngx/paperless-ngx/pull/5918))
|
||||
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.16 to 20.11.20 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.16 to 20.11.20 in /src-ui @dependabot) ([#5912](https://github.com/paperless-ngx/paperless-ngx/pull/5912))
|
||||
- Chore(deps): Bump zone.js from 0.14.3 to 0.14.4 in /src-ui [@dependabot](https://github.com/dependabot) ([#5913](https://github.com/paperless-ngx/paperless-ngx/pull/5913))
|
||||
- Chore(deps): Bump bootstrap from 5.3.2 to 5.3.3 in /src-ui [@dependabot](https://github.com/dependabot) ([#5911](https://github.com/paperless-ngx/paperless-ngx/pull/5911))
|
||||
- Chore(deps-dev): Bump typescript from 5.2.2 to 5.3.3 in /src-ui [@dependabot](https://github.com/dependabot) ([#5915](https://github.com/paperless-ngx/paperless-ngx/pull/5915))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 15 updates [@dependabot](https://github.com/dependabot) ([#5908](https://github.com/paperless-ngx/paperless-ngx/pull/5908))
|
||||
- Fix: allow disable email verification during signup [@shamoon](https://github.com/shamoon) ([#5895](https://github.com/paperless-ngx/paperless-ngx/pull/5895))
|
||||
- Fix: refactor accounts templates and create signup template [@shamoon](https://github.com/shamoon) ([#5899](https://github.com/paperless-ngx/paperless-ngx/pull/5899))
|
||||
- Chore(deps): Bump the small-changes group with 4 updates [@dependabot](https://github.com/dependabot) ([#5916](https://github.com/paperless-ngx/paperless-ngx/pull/5916))
|
||||
- Chore(deps-dev): Bump the development group with 4 updates [@dependabot](https://github.com/dependabot) ([#5914](https://github.com/paperless-ngx/paperless-ngx/pull/5914))
|
||||
- Enhancement: support disabling regular login [@shamoon](https://github.com/shamoon) ([#5816](https://github.com/paperless-ngx/paperless-ngx/pull/5816))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.5.4
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: handle title placeholder for docs without original_filename [@shamoon](https://github.com/shamoon) ([#5828](https://github.com/paperless-ngx/paperless-ngx/pull/5828))
|
||||
- Fix: bulk edit objects does not respect global permissions [@shamoon](https://github.com/shamoon) ([#5888](https://github.com/paperless-ngx/paperless-ngx/pull/5888))
|
||||
- Fix: intermittent save \& close warnings [@shamoon](https://github.com/shamoon) ([#5838](https://github.com/paperless-ngx/paperless-ngx/pull/5838))
|
||||
- Fix: inotify read timeout not in ms [@grembo](https://github.com/grembo) ([#5876](https://github.com/paperless-ngx/paperless-ngx/pull/5876))
|
||||
- Fix: allow relative date queries not in quick list [@shamoon](https://github.com/shamoon) ([#5801](https://github.com/paperless-ngx/paperless-ngx/pull/5801))
|
||||
- Fix: pass rule id to consumed .eml files [@shamoon](https://github.com/shamoon) ([#5800](https://github.com/paperless-ngx/paperless-ngx/pull/5800))
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Chore(deps): Bump cryptography from 42.0.2 to 42.0.4 [@dependabot](https://github.com/dependabot) ([#5851](https://github.com/paperless-ngx/paperless-ngx/pull/5851))
|
||||
- Chore(deps-dev): Bump ip from 2.0.0 to 2.0.1 in /src-ui [@dependabot](https://github.com/dependabot) ([#5835](https://github.com/paperless-ngx/paperless-ngx/pull/5835))
|
||||
- Chore(deps): Bump undici and [@<!---->angular-devkit/build-angular in /src-ui @dependabot](https://github.com/<!---->angular-devkit/build-angular in /src-ui @dependabot) ([#5796](https://github.com/paperless-ngx/paperless-ngx/pull/5796))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>8 changes</summary>
|
||||
|
||||
- Fix: handle title placeholder for docs without original_filename [@shamoon](https://github.com/shamoon) ([#5828](https://github.com/paperless-ngx/paperless-ngx/pull/5828))
|
||||
- Fix: bulk edit objects does not respect global permissions [@shamoon](https://github.com/shamoon) ([#5888](https://github.com/paperless-ngx/paperless-ngx/pull/5888))
|
||||
- Fix: intermittent save \& close warnings [@shamoon](https://github.com/shamoon) ([#5838](https://github.com/paperless-ngx/paperless-ngx/pull/5838))
|
||||
- Fix: inotify read timeout not in ms [@grembo](https://github.com/grembo) ([#5876](https://github.com/paperless-ngx/paperless-ngx/pull/5876))
|
||||
- Chore(deps-dev): Bump ip from 2.0.0 to 2.0.1 in /src-ui [@dependabot](https://github.com/dependabot) ([#5835](https://github.com/paperless-ngx/paperless-ngx/pull/5835))
|
||||
- Chore(deps): Bump undici and [@<!---->angular-devkit/build-angular in /src-ui @dependabot](https://github.com/<!---->angular-devkit/build-angular in /src-ui @dependabot) ([#5796](https://github.com/paperless-ngx/paperless-ngx/pull/5796))
|
||||
- Fix: allow relative date queries not in quick list [@shamoon](https://github.com/shamoon) ([#5801](https://github.com/paperless-ngx/paperless-ngx/pull/5801))
|
||||
- Fix: pass rule id to consumed .eml files [@shamoon](https://github.com/shamoon) ([#5800](https://github.com/paperless-ngx/paperless-ngx/pull/5800))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.5.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: dont allow allauth redirects to any host [@shamoon](https://github.com/shamoon) ([#5783](https://github.com/paperless-ngx/paperless-ngx/pull/5783))
|
||||
- Fix: Interaction when both splitting and ASN are enabled [@stumpylog](https://github.com/stumpylog) ([#5779](https://github.com/paperless-ngx/paperless-ngx/pull/5779))
|
||||
- Fix: moved ssl_mode parameter for mysql backend engine [@MaciejSzczurek](https://github.com/MaciejSzczurek) ([#5771](https://github.com/paperless-ngx/paperless-ngx/pull/5771))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>3 changes</summary>
|
||||
|
||||
- Fix: dont allow allauth redirects to any host [@shamoon](https://github.com/shamoon) ([#5783](https://github.com/paperless-ngx/paperless-ngx/pull/5783))
|
||||
- Fix: Interaction when both splitting and ASN are enabled [@stumpylog](https://github.com/stumpylog) ([#5779](https://github.com/paperless-ngx/paperless-ngx/pull/5779))
|
||||
- Fix: moved ssl_mode parameter for mysql backend engine [@MaciejSzczurek](https://github.com/MaciejSzczurek) ([#5771](https://github.com/paperless-ngx/paperless-ngx/pull/5771))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.5.2
|
||||
|
||||
### Bug Fixes
|
||||
|
@@ -264,7 +264,7 @@ directory. See [File name handling](advanced_usage.md#file-name-handling) for de
|
||||
: Tells paperless to replace placeholders in
|
||||
`PAPERLESS_FILENAME_FORMAT` that would resolve to
|
||||
'none' to be omitted from the resulting filename. This also holds
|
||||
true for directory names. See [File name handling](advanced_usage.md#file-name-handling) for
|
||||
true for directory names. See [File name handling](advanced_usage.md#empty-placeholders) for
|
||||
details.
|
||||
|
||||
Defaults to `false` which disables this feature.
|
||||
@@ -491,8 +491,9 @@ followed by the normalized actual header name.
|
||||
#### [`PAPERLESS_LOGOUT_REDIRECT_URL=<str>`](#PAPERLESS_LOGOUT_REDIRECT_URL) {#PAPERLESS_LOGOUT_REDIRECT_URL}
|
||||
|
||||
: URL to redirect the user to after a logout. This can be used
|
||||
together with PAPERLESS_ENABLE_HTTP_REMOTE_USER to
|
||||
redirect the user back to the SSO application's logout page.
|
||||
together with PAPERLESS_ENABLE_HTTP_REMOTE_USER and SSO to
|
||||
redirect the user back to the SSO application's logout page to
|
||||
complete the logout process.
|
||||
|
||||
Defaults to None, which disables this feature.
|
||||
|
||||
@@ -539,7 +540,7 @@ This is for use with self-signed certificates against local IMAP servers.
|
||||
#### [`PAPERLESS_SOCIALACCOUNT_PROVIDERS=<json>`](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) {#PAPERLESS_SOCIALACCOUNT_PROVIDERS}
|
||||
|
||||
: This variable is used to setup login and signup via social account providers which are compatible with django-allauth.
|
||||
See the corresponding [django-allauth documentation](https://docs.allauth.org/en/0.60.0/socialaccount/providers/index.html)
|
||||
See the corresponding [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html)
|
||||
for a list of provider configurations. You will also need to include the relevant Django 'application' inside the
|
||||
[PAPERLESS_APPS](#PAPERLESS_APPS) setting to activate that specific authentication provider (e.g. `allauth.socialaccount.providers.openid_connect` for the [OIDC Connect provider](https://docs.allauth.org/en/latest/socialaccount/providers/openid_connect.html)).
|
||||
|
||||
@@ -549,7 +550,7 @@ for a list of provider configurations. You will also need to include the relevan
|
||||
|
||||
: Attempt to signup the user using retrieved email, username etc from the third party authentication
|
||||
system. See the corresponding
|
||||
[django-allauth documentation](https://docs.allauth.org/en/0.60.0/socialaccount/configuration.html)
|
||||
[django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/configuration.html)
|
||||
|
||||
Defaults to False
|
||||
|
||||
@@ -572,6 +573,28 @@ system. See the corresponding
|
||||
|
||||
Defaults to 'https'
|
||||
|
||||
#### [`PAPERLESS_ACCOUNT_EMAIL_VERIFICATION=<string>`](#PAPERLESS_ACCOUNT_EMAIL_VERIFICATION) {#PAPERLESS_ACCOUNT_EMAIL_VERIFICATION}
|
||||
|
||||
: Determines whether email addresses are verified during signup (as performed by Django allauth). See the relevant
|
||||
[paperless settings](#PAPERLESS_EMAIL_HOST) and [the allauth docs](https://docs.allauth.org/en/latest/account/configuration.html)
|
||||
|
||||
Defaults to 'optional'
|
||||
|
||||
!!! note
|
||||
|
||||
If you do not have a working email server set up you should set this to 'none'.
|
||||
|
||||
#### [`PAPERLESS_DISABLE_REGULAR_LOGIN=<bool>`](#PAPERLESS_DISABLE_REGULAR_LOGIN) {#PAPERLESS_DISABLE_REGULAR_LOGIN}
|
||||
|
||||
: Disables the regular frontend username / password login, i.e. once you have setup SSO. Note that this setting does not disable the Django admin login. To prevent logins directly to Django, consider blocking `/admin/` in your [web server or reverse proxy configuration](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx).
|
||||
|
||||
Defaults to False
|
||||
|
||||
#### [`PAPERLESS_ACCOUNT_SESSION_REMEMBER=<bool>`](#PAPERLESS_ACCOUNT_SESSION_REMEMBER) {#PAPERLESS_ACCOUNT_SESSION_REMEMBER}
|
||||
|
||||
: See the corresponding
|
||||
[django-allauth documentation](https://docs.allauth.org/en/latest/account/configuration.html)
|
||||
|
||||
## OCR settings {#ocr}
|
||||
|
||||
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)
|
||||
@@ -749,6 +772,8 @@ but could result in missing text content.
|
||||
If unset, will default to the value determined by
|
||||
[Pillow](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.MAX_IMAGE_PIXELS).
|
||||
|
||||
Setting this value to 0 will entirely disable the limit. See the below warning.
|
||||
|
||||
!!! note
|
||||
|
||||
Increasing this limit could cause Paperless to consume additional
|
||||
@@ -758,7 +783,7 @@ but could result in missing text content.
|
||||
!!! warning
|
||||
|
||||
The limit is intended to prevent malicious files from consuming
|
||||
system resources and causing crashes and other errors. Only increase
|
||||
system resources and causing crashes and other errors. Only change
|
||||
this value if you are certain your documents are not malicious and
|
||||
you need the text which was not OCRed
|
||||
|
||||
@@ -950,6 +975,20 @@ be used with caution!
|
||||
|
||||
Defaults to None, which does not add any additional apps.
|
||||
|
||||
#### [`PAPERLESS_MAX_IMAGE_PIXELS=<number>`](#PAPERLESS_MAX_IMAGE_PIXELS) {#PAPERLESS_MAX_IMAGE_PIXELS}
|
||||
|
||||
: Configures the maximum size of an image PIL will allow to load without warning or error.
|
||||
|
||||
: If unset, will default to the value determined by
|
||||
[Pillow](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.MAX_IMAGE_PIXELS).
|
||||
|
||||
Defaults to None, which does change the limit
|
||||
|
||||
!!! warning
|
||||
|
||||
This limit is designed to prevent denial of service from malicious files.
|
||||
It should only be raised or disabled in certain circumstances and with great care.
|
||||
|
||||
## Document Consumption {#consume_config}
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}
|
||||
@@ -997,7 +1036,7 @@ or hidden folders some tools use to store data.
|
||||
`._foo.pdf` and `._bar/foo.pdf`
|
||||
|
||||
Defaults to
|
||||
`[".DS_STORE/*", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini", "@eaDir/*"]`.
|
||||
`[".DS_Store", ".DS_STORE", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini", "@eaDir/*", "Thumbs.db"]`.
|
||||
|
||||
#### [`PAPERLESS_CONSUMER_BARCODE_SCANNER=<string>`](#PAPERLESS_CONSUMER_BARCODE_SCANNER) {#PAPERLESS_CONSUMER_BARCODE_SCANNER}
|
||||
|
||||
@@ -1273,11 +1312,9 @@ assigns or creates tags if a properly formatted barcode is detected.
|
||||
|
||||
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
|
||||
|
||||
: Enables an audit trail for documents, document types, correspondents, and tags. Log entries can be viewed in the Django backend only.
|
||||
: Enables the audit trail for documents, document types, correspondents, and tags.
|
||||
|
||||
!!! warning
|
||||
|
||||
Once enabled cannot be disabled
|
||||
Defaults to true.
|
||||
|
||||
## Collate Double-Sided Documents {#collate}
|
||||
|
||||
|
104
docs/usage.md
104
docs/usage.md
@@ -206,12 +206,12 @@ for details.
|
||||
|
||||
## Permissions
|
||||
|
||||
As of version 1.14.0 Paperless-ngx added core support for user / group permissions. Permissions is
|
||||
based around 'global' permissions as well as 'object-level' permissions. Global permissions designate
|
||||
which parts of the application a user can access (e.g. Documents, Tags, Settings) and object-level
|
||||
determine which objects are visible or editable. All objects have an 'owner' and 'view' and 'edit'
|
||||
permissions which can be granted to other users or groups. The paperless-ngx permissions system uses
|
||||
the built-in user model of the backend framework, Django.
|
||||
Permissions in Paperless-ngx are based around ['global' permissions](#global-permissions) as well as
|
||||
['object-level' permissions](#object-permissions). Global permissions determine which parts of the
|
||||
application a user can access (e.g. Documents, Tags, Settings) and object-level determine which
|
||||
objects are visible or editable. All objects have an 'owner' and 'view' and 'edit' permissions which
|
||||
can be granted to other users or groups. The paperless-ngx permissions system uses the built-in user
|
||||
model of the backend framework, Django.
|
||||
|
||||
!!! tip
|
||||
|
||||
@@ -219,41 +219,68 @@ the built-in user model of the backend framework, Django.
|
||||
for a Tag will _not_ affect the permissions of documents that have the Tag.
|
||||
|
||||
Permissions can be set using the new "Permissions" tab when editing documents, or bulk-applied
|
||||
in the UI by selecting documents and choosing the "Permissions" button. Owner can also optionally
|
||||
be set for documents uploaded via the API. Documents consumed via the consumption dir currently
|
||||
do not have an owner set.
|
||||
|
||||
!!! note
|
||||
|
||||
After migration to version 1.14.0 all existing documents, tags etc. will have no explicit owner
|
||||
set which means they will be visible / editable by all users. Once an object has an owner set,
|
||||
only the owner can explicitly grant / revoke permissions.
|
||||
|
||||
!!! note
|
||||
|
||||
When first migrating to permissions it is recommended to use a 'superuser' account (which
|
||||
would usually have been setup during installation) to ensure you have full permissions.
|
||||
|
||||
Note that superusers have access to all objects.
|
||||
in the UI by selecting documents and choosing the "Permissions" button.
|
||||
|
||||
### Default permissions
|
||||
|
||||
Default permissions for documents can be set using workflows.
|
||||
[Workflows](#workflows) provide advanced ways to control permissions.
|
||||
|
||||
For objects created via the web UI (tags, doc types, etc.) the default is to set the current user
|
||||
as owner and no extra permissions, but you explicitly set these under Settings > Permissions.
|
||||
as owner and no extra permissions, but you can explicitly set these under Settings > Permissions.
|
||||
|
||||
Documents consumed via the consumption directory do not have an owner or additional permissions set by default, but again, can be controlled with [Workflows](#workflows).
|
||||
|
||||
### Users and Groups
|
||||
|
||||
Paperless-ngx versions after 1.14.0 allow creating and editing users and groups via the 'frontend' UI.
|
||||
These can be found under Settings > Users & Groups, assuming the user has access. If a user is designated
|
||||
Paperless-ngx supports editing users and groups via the 'frontend' UI, which can be found under
|
||||
Settings > Users & Groups, assuming the user has access. If a user is designated
|
||||
as a member of a group those permissions will be inherited and this is reflected in the UI. Explicit
|
||||
permissions can be granted to limit access to certain parts of the UI (and corresponding API endpoints).
|
||||
|
||||
!!! note
|
||||
|
||||
Superusers can access all parts of the front and backend application as well as any and all objects.
|
||||
|
||||
#### Detailed Explanation of Global Permissions {#global-permissions}
|
||||
|
||||
Global permissions define what areas of the app and API endpoints the user can access. For example, they
|
||||
determine if a user can create, edit, delete or view _any_ documents, but individual documents themselves
|
||||
still have "object-level" permissions.
|
||||
|
||||
| Type | Details |
|
||||
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Admin | _View_ or higher permissions grants access to the logs view as well as the system status. |
|
||||
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
|
||||
| Correspondent | Grants global permissions to add, edit, delete or view Correspondents. |
|
||||
| CustomField | Grants global permissions to add, edit, delete or view Custom Fields. |
|
||||
| Document | Grants global permissions to add, edit, delete or view Documents. |
|
||||
| DocumentType | Grants global permissions to add, edit, delete or view Document Types. |
|
||||
| Group | Grants global permissions to add, edit, delete or view Groups. |
|
||||
| MailAccount | Grants global permissions to add, edit, delete or view Mail Accounts. |
|
||||
| MailRule | Grants global permissions to add, edit, delete or view Mail Rules. |
|
||||
| Note | Grants global permissions to add, edit, delete or view Notes. |
|
||||
| PaperlessTask | Grants global permissions to view or dismiss (_Change_) File Tasks. |
|
||||
| SavedView | Grants global permissions to add, edit, delete or view Saved Views. |
|
||||
| ShareLink | Grants global permissions to add, delete or view Share Links. |
|
||||
| StoragePath | Grants global permissions to add, edit, delete or view Storage Paths. |
|
||||
| Tag | Grants global permissions to add, edit, delete or view Tags. |
|
||||
| UISettings | Grants global permissions to add, edit, delete or view the UI settings that are used by the web app.<br/>Users expected to access the web UI should usually be granted at least _View_ permissions. |
|
||||
| User | Grants global permissions to add, edit, delete or view Users. |
|
||||
| Workflow | Grants global permissions to add, edit, delete or view Workflows.<br/>Note that Workflows are global, in other words all users who can access workflows have access to the same set of them. |
|
||||
|
||||
#### Detailed Explanation of Object Permissions {#object-permissions}
|
||||
|
||||
| Type | Details |
|
||||
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Owner | By default objects are only visible and editable by their owner.<br/>Only the object owner can grant permissions to other users or groups.<br/>Additionally, only document owners can create share links and add / remove custom fields.<br/>For backwards compatibility objects can have no owner which makes them visible to any user. |
|
||||
| View | Confers the ability to view (not edit) a document, tag, etc.<br/>Users without 'view' (or higher) permissions will be shown _'Private'_ in place of the object name for example when viewing a document with a tag for which the user doesn't have permissions. |
|
||||
| Edit | Confers the ability to edit (and view) a document, tag, etc. |
|
||||
|
||||
### Password reset
|
||||
|
||||
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
||||
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST)
|
||||
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
|
||||
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
|
||||
|
||||
## Workflows
|
||||
|
||||
@@ -328,7 +355,7 @@ Workflows allow you to filter by:
|
||||
|
||||
### Workflow Actions
|
||||
|
||||
There is currently one type of workflow action, "Assignment", which can assign:
|
||||
There are currently two types of workflow actions, "Assignment", which can assign:
|
||||
|
||||
- Title, see [title placeholders](usage.md#title-placeholders) below
|
||||
- Tags, correspondent, document type and storage path
|
||||
@@ -336,6 +363,13 @@ There is currently one type of workflow action, "Assignment", which can assign:
|
||||
- View and / or edit permissions to users or groups
|
||||
- Custom fields. Note that no value for the field will be set
|
||||
|
||||
and "Removal" actions, which can remove either all of or specific sets of the following:
|
||||
|
||||
- Tags, correspondents, document types or storage paths
|
||||
- Document owner
|
||||
- View and / or edit permissions
|
||||
- Custom fields
|
||||
|
||||
#### Title placeholders
|
||||
|
||||
Workflow titles can include placeholders but the available options differ depending on the type of
|
||||
@@ -406,7 +440,7 @@ The following custom field types are supported:
|
||||
- `URL`: a valid url
|
||||
- `Integer`: integer number e.g. 12
|
||||
- `Number`: float number e.g. 12.3456
|
||||
- `Monetary`: float number with exactly two decimals, e.g. 12.30
|
||||
- `Monetary`: [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes) and a number with exactly two decimals, e.g. USD12.30
|
||||
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
||||
|
||||
## Share Links
|
||||
@@ -422,6 +456,18 @@ Paperless-ngx added the ability to create shareable links to files in version 2.
|
||||
|
||||
If your paperless-ngx instance is behind a reverse-proxy you may want to create an exception to bypass any authentication layers that are part of your setup in order to make links truly publicly-accessible. Of course, do so with caution.
|
||||
|
||||
## PDF Actions
|
||||
|
||||
Paperless-ngx supports 3 basic editing operations for PDFs (these operations cannot be performed on non-PDF files):
|
||||
|
||||
- Merging documents: available when selecting multiple documents for 'bulk editing'
|
||||
- Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page.
|
||||
- Splitting documents: available from an individual document's details page
|
||||
|
||||
!!! important
|
||||
|
||||
Note that rotation alters the Paperless-ngx _original_ file, which would, for example, invalidate a digital signature.
|
||||
|
||||
## Best practices {#basic-searching}
|
||||
|
||||
Paperless offers a couple tools that help you organize your document
|
||||
|
@@ -56,8 +56,8 @@ if ! command -v docker &> /dev/null ; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker compose &> /dev/null ; then
|
||||
echo "docker compose executable not found. Is docker compose installed?"
|
||||
if ! docker compose &> /dev/null ; then
|
||||
echo "docker compose plugin not found. Is docker compose installed?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -335,7 +335,7 @@ read -r -a OCR_LANGUAGES_ARRAY <<< "${_split_langs}"
|
||||
fi
|
||||
echo "PAPERLESS_TIME_ZONE=$TIME_ZONE"
|
||||
echo "PAPERLESS_OCR_LANGUAGE=$OCR_LANGUAGE"
|
||||
echo "PAPERLESS_SECRET_KEY=$SECRET_KEY"
|
||||
echo "PAPERLESS_SECRET_KEY='$SECRET_KEY'"
|
||||
if [[ ! ${DEFAULT_LANGUAGES[*]} =~ ${OCR_LANGUAGES_ARRAY[*]} ]] ; then
|
||||
echo "PAPERLESS_OCR_LANGUAGES=${OCR_LANGUAGES_ARRAY[*]}"
|
||||
fi
|
||||
|
@@ -77,7 +77,9 @@
|
||||
"scripts": [],
|
||||
"allowedCommonJsDependencies": [
|
||||
"pdfjs-dist",
|
||||
"pdfjs-dist/web/pdf_viewer"
|
||||
"pdfjs-dist/web/pdf_viewer",
|
||||
"filesize",
|
||||
"file-saver"
|
||||
],
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
|
@@ -2700,7 +2700,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2734,7 +2734,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2768,7 +2768,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2802,7 +2802,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2836,7 +2836,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2870,7 +2870,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2904,7 +2904,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2938,7 +2938,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2972,7 +2972,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3006,7 +3006,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3040,7 +3040,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3074,7 +3074,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3108,7 +3108,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3142,7 +3142,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3176,7 +3176,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3210,7 +3210,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3244,7 +3244,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3278,7 +3278,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -3312,7 +3312,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
|
@@ -425,7 +425,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -470,7 +470,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -645,7 +645,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -685,7 +685,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -729,7 +729,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
|
@@ -843,7 +843,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -994,7 +994,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
|
@@ -996,7 +996,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1301,7 +1301,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1484,7 +1484,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1518,7 +1518,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1552,7 +1552,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1586,7 +1586,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1620,7 +1620,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1654,7 +1654,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1688,7 +1688,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1722,7 +1722,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1756,7 +1756,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1790,7 +1790,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1824,7 +1824,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1858,7 +1858,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1892,7 +1892,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1926,7 +1926,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1960,7 +1960,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -1994,7 +1994,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2028,7 +2028,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2062,7 +2062,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2096,7 +2096,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2130,7 +2130,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2164,7 +2164,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2198,7 +2198,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2232,7 +2232,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2266,7 +2266,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2300,7 +2300,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2334,7 +2334,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2368,7 +2368,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2402,7 +2402,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2436,7 +2436,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
@@ -2470,7 +2470,7 @@
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"status": -1,
|
||||
"statusText": "",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
|
1581
src-ui/messages.xlf
1581
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
3024
src-ui/package-lock.json
generated
3024
src-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,57 +11,58 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^17.1.2",
|
||||
"@angular/common": "~17.1.2",
|
||||
"@angular/compiler": "~17.1.2",
|
||||
"@angular/core": "~17.1.2",
|
||||
"@angular/forms": "~17.1.2",
|
||||
"@angular/localize": "~17.1.2",
|
||||
"@angular/platform-browser": "~17.1.2",
|
||||
"@angular/platform-browser-dynamic": "~17.1.2",
|
||||
"@angular/router": "~17.1.2",
|
||||
"@angular/cdk": "^17.3.2",
|
||||
"@angular/common": "~17.3.2",
|
||||
"@angular/compiler": "~17.3.2",
|
||||
"@angular/core": "~17.3.2",
|
||||
"@angular/forms": "~17.3.2",
|
||||
"@angular/localize": "~17.3.2",
|
||||
"@angular/platform-browser": "~17.3.2",
|
||||
"@angular/platform-browser-dynamic": "~17.3.2",
|
||||
"@angular/router": "~17.3.2",
|
||||
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
||||
"@ng-select/ng-select": "^12.0.6",
|
||||
"@ng-select/ng-select": "^12.0.7",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.2",
|
||||
"bootstrap": "^5.3.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"mime-names": "^1.0.0",
|
||||
"ngx-bootstrap-icons": "^1.9.3",
|
||||
"ngx-color": "^9.0.0",
|
||||
"ngx-cookie-service": "^17.0.1",
|
||||
"ngx-cookie-service": "^17.1.0",
|
||||
"ngx-file-drop": "^16.0.0",
|
||||
"ngx-filesize": "^3.0.3",
|
||||
"ngx-ui-tour-ng-bootstrap": "^14.0.2",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"rxjs": "^7.8.1",
|
||||
"tslib": "^2.6.2",
|
||||
"uuid": "^9.0.1",
|
||||
"zone.js": "^0.14.3"
|
||||
"zone.js": "^0.14.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/jest": "17.0.0",
|
||||
"@angular-devkit/build-angular": "~17.1.2",
|
||||
"@angular-eslint/builder": "17.2.1",
|
||||
"@angular-eslint/eslint-plugin": "17.2.1",
|
||||
"@angular-eslint/eslint-plugin-template": "17.2.1",
|
||||
"@angular-eslint/schematics": "17.2.1",
|
||||
"@angular-eslint/template-parser": "17.2.1",
|
||||
"@angular/cli": "~17.1.2",
|
||||
"@angular/compiler-cli": "~17.1.2",
|
||||
"@playwright/test": "^1.41.2",
|
||||
"@angular-builders/jest": "17.0.2",
|
||||
"@angular-devkit/build-angular": "~17.3.2",
|
||||
"@angular-eslint/builder": "17.3.0",
|
||||
"@angular-eslint/eslint-plugin": "17.3.0",
|
||||
"@angular-eslint/eslint-plugin-template": "17.3.0",
|
||||
"@angular-eslint/schematics": "17.3.0",
|
||||
"@angular-eslint/template-parser": "17.3.0",
|
||||
"@angular/cli": "~17.3.2",
|
||||
"@angular/compiler-cli": "~17.3.2",
|
||||
"@playwright/test": "^1.42.1",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.11.16",
|
||||
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
||||
"@typescript-eslint/parser": "^6.20.0",
|
||||
"@types/node": "^20.12.2",
|
||||
"@typescript-eslint/eslint-plugin": "^7.4.0",
|
||||
"@typescript-eslint/parser": "^7.4.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint": "^8.57.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-preset-angular": "^14.0.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"ts-node": "~10.9.1",
|
||||
"typescript": "^5.2.2",
|
||||
"typescript": "^5.3.3",
|
||||
"wait-on": "^7.2.0"
|
||||
}
|
||||
}
|
||||
|
@@ -76,7 +76,10 @@ const mock = () => {
|
||||
let storage: { [key: string]: string } = {}
|
||||
return {
|
||||
getItem: (key: string) => (key in storage ? storage[key] : null),
|
||||
setItem: (key: string, value: string) => (storage[key] = value || ''),
|
||||
setItem: (key: string, value: string) => {
|
||||
if (value.length > 1000000) throw new Error('localStorage overflow')
|
||||
storage[key] = value || ''
|
||||
},
|
||||
removeItem: (key: string) => delete storage[key],
|
||||
clear: () => (storage = {}),
|
||||
}
|
||||
@@ -94,6 +97,10 @@ Object.defineProperty(navigator, 'clipboard', {
|
||||
})
|
||||
Object.defineProperty(navigator, 'canShare', { value: () => true })
|
||||
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { reload: jest.fn() },
|
||||
})
|
||||
|
||||
HTMLCanvasElement.prototype.getContext = <
|
||||
typeof HTMLCanvasElement.prototype.getContext
|
||||
|
@@ -163,7 +163,7 @@ export const routes: Routes = [
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
action: PermissionAction.Change,
|
||||
type: PermissionType.UISettings,
|
||||
},
|
||||
},
|
||||
|
@@ -113,8 +113,16 @@ import { ConfigComponent } from './components/admin/config/config.component'
|
||||
import { FileComponent } from './components/common/input/file/file.component'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { ConfirmButtonComponent } from './components/common/confirm-button/confirm-button.component'
|
||||
import { MonetaryComponent } from './components/common/input/monetary/monetary.component'
|
||||
import { SystemStatusDialogComponent } from './components/common/system-status-dialog/system-status-dialog.component'
|
||||
import { NgxFilesizeModule } from 'ngx-filesize'
|
||||
import { RotateConfirmDialogComponent } from './components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
import { MergeConfirmDialogComponent } from './components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||
import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
||||
import {
|
||||
airplane,
|
||||
archive,
|
||||
arrowClockwise,
|
||||
arrowCounterclockwise,
|
||||
arrowDown,
|
||||
arrowLeft,
|
||||
@@ -123,17 +131,20 @@ import {
|
||||
arrowRightShort,
|
||||
arrowUpRight,
|
||||
asterisk,
|
||||
bodyText,
|
||||
boxArrowUp,
|
||||
boxArrowUpRight,
|
||||
boxes,
|
||||
calendar,
|
||||
calendarEvent,
|
||||
cardChecklist,
|
||||
caretDown,
|
||||
caretUp,
|
||||
chatLeftText,
|
||||
check,
|
||||
check2All,
|
||||
checkAll,
|
||||
checkCircleFill,
|
||||
checkLg,
|
||||
chevronDoubleLeft,
|
||||
chevronDoubleRight,
|
||||
@@ -147,7 +158,9 @@ import {
|
||||
doorOpen,
|
||||
download,
|
||||
envelope,
|
||||
exclamationCircleFill,
|
||||
exclamationTriangle,
|
||||
exclamationTriangleFill,
|
||||
eye,
|
||||
fileEarmark,
|
||||
fileEarmarkCheck,
|
||||
@@ -166,6 +179,7 @@ import {
|
||||
hddStack,
|
||||
house,
|
||||
infoCircle,
|
||||
journals,
|
||||
link,
|
||||
listTask,
|
||||
listUl,
|
||||
@@ -180,6 +194,7 @@ import {
|
||||
plus,
|
||||
plusCircle,
|
||||
questionCircle,
|
||||
scissors,
|
||||
search,
|
||||
slashCircle,
|
||||
sliders2Vertical,
|
||||
@@ -199,7 +214,9 @@ import {
|
||||
} from 'ngx-bootstrap-icons'
|
||||
|
||||
const icons = {
|
||||
airplane,
|
||||
archive,
|
||||
arrowClockwise,
|
||||
arrowCounterclockwise,
|
||||
arrowDown,
|
||||
arrowLeft,
|
||||
@@ -208,17 +225,20 @@ const icons = {
|
||||
arrowRightShort,
|
||||
arrowUpRight,
|
||||
asterisk,
|
||||
bodyText,
|
||||
boxArrowUp,
|
||||
boxArrowUpRight,
|
||||
boxes,
|
||||
calendar,
|
||||
calendarEvent,
|
||||
cardChecklist,
|
||||
caretDown,
|
||||
caretUp,
|
||||
chatLeftText,
|
||||
check,
|
||||
check2All,
|
||||
checkAll,
|
||||
checkCircleFill,
|
||||
checkLg,
|
||||
chevronDoubleLeft,
|
||||
chevronDoubleRight,
|
||||
@@ -232,7 +252,9 @@ const icons = {
|
||||
doorOpen,
|
||||
download,
|
||||
envelope,
|
||||
exclamationCircleFill,
|
||||
exclamationTriangle,
|
||||
exclamationTriangleFill,
|
||||
eye,
|
||||
fileEarmark,
|
||||
fileEarmarkCheck,
|
||||
@@ -251,6 +273,7 @@ const icons = {
|
||||
hddStack,
|
||||
house,
|
||||
infoCircle,
|
||||
journals,
|
||||
link,
|
||||
listTask,
|
||||
listUl,
|
||||
@@ -265,6 +288,7 @@ const icons = {
|
||||
plus,
|
||||
plusCircle,
|
||||
questionCircle,
|
||||
scissors,
|
||||
search,
|
||||
slashCircle,
|
||||
sliders2Vertical,
|
||||
@@ -443,6 +467,11 @@ function initializeApp(settings: SettingsService) {
|
||||
ConfigComponent,
|
||||
FileComponent,
|
||||
ConfirmButtonComponent,
|
||||
MonetaryComponent,
|
||||
SystemStatusDialogComponent,
|
||||
RotateConfirmDialogComponent,
|
||||
MergeConfirmDialogComponent,
|
||||
SplitConfirmDialogComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
@@ -457,6 +486,7 @@ function initializeApp(settings: SettingsService) {
|
||||
TourNgBootstrapModule,
|
||||
DragDropModule,
|
||||
NgxBootstrapIconsModule.pick(icons),
|
||||
NgxFilesizeModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
@@ -4,10 +4,31 @@
|
||||
info="Options to customize appearance, notifications, saved views and more. Settings apply to the <strong>current user only</strong>."
|
||||
i18n-info
|
||||
>
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()"><ng-container i18n>Start tour</ng-container></button>
|
||||
<a *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }" class="btn btn-sm btn-primary ms-3" href="admin/" target="_blank">
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()">
|
||||
<i-bs class="me-1" name="airplane"></i-bs> <ng-container i18n>Start tour</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
|
||||
[disabled]="!systemStatus"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
|
||||
@if (!systemStatus) {
|
||||
<div class="spinner-border spinner-border-sm me-1 h-75" role="status"></div>
|
||||
} @else {
|
||||
<i-bs class="me-2" name="card-checklist"></i-bs>
|
||||
@if (systemStatusHasErrors) {
|
||||
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
|
||||
<i-bs name="exclamation-circle-fill" class="text-danger" width="1.75em" height="1.75em"></i-bs>
|
||||
</span>
|
||||
} @else {
|
||||
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
|
||||
<i-bs name="check-circle-fill" class="text-primary" width="1.75em" height="1.75em"></i-bs>
|
||||
</span>
|
||||
}
|
||||
}
|
||||
<ng-container i18n>System Status</ng-container>
|
||||
</button>
|
||||
<a *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }" class="btn btn-sm btn-primary" href="admin/" target="_blank">
|
||||
<ng-container i18n>Open Django Admin</ng-container>
|
||||
<i-bs name="arrow-up-right"></i-bs>
|
||||
<i-bs name="arrow-up-right"></i-bs>
|
||||
</a>
|
||||
</pngx-page-header>
|
||||
|
||||
@@ -351,5 +372,5 @@
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
|
||||
|
||||
<button type="submit" class="btn btn-primary mb-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
||||
<button type="submit" class="btn btn-primary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
||||
</form>
|
||||
|
@@ -9,6 +9,8 @@ import {
|
||||
NgbModule,
|
||||
NgbAlertModule,
|
||||
NgbNavLink,
|
||||
NgbModal,
|
||||
NgbModalModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of, throwError } from 'rxjs'
|
||||
@@ -39,6 +41,13 @@ import { SettingsComponent } from './settings.component'
|
||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
|
||||
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||
import {
|
||||
SystemStatus,
|
||||
InstallType,
|
||||
SystemStatusItemStatus,
|
||||
} from 'src/app/data/system-status'
|
||||
|
||||
const savedViews = [
|
||||
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
|
||||
@@ -65,6 +74,8 @@ describe('SettingsComponent', () => {
|
||||
let userService: UserService
|
||||
let permissionsService: PermissionsService
|
||||
let groupService: GroupService
|
||||
let modalService: NgbModal
|
||||
let systemStatusService: SystemStatusService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -96,6 +107,7 @@ describe('SettingsComponent', () => {
|
||||
NgbAlertModule,
|
||||
NgSelectModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
NgbModalModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
@@ -107,6 +119,8 @@ describe('SettingsComponent', () => {
|
||||
settingsService.currentUser = users[0]
|
||||
userService = TestBed.inject(UserService)
|
||||
permissionsService = TestBed.inject(PermissionsService)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
systemStatusService = TestBed.inject(SystemStatusService)
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||
@@ -309,10 +323,15 @@ describe('SettingsComponent', () => {
|
||||
component.store.getValue()['displayLanguage'] = 'en-US'
|
||||
component.store.getValue()['updateCheckingEnabled'] = false
|
||||
component.settingsForm.value.displayLanguage = 'en-GB'
|
||||
component.settingsForm.value.updateCheckingEnabled = true
|
||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValueOnce(of(true))
|
||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||
component.saveSettings()
|
||||
expect(toast.actionName).toEqual('Reload now')
|
||||
|
||||
component.settingsForm.value.updateCheckingEnabled = true
|
||||
component.saveSettings()
|
||||
|
||||
expect(toast.actionName).toEqual('Reload now')
|
||||
toast.action()
|
||||
})
|
||||
|
||||
it('should allow setting theme color, visually apply change immediately but not save', () => {
|
||||
@@ -367,4 +386,54 @@ describe('SettingsComponent', () => {
|
||||
fixture.detectChanges()
|
||||
expect(toastErrorSpy).toBeCalled()
|
||||
})
|
||||
|
||||
it('should load system status on initialize, show errors if needed', () => {
|
||||
const status: SystemStatus = {
|
||||
pngx_version: '2.4.3',
|
||||
server_os: 'macOS-14.1.1-arm64-arm-64bit',
|
||||
install_type: InstallType.BareMetal,
|
||||
storage: { total: 494384795648, available: 13573525504 },
|
||||
database: {
|
||||
type: 'sqlite',
|
||||
url: '/paperless-ngx/data/db.sqlite3',
|
||||
status: SystemStatusItemStatus.ERROR,
|
||||
error: null,
|
||||
migration_status: {
|
||||
latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data',
|
||||
unapplied_migrations: [],
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
redis_url: 'redis://localhost:6379',
|
||||
redis_status: SystemStatusItemStatus.ERROR,
|
||||
redis_error:
|
||||
'Error 61 connecting to localhost:6379. Connection refused.',
|
||||
celery_status: SystemStatusItemStatus.ERROR,
|
||||
index_status: SystemStatusItemStatus.OK,
|
||||
index_last_modified: new Date().toISOString(),
|
||||
index_error: null,
|
||||
classifier_status: SystemStatusItemStatus.OK,
|
||||
classifier_last_trained: new Date().toISOString(),
|
||||
classifier_error: null,
|
||||
},
|
||||
}
|
||||
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
||||
completeSetup()
|
||||
expect(component['systemStatus']).toEqual(status) // private
|
||||
expect(component.systemStatusHasErrors).toBeTruthy()
|
||||
// coverage
|
||||
component['systemStatus'].database.status = SystemStatusItemStatus.OK
|
||||
component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK
|
||||
component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK
|
||||
expect(component.systemStatusHasErrors).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should open system status dialog', () => {
|
||||
const modalOpenSpy = jest.spyOn(modalService, 'open')
|
||||
completeSetup()
|
||||
component.showSystemStatus()
|
||||
expect(modalOpenSpy).toHaveBeenCalledWith(SystemStatusDialogComponent, {
|
||||
size: 'xl',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@@ -9,7 +9,11 @@ import {
|
||||
} from '@angular/core'
|
||||
import { FormGroup, FormControl } from '@angular/forms'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap'
|
||||
import {
|
||||
NgbModal,
|
||||
NgbModalRef,
|
||||
NgbNavChangeEvent,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
|
||||
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import {
|
||||
@@ -40,6 +44,12 @@ import {
|
||||
} from 'src/app/services/settings.service'
|
||||
import { ToastService, Toast } from 'src/app/services/toast.service'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
|
||||
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||
import {
|
||||
SystemStatusItemStatus,
|
||||
SystemStatus,
|
||||
} from 'src/app/data/system-status'
|
||||
|
||||
enum SettingsNavIDs {
|
||||
General = 1,
|
||||
@@ -111,6 +121,18 @@ export class SettingsComponent
|
||||
users: User[]
|
||||
groups: Group[]
|
||||
|
||||
private systemStatus: SystemStatus
|
||||
|
||||
get systemStatusHasErrors(): boolean {
|
||||
return (
|
||||
this.systemStatus.database.status === SystemStatusItemStatus.ERROR ||
|
||||
this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR ||
|
||||
this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR ||
|
||||
this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR ||
|
||||
this.systemStatus.tasks.classifier_status === SystemStatusItemStatus.ERROR
|
||||
)
|
||||
}
|
||||
|
||||
get computedDateLocale(): string {
|
||||
return (
|
||||
this.settingsForm.value.dateLocale ||
|
||||
@@ -131,7 +153,9 @@ export class SettingsComponent
|
||||
private usersService: UserService,
|
||||
private groupsService: GroupService,
|
||||
private router: Router,
|
||||
public permissionsService: PermissionsService
|
||||
public permissionsService: PermissionsService,
|
||||
private modalService: NgbModal,
|
||||
private systemStatusService: SystemStatusService
|
||||
) {
|
||||
super()
|
||||
this.settings.settingsSaved.subscribe(() => {
|
||||
@@ -360,6 +384,17 @@ export class SettingsComponent
|
||||
// prevents loss of unsaved changes
|
||||
this.settingsForm.patchValue(currentFormValue)
|
||||
}
|
||||
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Admin
|
||||
)
|
||||
) {
|
||||
this.systemStatusService.get().subscribe((status) => {
|
||||
this.systemStatus = status
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private emptyGroup(group: FormGroup) {
|
||||
@@ -565,4 +600,14 @@ export class SettingsComponent
|
||||
clearThemeColor() {
|
||||
this.settingsForm.get('themeColor').patchValue('')
|
||||
}
|
||||
|
||||
showSystemStatus() {
|
||||
const modal: NgbModalRef = this.modalService.open(
|
||||
SystemStatusDialogComponent,
|
||||
{
|
||||
size: 'xl',
|
||||
}
|
||||
)
|
||||
modal.componentInstance.status = this.systemStatus
|
||||
}
|
||||
}
|
||||
|
@@ -29,7 +29,7 @@
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
||||
<input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length === 0" [(ngModel)]="togggleAll" (click)="toggleAll($event); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="all-tasks"></label>
|
||||
</div>
|
||||
</th>
|
||||
|
@@ -29,6 +29,7 @@ import { PageHeaderComponent } from '../../common/page-header/page-header.compon
|
||||
import { TasksComponent } from './tasks.component'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
const tasks: PaperlessTask[] = [
|
||||
{
|
||||
@@ -140,6 +141,7 @@ describe('TasksComponent', () => {
|
||||
HttpClientTestingModule,
|
||||
RouterTestingModule.withRoutes(routes),
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
FormsModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
|
@@ -18,6 +18,7 @@ export class TasksComponent
|
||||
{
|
||||
public activeTab: string
|
||||
public selectedTasks: Set<number> = new Set()
|
||||
public togggleAll: boolean = false
|
||||
public expandedTask: number
|
||||
|
||||
public pageSize: number = 25
|
||||
@@ -120,6 +121,7 @@ export class TasksComponent
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.togggleAll = false
|
||||
this.selectedTasks.clear()
|
||||
}
|
||||
|
||||
|
@@ -26,7 +26,7 @@
|
||||
@for (user of users; track user) {
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
|
||||
<div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div>
|
||||
<div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div>
|
||||
<div class="col">
|
||||
@@ -65,7 +65,7 @@
|
||||
@for (group of groups; track group) {
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
||||
<div class="col"></div>
|
||||
<div class="col"></div>
|
||||
<div class="col">
|
||||
|
@@ -4,16 +4,16 @@
|
||||
(click)="isMenuCollapsed = !isMenuCollapsed">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<a class="navbar-brand d-flex col-auto col-md-3 col-lg-2 me-0 px-3 py-3 order-sm-0"
|
||||
[ngClass]="{ 'slim': slimSidebarEnabled, 'd-flex col-auto col-md-3 col-lg-2' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
|
||||
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
||||
[ngClass]="{ 'slim': slimSidebarEnabled, 'col-auto col-md-3 col-lg-2 col-xxxl-1' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
|
||||
routerLink="/dashboard"
|
||||
tourAnchor="tour.intro">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" class="me-2" fill="currentColor">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" height="1.5em" fill="currentColor">
|
||||
<path
|
||||
d="M194.7,0C164.22,70.94,17.64,79.74,64.55,194.06c.58,1.47-10.85,17-18.47,29.9-1.76-6.45-3.81-13.48-3.52-14.07,38.11-45.14-27.26-70.65-30.78-107.58C-4.64,131.62-10.5,182.92,39,212.53c.3,0,2.64,11.14,3.81,16.71a58.55,58.55,0,0,0-2.93,6.45c-1.17,2.93,7.62,2.64,7.62,3.22.88-.29,21.7-36.93,22.28-37.23C187.67,174.72,208.48,68.6,194.7,0ZM134.61,74.75C79.5,124,70.12,160.64,71.88,178.53,53.41,134.85,107.64,86.77,134.61,74.75ZM28.2,145.11c10.55,9.67,28.14,39.28,13.19,56.57C44.91,193.77,46.08,175.89,28.2,145.11Z"
|
||||
transform="translate(0 0)" />
|
||||
</svg>
|
||||
<div class="ms-2 d-inline-block" [class.visually-hidden]="slimSidebarEnabled">
|
||||
<div class="ms-2 ms-md-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled">
|
||||
@if (customAppTitle?.length) {
|
||||
<div class="d-flex flex-column align-items-start">
|
||||
<span class="title">{{customAppTitle}}</span>
|
||||
@@ -27,12 +27,12 @@
|
||||
<div class="search-form-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<form (ngSubmit)="search()" class="form-inline flex-grow-1">
|
||||
<i-bs style="top: .25em;" width="1em" height="1em" name="search"></i-bs>
|
||||
<i-bs width="1em" height="1em" name="search"></i-bs>
|
||||
<input class="form-control form-control-sm" type="text" placeholder="Search documents" aria-label="Search"
|
||||
[formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (keyup)="searchFieldKeyup($event)"
|
||||
(selectItem)="itemSelected($event)" i18n-placeholder>
|
||||
@if (!searchFieldEmpty) {
|
||||
<button type="button" class="btn btn-link btn-sm px-0 position-absolute top-0 end-0" (click)="resetSearchField()">
|
||||
<button type="button" class="btn btn-link btn-sm ps-0 pe-1 position-absolute top-0 end-0" (click)="resetSearchField()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
</button>
|
||||
}
|
||||
@@ -55,7 +55,7 @@
|
||||
<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.View, type: PermissionType.UISettings }">
|
||||
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }">
|
||||
<i-bs class="me-2" name="gear"></i-bs><ng-container i18n>Settings</ng-container>
|
||||
</a>
|
||||
<a ngbDropdownItem class="nav-link d-flex" href="accounts/logout/" (click)="onLogout()">
|
||||
@@ -85,14 +85,14 @@
|
||||
</button>
|
||||
<div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<li class="nav-item app-link">
|
||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="house"></i-bs><span> <ng-container i18n>Dashboard</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
@@ -100,9 +100,10 @@
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
|
||||
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
@if (savedViewService.loading || savedViewService.sidebarViews?.length > 0) {
|
||||
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted">
|
||||
<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>
|
||||
@@ -111,7 +112,7 @@
|
||||
}
|
||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||
@for (view of savedViewService.sidebarViews; track view) {
|
||||
<li class="nav-item w-100" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
|
||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
|
||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||
(cdkDragEnded)="onDragEnd($event)">
|
||||
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}"
|
||||
@@ -128,18 +129,18 @@
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
@if (openDocuments.length > 0) {
|
||||
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted">
|
||||
<h6 class="sidebar-heading px-3 text-muted">
|
||||
<span i18n>Open documents</span>
|
||||
</h6>
|
||||
}
|
||||
<ul class="nav flex-column mb-2">
|
||||
@for (d of openDocuments; track d) {
|
||||
<li class="nav-item w-100">
|
||||
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
|
||||
<li class="nav-item w-100 app-link">
|
||||
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
|
||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
|
||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||
popoverClass="popover-slim">
|
||||
@@ -151,8 +152,8 @@
|
||||
</li>
|
||||
}
|
||||
@if (openDocuments.length >= 1) {
|
||||
<li class="nav-item w-100">
|
||||
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()"
|
||||
<li class="nav-item w-100 app-link">
|
||||
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()"
|
||||
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="x"></i-bs><span> <ng-container i18n>Close all</ng-container></span>
|
||||
@@ -160,178 +161,182 @@
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted">
|
||||
<span i18n>Manage</span>
|
||||
</h6>
|
||||
<ul class="nav flex-column mb-2">
|
||||
<li class="nav-item"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="person"></i-bs><span> <ng-container i18n>Correspondents</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"
|
||||
tourAnchor="tour.tags">
|
||||
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="tags"></i-bs><span> <ng-container i18n>Tags</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="hash"></i-bs><span> <ng-container i18n>Document Types</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="folder"></i-bs><span> <ng-container i18n>Storage Paths</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="ui-radios"></i-bs><span> <ng-container i18n>Custom Fields</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||
tourAnchor="tour.workflows">
|
||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="boxes"></i-bs><span> <ng-container i18n>Workflows</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||
tourAnchor="tour.mail">
|
||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="envelope"></i-bs><span> <ng-container i18n>Mail</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="nav-group mt-3 mb-1">
|
||||
<h6 class="sidebar-heading px-3 text-muted">
|
||||
<span i18n>Manage</span>
|
||||
</h6>
|
||||
<ul class="nav flex-column mb-2">
|
||||
<li class="nav-item app-link"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="person"></i-bs><span> <ng-container i18n>Correspondents</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"
|
||||
tourAnchor="tour.tags">
|
||||
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="tags"></i-bs><span> <ng-container i18n>Tags</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="hash"></i-bs><span> <ng-container i18n>Document Types</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="folder"></i-bs><span> <ng-container i18n>Storage Paths</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="ui-radios"></i-bs><span> <ng-container i18n>Custom Fields</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||
tourAnchor="tour.workflows">
|
||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="boxes"></i-bs><span> <ng-container i18n>Workflows</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||
tourAnchor="tour.mail">
|
||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="envelope"></i-bs><span> <ng-container i18n>Mail</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted">
|
||||
<span i18n>Administration</span>
|
||||
</h6>
|
||||
<ul class="nav flex-column mb-2">
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }"
|
||||
tourAnchor="tour.settings">
|
||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="gear"></i-bs><span> <ng-container i18n>Settings</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
||||
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span> <ng-container i18n>Configuration</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
||||
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="people"></i-bs><span> <ng-container i18n>Users & Groups</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
||||
tourAnchor="tour.file-tasks">
|
||||
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="list-task"></i-bs><span> <ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
|
||||
<span><span class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span></span>
|
||||
}</span>
|
||||
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
|
||||
<span class="badge bg-danger position-absolute top-0 end-0">{{tasksService.failedFileTasks.length}}</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
|
||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="text-left"></i-bs><span> <ng-container i18n>Logs</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1"> <ng-container i18n>Documentation</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
|
||||
<div class="me-3">
|
||||
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
{{ versionString }}
|
||||
</a>
|
||||
</div>
|
||||
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
|
||||
<div class="version-check">
|
||||
<ng-template #updateAvailablePopContent>
|
||||
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
|
||||
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
||||
</ng-template>
|
||||
<ng-template #updateCheckingNotEnabledPopContent>
|
||||
<p class="small mb-2">
|
||||
<ng-container i18n>Paperless-ngx can automatically check for updates</ng-container>
|
||||
</p>
|
||||
<div class="btn-group btn-group-xs flex-fill w-100">
|
||||
<button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button>
|
||||
<button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button>
|
||||
</div>
|
||||
<p class="small mb-0 mt-2">
|
||||
<a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n>
|
||||
How does this work?
|
||||
</a>
|
||||
</p>
|
||||
</ng-template>
|
||||
@if (settingsService.updateCheckingIsSet) {
|
||||
@if (appRemoteVersion.update_available) {
|
||||
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||
href="https://github.com/paperless-ngx/paperless-ngx/releases"
|
||||
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
|
||||
<div class="nav-group mt-auto mb-1">
|
||||
<h6 class="sidebar-heading px-3 pt-4 text-muted">
|
||||
<span i18n>Administration</span>
|
||||
</h6>
|
||||
<ul class="nav flex-column mb-2">
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
||||
tourAnchor="tour.settings">
|
||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="gear"></i-bs><span> <ng-container i18n>Settings</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
||||
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span> <ng-container i18n>Configuration</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
||||
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="people"></i-bs><span> <ng-container i18n>Users & Groups</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
||||
tourAnchor="tour.file-tasks">
|
||||
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="list-task"></i-bs><span> <ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
|
||||
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
|
||||
}</span>
|
||||
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
|
||||
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
|
||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="text-left"></i-bs><span> <ng-container i18n>Logs</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1"> <ng-container i18n>Documentation</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
|
||||
<div class="me-3">
|
||||
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
{{ versionString }}
|
||||
</a>
|
||||
</div>
|
||||
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
|
||||
<div class="version-check">
|
||||
<ng-template #updateAvailablePopContent>
|
||||
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
|
||||
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
||||
</ng-template>
|
||||
<ng-template #updateCheckingNotEnabledPopContent>
|
||||
<p class="small mb-2">
|
||||
<ng-container i18n>Paperless-ngx can automatically check for updates</ng-container>
|
||||
</p>
|
||||
<div class="btn-group btn-group-xs flex-fill w-100">
|
||||
<button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button>
|
||||
<button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button>
|
||||
</div>
|
||||
<p class="small mb-0 mt-2">
|
||||
<a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n>
|
||||
How does this work?
|
||||
</a>
|
||||
</p>
|
||||
</ng-template>
|
||||
@if (settingsService.updateCheckingIsSet) {
|
||||
@if (appRemoteVersion.update_available) {
|
||||
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||
href="https://github.com/paperless-ngx/paperless-ngx/releases"
|
||||
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
|
||||
container="body">
|
||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||
@if (appRemoteVersion?.update_available) {
|
||||
<ng-container i18n>Update available</ng-container>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
} @else {
|
||||
<a class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
|
||||
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
|
||||
container="body">
|
||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||
@if (appRemoteVersion?.update_available) {
|
||||
<ng-container i18n>Update available</ng-container>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
} @else {
|
||||
<a class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
|
||||
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
|
||||
container="body">
|
||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
@@ -18,6 +18,10 @@
|
||||
height: 0.8em;
|
||||
}
|
||||
|
||||
.nav-group:not(:has(.app-link)) .sidebar-heading {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// These come from the col-* classes for non-slim sidebar, needed for animation
|
||||
@media (min-width: 768px) {
|
||||
max-width: 25%;
|
||||
@@ -262,9 +266,15 @@ main {
|
||||
> i-bs {
|
||||
position: absolute;
|
||||
left: 0.6rem;
|
||||
top: 0.5rem;
|
||||
top: .35rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
// adjust for smaller font size on non-mobile
|
||||
top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
@@ -0,0 +1,39 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{{message}}</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="metadataDocumentID" i18n>Documents:</label>
|
||||
<ul class="list-group"
|
||||
cdkDropList
|
||||
(cdkDropListDropped)="onDrop($event)">
|
||||
@for (documentID of documentIDs; track documentID) {
|
||||
<li class="list-group-item" cdkDrag>
|
||||
<i-bs name="grip-vertical" class="me-2"></i-bs>
|
||||
{{getDocument(documentID)?.title}}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="form-group mt-4">
|
||||
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
|
||||
<select class="form-select" [(ngModel)]="metadataDocumentID">
|
||||
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
|
||||
@for (document of documents; track document.id) {
|
||||
<option [ngValue]="document.id">{{document.title}}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||
{{btnCaption}}
|
||||
</button>
|
||||
</div>
|
@@ -0,0 +1,3 @@
|
||||
.list-group-item {
|
||||
cursor: move;
|
||||
}
|
@@ -0,0 +1,73 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { MergeConfirmDialogComponent } from './merge-confirm-dialog.component'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of } from 'rxjs'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
|
||||
describe('MergeConfirmDialogComponent', () => {
|
||||
let component: MergeConfirmDialogComponent
|
||||
let fixture: ComponentFixture<MergeConfirmDialogComponent>
|
||||
let documentService: DocumentService
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [MergeConfirmDialogComponent],
|
||||
providers: [NgbActiveModal],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(MergeConfirmDialogComponent)
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should fetch documents on ngOnInit', () => {
|
||||
const documents = [
|
||||
{ id: 1, name: 'Document 1' },
|
||||
{ id: 2, name: 'Document 2' },
|
||||
{ id: 3, name: 'Document 3' },
|
||||
]
|
||||
jest.spyOn(documentService, 'getCachedMany').mockReturnValue(of(documents))
|
||||
|
||||
component.ngOnInit()
|
||||
|
||||
expect(component.documents).toEqual(documents)
|
||||
expect(documentService.getCachedMany).toHaveBeenCalledWith(
|
||||
component.documentIDs
|
||||
)
|
||||
})
|
||||
|
||||
it('should move documentIDs on drop', () => {
|
||||
component.documentIDs = [1, 2, 3]
|
||||
const event = {
|
||||
previousIndex: 1,
|
||||
currentIndex: 2,
|
||||
}
|
||||
|
||||
component.onDrop(event as any)
|
||||
|
||||
expect(component.documentIDs).toEqual([1, 3, 2])
|
||||
})
|
||||
|
||||
it('should get document by ID', () => {
|
||||
const documents = [
|
||||
{ id: 1, name: 'Document 1' },
|
||||
{ id: 2, name: 'Document 2' },
|
||||
{ id: 3, name: 'Document 3' },
|
||||
]
|
||||
jest.spyOn(documentService, 'getCachedMany').mockReturnValue(of(documents))
|
||||
|
||||
component.ngOnInit()
|
||||
|
||||
expect(component.getDocument(2)).toEqual({ id: 2, name: 'Document 2' })
|
||||
})
|
||||
})
|
@@ -0,0 +1,51 @@
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import { Document } from 'src/app/data/document'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-merge-confirm-dialog',
|
||||
templateUrl: './merge-confirm-dialog.component.html',
|
||||
styleUrl: './merge-confirm-dialog.component.scss',
|
||||
})
|
||||
export class MergeConfirmDialogComponent
|
||||
extends ConfirmDialogComponent
|
||||
implements OnInit
|
||||
{
|
||||
public documentIDs: number[] = []
|
||||
private _documents: Document[] = []
|
||||
get documents(): Document[] {
|
||||
return this._documents
|
||||
}
|
||||
|
||||
public metadataDocumentID: number = -1
|
||||
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
constructor(
|
||||
activeModal: NgbActiveModal,
|
||||
private documentService: DocumentService
|
||||
) {
|
||||
super(activeModal)
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.documentService
|
||||
.getCachedMany(this.documentIDs)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((documents) => {
|
||||
this._documents = documents
|
||||
})
|
||||
}
|
||||
|
||||
onDrop(event: CdkDragDrop<number[]>) {
|
||||
moveItemInArray(this.documentIDs, event.previousIndex, event.currentIndex)
|
||||
}
|
||||
|
||||
getDocument(documentID: number): Document {
|
||||
return this.documents.find((d) => d.id === documentID)
|
||||
}
|
||||
}
|
@@ -0,0 +1,48 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-2 d-flex justify-content-end">
|
||||
<button class="btn btn-secondary mt-auto" (click)="rotate(false)">
|
||||
<i-bs name="arrow-counterclockwise"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-8 d-flex align-items-center">
|
||||
@if (documentID) {
|
||||
<img class="w-50 m-auto" [ngStyle]="{'transform': 'rotate('+rotation+'deg)'}" [src]="documentService.getThumbUrl(documentID)" />
|
||||
}
|
||||
</div>
|
||||
<div class="col-2 d-flex">
|
||||
<button class="btn btn-secondary mt-auto" (click)="rotate()">
|
||||
<i-bs name="arrow-clockwise"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-4">
|
||||
<div class="col">
|
||||
@if (messageBold) {
|
||||
<p><b>{{messageBold}}</b></p>
|
||||
}
|
||||
@if (message) {
|
||||
<p class="mb-0" [innerHTML]="message | safeHtml"></p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (showPDFNote) {
|
||||
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be rotated.</p>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled || degrees === 0">
|
||||
{{btnCaption}}
|
||||
@if (!confirmButtonEnabled) {
|
||||
<ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
|
||||
}
|
||||
</button>
|
||||
</div>
|
@@ -0,0 +1,3 @@
|
||||
img {
|
||||
transition: all 0.25s ease;
|
||||
}
|
@@ -0,0 +1,60 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { RotateConfirmDialogComponent } from './rotate-confirm-dialog.component'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
|
||||
describe('RotateConfirmDialogComponent', () => {
|
||||
let component: RotateConfirmDialogComponent
|
||||
let fixture: ComponentFixture<RotateConfirmDialogComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [RotateConfirmDialogComponent, SafeHtmlPipe],
|
||||
providers: [NgbActiveModal, SafeHtmlPipe],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(RotateConfirmDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should support rotating the image', () => {
|
||||
component.documentID = 1
|
||||
fixture.detectChanges()
|
||||
component.rotate()
|
||||
fixture.detectChanges()
|
||||
expect(component.degrees).toBe(90)
|
||||
expect(fixture.nativeElement.querySelector('img').style.transform).toBe(
|
||||
'rotate(90deg)'
|
||||
)
|
||||
component.rotate()
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.querySelector('img').style.transform).toBe(
|
||||
'rotate(180deg)'
|
||||
)
|
||||
})
|
||||
|
||||
it('should normalize degrees', () => {
|
||||
expect(component.degrees).toBe(0)
|
||||
component.rotate()
|
||||
expect(component.degrees).toBe(90)
|
||||
component.rotate()
|
||||
expect(component.degrees).toBe(180)
|
||||
component.rotate()
|
||||
expect(component.degrees).toBe(270)
|
||||
component.rotate()
|
||||
expect(component.degrees).toBe(0)
|
||||
component.rotate()
|
||||
expect(component.degrees).toBe(90)
|
||||
component.rotate(false)
|
||||
expect(component.degrees).toBe(0)
|
||||
component.rotate(false)
|
||||
expect(component.degrees).toBe(270)
|
||||
})
|
||||
})
|
@@ -0,0 +1,34 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-rotate-confirm-dialog',
|
||||
templateUrl: './rotate-confirm-dialog.component.html',
|
||||
styleUrl: './rotate-confirm-dialog.component.scss',
|
||||
})
|
||||
export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
|
||||
public documentID: number
|
||||
public showPDFNote: boolean = true
|
||||
|
||||
// animation is better if we dont normalize yet
|
||||
public rotation: number = 0
|
||||
|
||||
public get degrees(): number {
|
||||
let degrees = this.rotation % 360
|
||||
if (degrees < 0) degrees += 360
|
||||
return degrees
|
||||
}
|
||||
|
||||
constructor(
|
||||
activeModal: NgbActiveModal,
|
||||
public documentService: DocumentService
|
||||
) {
|
||||
super(activeModal)
|
||||
}
|
||||
|
||||
rotate(clockwise: boolean = true) {
|
||||
this.rotation += clockwise ? 90 : -90
|
||||
}
|
||||
}
|
@@ -0,0 +1,55 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{{message}}</p>
|
||||
<div class="row mb-2">
|
||||
<div class="col-6">
|
||||
<div class="input-group input-group-sm">
|
||||
<div class="input-group-text" i18n>Page</div>
|
||||
<input class="form-control" type="number" min="1" [(ngModel)]="page" />
|
||||
<div class="input-group-text" i18n>of {{totalPages}}</div>
|
||||
</div>
|
||||
<div class="pdf-viewer-container w-100 mt-3">
|
||||
<pngx-pdf-viewer [src]="pdfSrc" [(page)]="page"
|
||||
[original-size]="false"
|
||||
[zoom]="1"
|
||||
zoom-scale="page-fit"
|
||||
(after-load-complete)="pdfPreviewLoaded($event)">
|
||||
</pngx-pdf-viewer>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="d-grid">
|
||||
<button class="btn btn-sm btn-primary" (click)="addSplit()">
|
||||
<i-bs name="plus-circle"></i-bs>
|
||||
<span i18n>Add Split</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="list-group mt-3">
|
||||
@for (pageStr of pagesString.split(','); track pageStr; let i = $index) {
|
||||
<li class="list-group-item">
|
||||
{{pageStr}}
|
||||
@if (pagesString.split(',').length > 1) {
|
||||
|
||||
<button class="btn btn-sm btn-danger" (click)="removeSplit(i)">
|
||||
<i-bs name="trash"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||
{{btnCaption}}
|
||||
</button>
|
||||
</div>
|
@@ -0,0 +1,9 @@
|
||||
.pdf-viewer-container {
|
||||
background-color: gray;
|
||||
height: 300px;
|
||||
|
||||
pngx-pdf-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
@@ -0,0 +1,81 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { SplitConfirmDialogComponent } from './split-confirm-dialog.component'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { ReactiveFormsModule, FormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { PdfViewerComponent } from '../../pdf-viewer/pdf-viewer.component'
|
||||
|
||||
describe('SplitConfirmDialogComponent', () => {
|
||||
let component: SplitConfirmDialogComponent
|
||||
let fixture: ComponentFixture<SplitConfirmDialogComponent>
|
||||
let documentService: DocumentService
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [SplitConfirmDialogComponent, PdfViewerComponent],
|
||||
providers: [NgbActiveModal],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(SplitConfirmDialogComponent)
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should update pagesString when pages are added', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 2
|
||||
component.addSplit()
|
||||
expect(component.pagesString).toEqual('1-2,3-5')
|
||||
component.page = 4
|
||||
component.addSplit()
|
||||
expect(component.pagesString).toEqual('1-2,3-4,5')
|
||||
})
|
||||
|
||||
it('should update pagesString when pages are removed', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 2
|
||||
component.addSplit()
|
||||
component.page = 4
|
||||
component.addSplit()
|
||||
expect(component.pagesString).toEqual('1-2,3-4,5')
|
||||
component.removeSplit(0)
|
||||
expect(component.pagesString).toEqual('1-4,5')
|
||||
})
|
||||
|
||||
it('should enable confirm button when pages are added', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 2
|
||||
component.addSplit()
|
||||
expect(component.confirmButtonEnabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should disable confirm button when all pages are removed', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 2
|
||||
component.addSplit()
|
||||
component.removeSplit(0)
|
||||
expect(component.confirmButtonEnabled).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not add split if page is the last page', () => {
|
||||
component.totalPages = 5
|
||||
component.page = 5
|
||||
component.addSplit()
|
||||
expect(component.pagesString).toEqual('1-5')
|
||||
})
|
||||
|
||||
it('should update totalPages when pdf is loaded', () => {
|
||||
component.pdfPreviewLoaded({ numPages: 5 } as any)
|
||||
expect(component.totalPages).toEqual(5)
|
||||
})
|
||||
})
|
@@ -0,0 +1,66 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { PDFDocumentProxy } from '../../pdf-viewer/typings'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-split-confirm-dialog',
|
||||
templateUrl: './split-confirm-dialog.component.html',
|
||||
styleUrl: './split-confirm-dialog.component.scss',
|
||||
})
|
||||
export class SplitConfirmDialogComponent extends ConfirmDialogComponent {
|
||||
public get pagesString(): string {
|
||||
let pagesStr = ''
|
||||
|
||||
let lastPage = 1
|
||||
for (let i = 1; i <= this.totalPages; i++) {
|
||||
if (this.pages.has(i) || i === this.totalPages) {
|
||||
if (lastPage === i) {
|
||||
pagesStr += `${i},`
|
||||
lastPage = Math.min(i + 1, this.totalPages)
|
||||
} else {
|
||||
pagesStr += `${lastPage}-${i},`
|
||||
lastPage = Math.min(i + 1, this.totalPages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pagesStr.replace(/,$/, '')
|
||||
}
|
||||
|
||||
private pages: Set<number> = new Set()
|
||||
|
||||
public documentID: number
|
||||
public page: number = 1
|
||||
public totalPages: number
|
||||
|
||||
public get pdfSrc(): string {
|
||||
return this.documentService.getPreviewUrl(this.documentID)
|
||||
}
|
||||
|
||||
constructor(
|
||||
activeModal: NgbActiveModal,
|
||||
private documentService: DocumentService
|
||||
) {
|
||||
super(activeModal)
|
||||
this.confirmButtonEnabled = this.pages.size > 0
|
||||
}
|
||||
|
||||
pdfPreviewLoaded(pdf: PDFDocumentProxy) {
|
||||
this.totalPages = pdf.numPages
|
||||
}
|
||||
|
||||
addSplit() {
|
||||
if (this.page === this.totalPages) return
|
||||
this.pages.add(this.page)
|
||||
this.pages = new Set(Array.from(this.pages).sort())
|
||||
this.confirmButtonEnabled = this.pages.size > 0
|
||||
}
|
||||
|
||||
removeSplit(i: number) {
|
||||
let page = Array.from(this.pages)[Math.min(i, this.pages.size - 1)]
|
||||
this.pages.delete(page)
|
||||
this.confirmButtonEnabled = this.pages.size > 0
|
||||
}
|
||||
}
|
@@ -1,6 +1,9 @@
|
||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||
@if (object?.id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||
}
|
||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
|
@@ -1,6 +1,9 @@
|
||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||
@if (object?.id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||
}
|
||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
|
@@ -1,6 +1,9 @@
|
||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||
@if (object?.id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||
}
|
||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
|
@@ -1,6 +1,9 @@
|
||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||
@if (object?.id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||
}
|
||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
|
@@ -1,6 +1,9 @@
|
||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||
@if (object?.id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||
}
|
||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
|
@@ -1,6 +1,9 @@
|
||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||
@if (object?.id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||
}
|
||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
@@ -29,7 +32,6 @@
|
||||
@if (showActionParamField) {
|
||||
<pngx-input-text i18n-title title="Action parameter" formControlName="action_parameter" [error]="error?.action_parameter"></pngx-input-text>
|
||||
}
|
||||
<p class="small fst-italic mt-5" i18n>Assignments specified here will supersede any consumption templates.</p>
|
||||
<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>
|
||||
|
@@ -1,6 +1,9 @@
|
||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||
@if (object?.id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||
}
|
||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
|
@@ -1,6 +1,9 @@
|
||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||
@if (object?.id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||
}
|
||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
|
@@ -1,6 +1,9 @@
|
||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||
@if (object?.id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||
}
|
||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
|
@@ -1,6 +1,9 @@
|
||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||
@if (object?.id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||
}
|
||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
@@ -35,7 +38,7 @@
|
||||
<div ngbAccordionItem>
|
||||
<div ngbAccordionHeader>
|
||||
<button ngbAccordionButton>{{i + 1}}. {{getTriggerTypeOptionName(triggerFields.controls[i].value.type)}}
|
||||
@if(trigger.id > -1) {
|
||||
@if(trigger.id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{trigger.id}}</span>
|
||||
}
|
||||
<pngx-confirm-button
|
||||
@@ -77,7 +80,7 @@
|
||||
<div ngbAccordionItem cdkDrag [formGroup]="actionFields.controls[i]">
|
||||
<div ngbAccordionHeader>
|
||||
<button ngbAccordionButton>{{i + 1}}. {{getActionTypeOptionName(actionFields.controls[i].value.type)}}
|
||||
@if(action.id > -1) {
|
||||
@if(action.id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{action.id}}</span>
|
||||
}
|
||||
<pngx-confirm-button
|
||||
@@ -91,63 +94,7 @@
|
||||
</div>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<pngx-input-select i18n-title title="Action type" [horizontal]="true" [items]="actionTypeOptions" formControlName="type"></pngx-input-select>
|
||||
<input type="hidden" formControlName="id" />
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-text i18n-title title="Assign title" formControlName="assign_title" i18n-hint hint="Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#workflows'>documentation</a>." [error]="error?.actions?.[i]?.assign_title"></pngx-input-text>
|
||||
<pngx-input-tags [allowCreate]="false" i18n-title title="Assign tags" 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" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select>
|
||||
<div>
|
||||
<label class="form-label" i18n>Assign view permissions</label>
|
||||
<div class="mb-2">
|
||||
<div class="row mb-1">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-user type="view" formControlName="assign_view_users"></pngx-permissions-user>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-group type="view" formControlName="assign_view_groups"></pngx-permissions-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="form-label" i18n>Assign edit permissions</label>
|
||||
<div>
|
||||
<div class="row mb-1">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-user type="change" formControlName="assign_change_users"></pngx-permissions-user>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-group type="change" formControlName="assign_change_groups"></pngx-permissions-group>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text text-muted text-end d-block" i18n>Edit permissions also grant viewing permissions</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template [ngTemplateOutlet]="actionForm" [ngTemplateOutletContext]="{ formGroup: actionFields.controls[i], action: action }"></ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,3 +148,154 @@
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #actionForm let-formGroup="formGroup" let action="action">
|
||||
<div [formGroup]="formGroup">
|
||||
<input type="hidden" formControlName="id" />
|
||||
<pngx-input-select i18n-title title="Action type" [horizontal]="true" [items]="actionTypeOptions" formControlName="type"></pngx-input-select>
|
||||
@switch(formGroup.get('type').value) {
|
||||
@case ( WorkflowActionType.Assignment) {
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-text i18n-title title="Assign title" formControlName="assign_title" i18n-hint hint="Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#workflows'>documentation</a>." [error]="error?.actions?.[i]?.assign_title"></pngx-input-text>
|
||||
<pngx-input-tags [allowCreate]="false" i18n-title title="Assign tags" 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" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select>
|
||||
<div>
|
||||
<label class="form-label" i18n>Assign view permissions</label>
|
||||
<div class="mb-2">
|
||||
<div class="row mb-1">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-user type="view" formControlName="assign_view_users"></pngx-permissions-user>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-group type="view" formControlName="assign_view_groups"></pngx-permissions-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="form-label" i18n>Assign edit permissions</label>
|
||||
<div>
|
||||
<div class="row mb-1">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-user type="change" formControlName="assign_change_users"></pngx-permissions-user>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-group type="change" formControlName="assign_change_groups"></pngx-permissions-group>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text text-muted text-end d-block" i18n>Edit permissions also grant viewing permissions</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case (WorkflowActionType.Removal) {
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h6 class="form-label" i18n>Remove tags</h6>
|
||||
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_tags"></pngx-input-switch>
|
||||
<div class="mt-n3">
|
||||
<pngx-input-tags [allowCreate]="false" title="" formControlName="remove_tags"></pngx-input-tags>
|
||||
</div>
|
||||
|
||||
<h6 class="form-label" i18n>Remove correspondents</h6>
|
||||
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_correspondents"></pngx-input-switch>
|
||||
<div class="mt-n3">
|
||||
<pngx-input-select i18n-title title="" multiple="true" [items]="correspondents" formControlName="remove_correspondents"></pngx-input-select>
|
||||
</div>
|
||||
|
||||
<h6 class="form-label" i18n>Remove document types</h6>
|
||||
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_document_types"></pngx-input-switch>
|
||||
<div class="mt-n3">
|
||||
<pngx-input-select i18n-title title="" multiple="true" [items]="documentTypes" formControlName="remove_document_types"></pngx-input-select>
|
||||
</div>
|
||||
|
||||
<h6 class="form-label" i18n>Remove storage paths</h6>
|
||||
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_storage_paths"></pngx-input-switch>
|
||||
<div class="mt-n3">
|
||||
<pngx-input-select i18n-title title="" multiple="true" [items]="storagePaths" formControlName="remove_storage_paths"></pngx-input-select>
|
||||
</div>
|
||||
|
||||
<h6 class="form-label" i18n>Remove custom fields</h6>
|
||||
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_custom_fields"></pngx-input-switch>
|
||||
<div class="mt-n3">
|
||||
<pngx-input-select i18n-title title="" multiple="true" [items]="customFields" formControlName="remove_custom_fields"></pngx-input-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h6 class="form-label" i18n>Remove owners</h6>
|
||||
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_owners"></pngx-input-switch>
|
||||
<div class="mt-n3">
|
||||
<pngx-input-select i18n-title title="" multiple="true" [items]="users" bindLabel="username" formControlName="remove_owners"></pngx-input-select>
|
||||
</div>
|
||||
|
||||
<h6 class="form-label" i18n>Remove permissions</h6>
|
||||
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_permissions"></pngx-input-switch>
|
||||
<div>
|
||||
<label class="form-label" i18n>View permissions</label>
|
||||
<div class="mb-2">
|
||||
<div class="row mb-1">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-user type="view" formControlName="remove_view_users"></pngx-permissions-user>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-group type="view" formControlName="remove_view_groups"></pngx-permissions-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="form-label" i18n>Edit permissions</label>
|
||||
<div>
|
||||
<div class="row mb-1">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-user type="change" formControlName="remove_change_users"></pngx-permissions-user>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-3">
|
||||
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
|
||||
</div>
|
||||
<div class="col-lg-9">
|
||||
<pngx-permissions-group type="change" formControlName="remove_change_groups"></pngx-permissions-group>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text text-muted text-end d-block" i18n>Edit permissions also grant viewing permissions</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
@@ -235,4 +235,103 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
MATCHING_ALGORITHMS.find((a) => a.id === MATCH_AUTO)
|
||||
)
|
||||
})
|
||||
|
||||
it('should disable or enable action fields based on removal action type', () => {
|
||||
const workflow: Workflow = {
|
||||
name: 'Workflow 1',
|
||||
id: 1,
|
||||
order: 1,
|
||||
enabled: true,
|
||||
triggers: [],
|
||||
actions: [
|
||||
{
|
||||
id: 1,
|
||||
type: WorkflowActionType.Removal,
|
||||
remove_all_tags: true,
|
||||
remove_all_document_types: true,
|
||||
remove_all_correspondents: true,
|
||||
remove_all_storage_paths: true,
|
||||
remove_all_custom_fields: true,
|
||||
remove_all_owners: true,
|
||||
remove_all_permissions: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
component.object = workflow
|
||||
component.ngOnInit()
|
||||
|
||||
component['checkRemovalActionFields'](workflow)
|
||||
|
||||
// Assert that the action fields are disabled or enabled correctly
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_tags').disabled
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_document_types').disabled
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_correspondents').disabled
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_storage_paths').disabled
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_custom_fields').disabled
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_owners').disabled
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_view_users').disabled
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_view_groups').disabled
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_change_users').disabled
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_change_groups').disabled
|
||||
).toBeTruthy()
|
||||
|
||||
workflow.actions[0].remove_all_tags = false
|
||||
workflow.actions[0].remove_all_document_types = false
|
||||
workflow.actions[0].remove_all_correspondents = false
|
||||
workflow.actions[0].remove_all_storage_paths = false
|
||||
workflow.actions[0].remove_all_custom_fields = false
|
||||
workflow.actions[0].remove_all_owners = false
|
||||
workflow.actions[0].remove_all_permissions = false
|
||||
|
||||
component['checkRemovalActionFields'](workflow)
|
||||
|
||||
// Assert that the action fields are disabled or enabled correctly
|
||||
expect(component.actionFields.at(0).get('remove_tags').disabled).toBeFalsy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_document_types').disabled
|
||||
).toBeFalsy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_correspondents').disabled
|
||||
).toBeFalsy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_storage_paths').disabled
|
||||
).toBeFalsy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_custom_fields').disabled
|
||||
).toBeFalsy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_owners').disabled
|
||||
).toBeFalsy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_view_users').disabled
|
||||
).toBeFalsy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_view_groups').disabled
|
||||
).toBeFalsy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_change_users').disabled
|
||||
).toBeFalsy()
|
||||
expect(
|
||||
component.actionFields.at(0).get('remove_change_groups').disabled
|
||||
).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
@@ -68,6 +68,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
||||
id: WorkflowActionType.Assignment,
|
||||
name: $localize`Assignment`,
|
||||
},
|
||||
{
|
||||
id: WorkflowActionType.Removal,
|
||||
name: $localize`Removal`,
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
||||
@@ -84,6 +88,7 @@ export class WorkflowEditDialogComponent
|
||||
implements OnInit
|
||||
{
|
||||
public WorkflowTriggerType = WorkflowTriggerType
|
||||
public WorkflowActionType = WorkflowActionType
|
||||
|
||||
templates: Workflow[]
|
||||
correspondents: Correspondent[]
|
||||
@@ -159,6 +164,124 @@ export class WorkflowEditDialogComponent
|
||||
ngOnInit(): void {
|
||||
super.ngOnInit()
|
||||
this.updateAllTriggerActionFields()
|
||||
this.objectForm.valueChanges.subscribe(
|
||||
this.checkRemovalActionFields.bind(this)
|
||||
)
|
||||
this.checkRemovalActionFields(this.objectForm.value)
|
||||
}
|
||||
|
||||
private checkRemovalActionFields(formWorkflow: Workflow) {
|
||||
formWorkflow.actions
|
||||
.filter((action) => action.type === WorkflowActionType.Removal)
|
||||
.forEach((action, i) => {
|
||||
if (action.remove_all_tags) {
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_tags')
|
||||
.disable({ emitEvent: false })
|
||||
} else {
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_tags')
|
||||
.enable({ emitEvent: false })
|
||||
}
|
||||
|
||||
if (action.remove_all_document_types) {
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_document_types')
|
||||
.disable({ emitEvent: false })
|
||||
} else {
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_document_types')
|
||||
.enable({ emitEvent: false })
|
||||
}
|
||||
|
||||
if (action.remove_all_correspondents) {
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_correspondents')
|
||||
.disable({ emitEvent: false })
|
||||
} else {
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_correspondents')
|
||||
.enable({ emitEvent: false })
|
||||
}
|
||||
|
||||
if (action.remove_all_storage_paths) {
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_storage_paths')
|
||||
.disable({ emitEvent: false })
|
||||
} else {
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_storage_paths')
|
||||
.enable({ emitEvent: false })
|
||||
}
|
||||
|
||||
if (action.remove_all_custom_fields) {
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_custom_fields')
|
||||
.disable({ emitEvent: false })
|
||||
} else {
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_custom_fields')
|
||||
.enable({ emitEvent: false })
|
||||
}
|
||||
|
||||
if (action.remove_all_owners) {
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_owners')
|
||||
.disable({ emitEvent: false })
|
||||
} else {
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_owners')
|
||||
.enable({ emitEvent: false })
|
||||
}
|
||||
|
||||
if (action.remove_all_permissions) {
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_view_users')
|
||||
.disable({ emitEvent: false })
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_view_groups')
|
||||
.disable({ emitEvent: false })
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_change_users')
|
||||
.disable({ emitEvent: false })
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_change_groups')
|
||||
.disable({ emitEvent: false })
|
||||
} else {
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_view_users')
|
||||
.enable({ emitEvent: false })
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_view_groups')
|
||||
.enable({ emitEvent: false })
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_change_users')
|
||||
.enable({ emitEvent: false })
|
||||
this.actionFields
|
||||
.at(i)
|
||||
.get('remove_change_groups')
|
||||
.enable({ emitEvent: false })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
get triggerFields(): FormArray {
|
||||
@@ -215,6 +338,31 @@ export class WorkflowEditDialogComponent
|
||||
assign_change_users: new FormControl(action.assign_change_users),
|
||||
assign_change_groups: new FormControl(action.assign_change_groups),
|
||||
assign_custom_fields: new FormControl(action.assign_custom_fields),
|
||||
remove_tags: new FormControl(action.remove_tags),
|
||||
remove_all_tags: new FormControl(action.remove_all_tags),
|
||||
remove_document_types: new FormControl(action.remove_document_types),
|
||||
remove_all_document_types: new FormControl(
|
||||
action.remove_all_document_types
|
||||
),
|
||||
remove_correspondents: new FormControl(action.remove_correspondents),
|
||||
remove_all_correspondents: new FormControl(
|
||||
action.remove_all_correspondents
|
||||
),
|
||||
remove_storage_paths: new FormControl(action.remove_storage_paths),
|
||||
remove_all_storage_paths: new FormControl(
|
||||
action.remove_all_storage_paths
|
||||
),
|
||||
remove_owners: new FormControl(action.remove_owners),
|
||||
remove_all_owners: new FormControl(action.remove_all_owners),
|
||||
remove_view_users: new FormControl(action.remove_view_users),
|
||||
remove_view_groups: new FormControl(action.remove_view_groups),
|
||||
remove_change_users: new FormControl(action.remove_change_users),
|
||||
remove_change_groups: new FormControl(action.remove_change_groups),
|
||||
remove_all_permissions: new FormControl(action.remove_all_permissions),
|
||||
remove_custom_fields: new FormControl(action.remove_custom_fields),
|
||||
remove_all_custom_fields: new FormControl(
|
||||
action.remove_all_custom_fields
|
||||
),
|
||||
}),
|
||||
{ emitEvent }
|
||||
)
|
||||
@@ -290,6 +438,23 @@ export class WorkflowEditDialogComponent
|
||||
assign_change_users: [],
|
||||
assign_change_groups: [],
|
||||
assign_custom_fields: [],
|
||||
remove_tags: [],
|
||||
remove_all_tags: false,
|
||||
remove_document_types: [],
|
||||
remove_all_document_types: false,
|
||||
remove_correspondents: [],
|
||||
remove_all_correspondents: false,
|
||||
remove_storage_paths: [],
|
||||
remove_all_storage_paths: false,
|
||||
remove_owners: [],
|
||||
remove_all_owners: false,
|
||||
remove_view_users: [],
|
||||
remove_view_groups: [],
|
||||
remove_change_users: [],
|
||||
remove_change_groups: [],
|
||||
remove_all_permissions: false,
|
||||
remove_custom_fields: [],
|
||||
remove_all_custom_fields: false,
|
||||
}
|
||||
this.object.actions.push(action)
|
||||
this.createActionField(action)
|
||||
@@ -316,5 +481,8 @@ export class WorkflowEditDialogComponent
|
||||
this.actionFields.insert(event.currentIndex, actionField)
|
||||
// removing id will effectively re-create the actions in this order
|
||||
this.object.actions.forEach((a) => (a.id = null))
|
||||
this.actionFields.controls.forEach((c) =>
|
||||
c.get('id').setValue(null, { emitEvent: false })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -493,12 +493,17 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
expect(changedResult.getExcludedItems()).toEqual(items)
|
||||
}))
|
||||
|
||||
it('FilterableDropdownSelectionModel should sort items by state', () => {
|
||||
component.items = items
|
||||
it('selection model should sort items by state', () => {
|
||||
component.items = items.concat([{ id: null, name: 'Null B' }])
|
||||
component.selectionModel = selectionModel
|
||||
selectionModel.toggle(items[1].id)
|
||||
selectionModel.apply()
|
||||
expect(selectionModel.itemsSorted).toEqual([nullItem, items[1], items[0]])
|
||||
expect(selectionModel.itemsSorted).toEqual([
|
||||
nullItem,
|
||||
{ id: null, name: 'Null B' },
|
||||
items[1],
|
||||
items[0],
|
||||
])
|
||||
})
|
||||
|
||||
it('should set support create, keep open model and call createRef method', fakeAsync(() => {
|
||||
@@ -542,4 +547,34 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
tick(300)
|
||||
expect(createSpy).toHaveBeenCalled()
|
||||
}))
|
||||
|
||||
it('should exclude item and trigger change event', () => {
|
||||
const id = 1
|
||||
const state = ToggleableItemState.Selected
|
||||
component.selectionModel = selectionModel
|
||||
component.manyToOne = true
|
||||
component.selectionModel.singleSelect = true
|
||||
component.selectionModel.intersection = Intersection.Include
|
||||
component.selectionModel['temporarySelectionStates'].set(id, state)
|
||||
const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
|
||||
component.selectionModel.exclude(id)
|
||||
expect(component.selectionModel.temporaryLogicalOperator).toBe(
|
||||
LogicalOperator.And
|
||||
)
|
||||
expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
|
||||
ToggleableItemState.Excluded
|
||||
)
|
||||
expect(component.selectionModel['temporarySelectionStates'].size).toBe(1)
|
||||
expect(changedSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should initialize selection states and apply changes', () => {
|
||||
selectionModel.items = items
|
||||
const map = new Map<number, ToggleableItemState>()
|
||||
map.set(1, ToggleableItemState.Selected)
|
||||
map.set(2, ToggleableItemState.Excluded)
|
||||
selectionModel.init(map)
|
||||
expect(selectionModel.getSelectedItems()).toEqual([items[0]])
|
||||
expect(selectionModel.getExcludedItems()).toEqual([items[1]])
|
||||
})
|
||||
})
|
||||
|
@@ -275,7 +275,7 @@ export class FilterableDropdownSelectionModel {
|
||||
)
|
||||
}
|
||||
|
||||
init(map) {
|
||||
init(map: Map<number, ToggleableItemState>) {
|
||||
this.temporarySelectionStates = map
|
||||
this.apply()
|
||||
}
|
||||
|
@@ -0,0 +1,27 @@
|
||||
<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">
|
||||
<div class="input-group" [class.is-invalid]="error">
|
||||
<span class="input-group-text fw-bold bg-light">{{monetaryValue | currency: currencyCode }}</span>
|
||||
<input #currencyField class="form-control text-muted mw-60" tabindex="0" [(ngModel)]="currencyCode" maxlength="3" [class.is-invalid]="error" (change)="onChange(value)" [disabled]="disabled">
|
||||
<input #inputField type="number" tabindex="0" class="form-control text-muted" step=".01" [id]="inputId" [(ngModel)]="monetaryValue" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
|
||||
</div>
|
||||
<div class="invalid-feedback position-absolute top-100">
|
||||
{{error}}
|
||||
</div>
|
||||
@if (hint) {
|
||||
<small class="form-text text-muted">{{hint}}</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,11 @@
|
||||
.input-group-text {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.text-muted:focus-within {
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
|
||||
.mw-60 {
|
||||
max-width: 60px;
|
||||
}
|
@@ -0,0 +1,65 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import {
|
||||
FormsModule,
|
||||
NG_VALUE_ACCESSOR,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { CurrencyPipe } from '@angular/common'
|
||||
import { MonetaryComponent } from './monetary.component'
|
||||
|
||||
describe('MonetaryComponent', () => {
|
||||
let component: MonetaryComponent
|
||||
let fixture: ComponentFixture<MonetaryComponent>
|
||||
let input: HTMLInputElement
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [MonetaryComponent],
|
||||
providers: [CurrencyPipe],
|
||||
imports: [FormsModule, ReactiveFormsModule, HttpClientTestingModule],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(MonetaryComponent)
|
||||
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
input = component.inputField.nativeElement
|
||||
})
|
||||
|
||||
it('should set the currency code correctly', () => {
|
||||
expect(component.currencyCode).toEqual('USD') // default
|
||||
component.currencyCode = 'EUR'
|
||||
expect(component.currencyCode).toEqual('EUR')
|
||||
|
||||
component.value = 'G123.4'
|
||||
jest
|
||||
.spyOn(document, 'activeElement', 'get')
|
||||
.mockReturnValue(component.currencyField.nativeElement)
|
||||
expect(component.currencyCode).toEqual('G')
|
||||
})
|
||||
|
||||
it('should parse monetary value only when out of focus', () => {
|
||||
component.monetaryValue = 10.5
|
||||
jest.spyOn(document, 'activeElement', 'get').mockReturnValue(null)
|
||||
expect(component.monetaryValue).toEqual('10.50')
|
||||
|
||||
component.value = 'GBP123.4'
|
||||
jest
|
||||
.spyOn(document, 'activeElement', 'get')
|
||||
.mockReturnValue(component.inputField.nativeElement)
|
||||
expect(component.monetaryValue).toEqual('123.4')
|
||||
})
|
||||
|
||||
it('should report value including currency code and monetary value', () => {
|
||||
component.currencyCode = 'EUR'
|
||||
component.monetaryValue = 10.5
|
||||
expect(component.value).toEqual('EUR10.50')
|
||||
})
|
||||
|
||||
it('should set the default currency code based on LOCALE_ID', () => {
|
||||
expect(component.defaultCurrencyCode).toEqual('USD') // default
|
||||
component = new MonetaryComponent('pt-BR')
|
||||
expect(component.defaultCurrencyCode).toEqual('BRL')
|
||||
})
|
||||
})
|
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
forwardRef,
|
||||
Inject,
|
||||
LOCALE_ID,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||
import { AbstractInputComponent } from '../abstract-input'
|
||||
import { getLocaleCurrencyCode } from '@angular/common'
|
||||
|
||||
@Component({
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => MonetaryComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
selector: 'pngx-input-monetary',
|
||||
templateUrl: './monetary.component.html',
|
||||
styleUrls: ['./monetary.component.scss'],
|
||||
})
|
||||
export class MonetaryComponent extends AbstractInputComponent<string> {
|
||||
@ViewChild('currencyField')
|
||||
currencyField: ElementRef
|
||||
defaultCurrencyCode: string
|
||||
|
||||
constructor(@Inject(LOCALE_ID) currentLocale: string) {
|
||||
super()
|
||||
|
||||
this.defaultCurrencyCode = getLocaleCurrencyCode(currentLocale)
|
||||
}
|
||||
|
||||
get currencyCode(): string {
|
||||
const focused = document.activeElement === this.currencyField?.nativeElement
|
||||
if (focused && this.value) return this.value.match(/^([A-Z]{0,3})/)?.[0]
|
||||
return (
|
||||
this.value
|
||||
?.toString()
|
||||
.toUpperCase()
|
||||
.match(/^([A-Z]{1,3})/)?.[0] ?? this.defaultCurrencyCode
|
||||
)
|
||||
}
|
||||
|
||||
set currencyCode(value: string) {
|
||||
this.value = value + this.monetaryValue?.toString()
|
||||
}
|
||||
|
||||
get monetaryValue(): string {
|
||||
if (!this.value) return null
|
||||
const focused = document.activeElement === this.inputField?.nativeElement
|
||||
const val = parseFloat(this.value.toString().replace(/[^0-9.,]+/g, ''))
|
||||
return focused ? val.toString() : val.toFixed(2)
|
||||
}
|
||||
|
||||
set monetaryValue(value: number) {
|
||||
this.value = this.currencyCode + value.toFixed(2)
|
||||
}
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
<div class="paperless-input-select">
|
||||
<div class="paperless-input-select" [class.disabled]="disabled">
|
||||
<div>
|
||||
<ng-select name="inputId" [(ngModel)]="value"
|
||||
[disabled]="disabled"
|
||||
|
@@ -0,0 +1,11 @@
|
||||
.paperless-input-select.disabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
::ng-deep ng-select {
|
||||
pointer-events: none;
|
||||
|
||||
.ng-select-container {
|
||||
background-color: var(--pngx-bg-alt) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
<div class="paperless-input-select">
|
||||
<div class="paperless-input-select" [class.disabled]="disabled">
|
||||
<div>
|
||||
<ng-select name="inputId" [(ngModel)]="value"
|
||||
[disabled]="disabled"
|
||||
|
@@ -0,0 +1,11 @@
|
||||
.paperless-input-select.disabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
::ng-deep ng-select {
|
||||
pointer-events: none;
|
||||
|
||||
.ng-select-container {
|
||||
background-color: var(--pngx-bg-alt) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
// styles for ng-select child are in styles.scss
|
||||
.paperless-input-select.disabled {
|
||||
.input-group {
|
||||
.input-group,
|
||||
div > div {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
@@ -118,4 +118,18 @@ describe('SelectComponent', () => {
|
||||
tick(3000)
|
||||
expect(clearSpy).toHaveBeenCalled()
|
||||
}))
|
||||
|
||||
it('should emit filtered documents', () => {
|
||||
component.value = 10
|
||||
component.items = items
|
||||
const emitSpy = jest.spyOn(component.filterDocuments, 'emit')
|
||||
component.onFilterDocuments()
|
||||
expect(emitSpy).toHaveBeenCalledWith([items[2]])
|
||||
})
|
||||
|
||||
it('should return the correct filter button title', () => {
|
||||
component.title = 'Tag'
|
||||
const expectedTitle = `Filter documents with this ${component.title}`
|
||||
expect(component.filterButtonTitle).toEqual(expectedTitle)
|
||||
})
|
||||
})
|
||||
|
@@ -169,4 +169,12 @@ describe('TagsComponent', () => {
|
||||
expect(component.getTag(2)).toEqual(tags[1])
|
||||
expect(component.getTag(4)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should emit filtered documents', () => {
|
||||
component.value = [10]
|
||||
component.tags = tags
|
||||
const emitSpy = jest.spyOn(component.filterDocuments, 'emit')
|
||||
component.onFilterDocuments()
|
||||
expect(emitSpy).toHaveBeenCalledWith([tags[2]])
|
||||
})
|
||||
})
|
||||
|
@@ -1,5 +1,5 @@
|
||||
@if (customLogo) {
|
||||
<img src="{{customLogo}}" height="100%" width="100%" [attr.style]="'height:'+height" />
|
||||
<img src="{{customLogo}}" [class]="getClasses()" [attr.style]="'height:'+height" />
|
||||
} @else {
|
||||
<svg [class]="getClasses()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" [attr.style]="'height:'+height">
|
||||
<path class="leaf" d="M140,713.7c-3.4-16.4-10.3-49.1-11.2-49.1c-145.7-87.1-128.4-238-80.2-324.2C59,449,251.2,524,139.1,656.8 c-0.9,1.7,5.2,22.4,10.3,41.4c22.4-37.9,56-83.6,54.3-87.9C65.9,273.9,496.9,248.1,586.6,39.4c40.5,201.8-20.7,513.9-367.2,593.2 c-1.7,0.9-62.9,108.6-65.5,109.5c0-1.7-25.9-0.9-22.4-9.5C133.1,727.4,136.6,720.6,140,713.7L140,713.7z M135.7,632.6 c44-50.9-7.8-137.9-38.8-166.4C149.5,556.7,146,609.3,135.7,632.6L135.7,632.6z" transform="translate(0)" style="fill:#17541f"/>
|
||||
|
@@ -19,7 +19,7 @@
|
||||
}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="btn-toolbar col col-md-auto">
|
||||
<div class="btn-toolbar col col-md-auto gap-2">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -0,0 +1,158 @@
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modal-basic-title" i18n>System Status</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (!status) {
|
||||
<div class="w-100 h-100 d-flex align-items-center justify-content-center">
|
||||
<div>
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<ng-container i18n>Loading...</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="row row-cols-1 row-cols-md-3 g-3">
|
||||
<div class="col">
|
||||
<div class="card bg-light h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0" i18n>Environment</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="card-text">
|
||||
<dt i18n>Paperless-ngx Version</dt>
|
||||
<dd>{{status.pngx_version}}</dd>
|
||||
<dt i18n>Install Type</dt>
|
||||
<dd>{{status.install_type}}</dd>
|
||||
<dt i18n>Server OS</dt>
|
||||
<dd>{{status.server_os}}</dd>
|
||||
<dt i18n>Media Storage</dt>
|
||||
<dd>
|
||||
<ngb-progressbar style="height: 4px;" class="mt-2 mb-1" type="primary" [max]="status.storage.total" [value]="status.storage.total - status.storage.available"></ngb-progressbar>
|
||||
<span class="small">{{status.storage.available | filesize}} <ng-container i18n>available</ng-container> ({{status.storage.total | filesize}} <ng-container i18n>total</ng-container>)</span>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="card bg-light h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0" i18n>Database</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="card-text">
|
||||
<dt i18n>Type</dt>
|
||||
<dd>{{status.database.type}}</dd>
|
||||
<dt i18n>Status</dt>
|
||||
<dd class="d-flex align-items-center">
|
||||
{{status.database.status}}
|
||||
@if (status.database.status === 'OK') {
|
||||
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" ngbPopover="{{status.database.url}}" triggers="mouseenter:mouseleave"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.database.url}}: {{status.database.error}}" triggers="mouseenter:mouseleave"></i-bs>
|
||||
}
|
||||
</dd>
|
||||
<dt i18n>Migration Status</dt>
|
||||
<dd class="d-flex align-items-center">
|
||||
@if (status.database.migration_status.unapplied_migrations.length === 0) {
|
||||
<ng-container i18n>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"></i-bs>
|
||||
} @else {
|
||||
<ng-container>{{status.database.migration_status.unapplied_migrations.length}} Pending</ng-container><i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"></i-bs>
|
||||
}
|
||||
<ng-template #migrationStatus>
|
||||
<h6><ng-container i18n>Latest Migration</ng-container>:</h6> <span class="font-monospace small">{{status.database.migration_status.latest_migration}}</span>
|
||||
@if (status.database.migration_status.unapplied_migrations.length > 0) {
|
||||
<h6 class="mt-3"><ng-container i18n>Pending Migrations</ng-container>:</h6>
|
||||
<ul>
|
||||
@for (migration of status.database.migration_status.unapplied_migrations; track migration) {
|
||||
<li class="font-monospace small">{{migration}}</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</ng-template>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<div class="card bg-light h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0" i18n>Tasks</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="card-text">
|
||||
<dt i18n>Redis Status</dt>
|
||||
<dd class="d-flex align-items-center">
|
||||
{{status.tasks.redis_status}}
|
||||
@if (status.tasks.redis_status === 'OK') {
|
||||
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" ngbPopover="{{status.tasks.redis_url}}" triggers="mouseenter:mouseleave"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.tasks.redis_url}}: {{status.tasks.redis_error}}" triggers="mouseenter:mouseleave"></i-bs>
|
||||
}
|
||||
</dd>
|
||||
<dt i18n>Celery Status</dt>
|
||||
<dd class="d-flex align-items-center">
|
||||
{{status.tasks.celery_status}}
|
||||
@if (status.tasks.celery_status === 'OK') {
|
||||
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
|
||||
}
|
||||
</dd>
|
||||
<dt i18n>Search Index</dt>
|
||||
<dd class="d-flex align-items-center">
|
||||
{{status.tasks.index_status}}
|
||||
@if (status.tasks.index_status === 'OK') {
|
||||
@if (isStale(status.tasks.index_last_modified)) {
|
||||
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"></i-bs>
|
||||
}
|
||||
} @else {
|
||||
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.tasks.index_error}}" triggers="mouseenter:mouseleave"></i-bs>
|
||||
}
|
||||
</dd>
|
||||
<ng-template #indexStatus>
|
||||
<h6><ng-container i18n>Last Updated</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_last_modified | customDate:'medium'}}</span>
|
||||
</ng-template>
|
||||
<dt i18n>Classifier</dt>
|
||||
<dd class="d-flex align-items-center">
|
||||
{{status.tasks.classifier_status}}
|
||||
@if (status.tasks.classifier_status === 'OK') {
|
||||
@if (isStale(status.tasks.classifier_last_trained)) {
|
||||
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave"></i-bs>
|
||||
}
|
||||
} @else {
|
||||
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
|
||||
[class.text-danger]="status.tasks.classifier_status === SystemStatusItemStatus.ERROR"
|
||||
[class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING"
|
||||
ngbPopover="{{status.tasks.classifier_error}}"
|
||||
triggers="mouseenter:mouseleave"></i-bs>
|
||||
}
|
||||
</dd>
|
||||
<ng-template #classifierStatus>
|
||||
<h6><ng-container i18n>Last Trained</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_last_trained | customDate:'medium'}}</span>
|
||||
</ng-template>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="copy()">
|
||||
@if (!copied) {
|
||||
<i-bs name="clipboard-fill"></i-bs>
|
||||
}
|
||||
@if (copied) {
|
||||
<i-bs name="clipboard-check-fill"></i-bs>
|
||||
}
|
||||
<ng-container i18n>Copy</ng-container>
|
||||
</button>
|
||||
</div>
|
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import {
|
||||
NgbActiveModal,
|
||||
NgbModalModule,
|
||||
NgbPopoverModule,
|
||||
NgbProgressbarModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'
|
||||
import { SystemStatusDialogComponent } from './system-status-dialog.component'
|
||||
import {
|
||||
SystemStatusItemStatus,
|
||||
InstallType,
|
||||
SystemStatus,
|
||||
} from 'src/app/data/system-status'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { NgxFilesizeModule } from 'ngx-filesize'
|
||||
|
||||
const status: SystemStatus = {
|
||||
pngx_version: '2.4.3',
|
||||
server_os: 'macOS-14.1.1-arm64-arm-64bit',
|
||||
install_type: InstallType.BareMetal,
|
||||
storage: { total: 494384795648, available: 13573525504 },
|
||||
database: {
|
||||
type: 'sqlite',
|
||||
url: '/paperless-ngx/data/db.sqlite3',
|
||||
status: SystemStatusItemStatus.ERROR,
|
||||
error: null,
|
||||
migration_status: {
|
||||
latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data',
|
||||
unapplied_migrations: [],
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
redis_url: 'redis://localhost:6379',
|
||||
redis_status: SystemStatusItemStatus.ERROR,
|
||||
redis_error: 'Error 61 connecting to localhost:6379. Connection refused.',
|
||||
celery_status: SystemStatusItemStatus.ERROR,
|
||||
index_status: SystemStatusItemStatus.OK,
|
||||
index_last_modified: new Date().toISOString(),
|
||||
index_error: null,
|
||||
classifier_status: SystemStatusItemStatus.OK,
|
||||
classifier_last_trained: new Date().toISOString(),
|
||||
classifier_error: null,
|
||||
},
|
||||
}
|
||||
|
||||
describe('SystemStatusDialogComponent', () => {
|
||||
let component: SystemStatusDialogComponent
|
||||
let fixture: ComponentFixture<SystemStatusDialogComponent>
|
||||
let clipboard: Clipboard
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [SystemStatusDialogComponent],
|
||||
providers: [NgbActiveModal],
|
||||
imports: [
|
||||
NgbModalModule,
|
||||
ClipboardModule,
|
||||
HttpClientTestingModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
NgxFilesizeModule,
|
||||
NgbPopoverModule,
|
||||
NgbProgressbarModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(SystemStatusDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
component.status = status
|
||||
clipboard = TestBed.inject(Clipboard)
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should close the active modal', () => {
|
||||
const closeSpy = jest.spyOn(component.activeModal, 'close')
|
||||
component.close()
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should copy the system status to clipboard', fakeAsync(() => {
|
||||
jest.spyOn(clipboard, 'copy')
|
||||
component.copy()
|
||||
expect(clipboard.copy).toHaveBeenCalledWith(
|
||||
JSON.stringify(component.status)
|
||||
)
|
||||
expect(component.copied).toBeTruthy()
|
||||
tick(3000)
|
||||
expect(component.copied).toBeFalsy()
|
||||
}))
|
||||
|
||||
it('should calculate if date is stale', () => {
|
||||
const date = new Date()
|
||||
date.setHours(date.getHours() - 25)
|
||||
expect(component.isStale(date.toISOString())).toBeTruthy()
|
||||
expect(component.isStale(date.toISOString(), 26)).toBeFalsy()
|
||||
})
|
||||
})
|
@@ -0,0 +1,43 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import {
|
||||
SystemStatus,
|
||||
SystemStatusItemStatus,
|
||||
} from 'src/app/data/system-status'
|
||||
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-system-status-dialog',
|
||||
templateUrl: './system-status-dialog.component.html',
|
||||
styleUrl: './system-status-dialog.component.scss',
|
||||
})
|
||||
export class SystemStatusDialogComponent {
|
||||
public SystemStatusItemStatus = SystemStatusItemStatus
|
||||
public status: SystemStatus
|
||||
|
||||
public copied: boolean = false
|
||||
|
||||
constructor(
|
||||
public activeModal: NgbActiveModal,
|
||||
private clipboard: Clipboard
|
||||
) {}
|
||||
|
||||
public close() {
|
||||
this.activeModal.close()
|
||||
}
|
||||
|
||||
public copy() {
|
||||
this.clipboard.copy(JSON.stringify(this.status))
|
||||
this.copied = true
|
||||
setTimeout(() => {
|
||||
this.copied = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
public isStale(dateStr: string, hours: number = 24): boolean {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
return now.getTime() - date.getTime() > hours * 60 * 60 * 1000
|
||||
}
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
<pngx-page-header title="Dashboard" [subTitle]="subtitle" i18n-title>
|
||||
<pngx-logo extra_classes="d-none d-md-block mt-n2 me-1" height="3.5rem"></pngx-logo>
|
||||
<pngx-logo extra_classes="d-none d-md-block mt-n2" height="3.5rem"></pngx-logo>
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row">
|
||||
|
@@ -6,7 +6,7 @@
|
||||
<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" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
|
||||
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none max-vh100-40 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="card shadow-sm consumer-status-card">
|
||||
<div class="card-body">
|
||||
|
@@ -39,3 +39,7 @@ form {
|
||||
.consumer-status-card {
|
||||
background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
|
||||
}
|
||||
|
||||
.max-vh100-40 {
|
||||
max-height: calc(100vh - 40px);
|
||||
}
|
||||
|
@@ -119,6 +119,8 @@ describe('UploadFileWidgetComponent', () => {
|
||||
const processingStatus = new FileStatus()
|
||||
processingStatus.phase = FileStatusPhase.WORKING
|
||||
expect(component.getStatusColor(processingStatus)).toEqual('primary')
|
||||
processingStatus.phase = FileStatusPhase.UPLOADING
|
||||
expect(component.getStatusColor(processingStatus)).toEqual('primary')
|
||||
const failedStatus = new FileStatus()
|
||||
failedStatus.phase = FileStatusPhase.FAILED
|
||||
expect(component.getStatusColor(failedStatus)).toEqual('danger')
|
||||
|
@@ -1,11 +1,13 @@
|
||||
<pngx-page-header [(title)]="title">
|
||||
@if (contentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
|
||||
<div class="input-group input-group-sm me-2 d-none d-md-flex">
|
||||
<div class="input-group-text" i18n>Page</div>
|
||||
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" />
|
||||
<div class="input-group-text" i18n>of {{previewNumPages}}</div>
|
||||
</div>
|
||||
<div class="input-group input-group-sm me-5 d-none d-md-flex">
|
||||
@if (previewNumPages) {
|
||||
<div class="input-group input-group-sm d-none d-md-flex">
|
||||
<div class="input-group-text" i18n>Page</div>
|
||||
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" />
|
||||
<div class="input-group-text" i18n>of {{previewNumPages}}</div>
|
||||
</div>
|
||||
}
|
||||
<div class="input-group input-group-sm me-md-5 d-none d-md-flex">
|
||||
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button>
|
||||
<select class="form-select" (change)="onZoomSelect($event)">
|
||||
@for (setting of zoomSettings; track setting) {
|
||||
@@ -18,11 +20,11 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-danger me-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger me-md-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
|
||||
</button>
|
||||
|
||||
<div class="btn-group me-2">
|
||||
<div class="btn-group">
|
||||
<a [href]="downloadUrl" class="btn btn-sm btn-outline-primary">
|
||||
<i-bs width="1.2em" height="1.2em" name="download"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Download</span>
|
||||
</a>
|
||||
@@ -38,24 +40,31 @@
|
||||
</div>
|
||||
|
||||
<div class="ms-auto" ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary me-2" id="actionsDropdown" ngbDropdownToggle>
|
||||
<button class="btn btn-sm btn-outline-primary" id="actionsDropdown" ngbDropdownToggle>
|
||||
<i-bs name="three-dots"></i-bs>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
|
||||
<button ngbDropdownItem (click)="redoOcr()" [disabled]="!userCanEdit">
|
||||
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs><span class="ps-1" i18n>Redo OCR</span>
|
||||
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs> <span i18n>Redo OCR</span>
|
||||
</button>
|
||||
|
||||
<button ngbDropdownItem (click)="moreLike()">
|
||||
<i-bs width="1em" height="1em" name="diagram-3"></i-bs><span class="ps-1" i18n>More like this</span>
|
||||
<i-bs width="1em" height="1em" name="diagram-3"></i-bs> <span i18n>More like this</span>
|
||||
</button>
|
||||
|
||||
<button ngbDropdownItem (click)="splitDocument()" [disabled]="contentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
|
||||
<i-bs width="1em" height="1em" name="scissors"></i-bs> <span i18n>Split</span>
|
||||
</button>
|
||||
|
||||
<button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || contentRenderType !== ContentRenderType.PDF">
|
||||
<i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pngx-custom-fields-dropdown
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
||||
class="me-2"
|
||||
[documentId]="documentId"
|
||||
[disabled]="!userIsOwner"
|
||||
[existingFields]="document?.custom_fields"
|
||||
@@ -142,14 +151,12 @@
|
||||
[error]="getCustomFieldError(i)"></pngx-input-number>
|
||||
}
|
||||
@case (CustomFieldDataType.Monetary) {
|
||||
<pngx-input-number formControlName="value"
|
||||
<pngx-input-monetary formControlName="value"
|
||||
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
|
||||
[removable]="userIsOwner"
|
||||
(removed)="removeField(fieldInstance)"
|
||||
[horizontal]="true"
|
||||
[showAdd]="false"
|
||||
[step]=".01"
|
||||
[error]="getCustomFieldError(i)"></pngx-input-number>
|
||||
[error]="getCustomFieldError(i)"></pngx-input-monetary>
|
||||
}
|
||||
@case (CustomFieldDataType.Boolean) {
|
||||
<pngx-input-check formControlName="value"
|
||||
|
@@ -75,6 +75,8 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service
|
||||
import { PdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
||||
|
||||
const doc: Document = {
|
||||
id: 3,
|
||||
@@ -171,6 +173,8 @@ describe('DocumentDetailComponent', () => {
|
||||
ShareLinksDropdownComponent,
|
||||
CustomFieldsDropdownComponent,
|
||||
PdfViewerComponent,
|
||||
SplitConfirmDialogComponent,
|
||||
RotateConfirmDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
DocumentTitlePipe,
|
||||
@@ -1070,6 +1074,58 @@ describe('DocumentDetailComponent', () => {
|
||||
).not.toBeUndefined()
|
||||
})
|
||||
|
||||
it('should support split', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
initNormally()
|
||||
component.splitDocument()
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.documentID = doc.id
|
||||
modal.componentInstance.totalPages = 5
|
||||
modal.componentInstance.page = 2
|
||||
modal.componentInstance.addSplit()
|
||||
modal.componentInstance.confirm()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [doc.id],
|
||||
method: 'split',
|
||||
parameters: { pages: '1-2,3-5' },
|
||||
})
|
||||
req.error(new ProgressEvent('failed'))
|
||||
modal.componentInstance.confirm()
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
})
|
||||
|
||||
it('should support rotate', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
initNormally()
|
||||
component.rotateDocument()
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.documentID = doc.id
|
||||
modal.componentInstance.rotate()
|
||||
modal.componentInstance.confirm()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [doc.id],
|
||||
method: 'rotate',
|
||||
parameters: { degrees: 90 },
|
||||
})
|
||||
req.error(new ProgressEvent('failed'))
|
||||
modal.componentInstance.confirm()
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
})
|
||||
|
||||
function initNormally() {
|
||||
jest
|
||||
.spyOn(activatedRoute, 'paramMap', 'get')
|
||||
|
@@ -67,6 +67,8 @@ import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { PDFDocumentProxy } from '../common/pdf-viewer/typings'
|
||||
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
||||
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
|
||||
enum DocumentDetailNavIDs {
|
||||
Details = 1,
|
||||
@@ -147,7 +149,7 @@ export class DocumentDetailComponent
|
||||
})
|
||||
|
||||
previewCurrentPage: number = 1
|
||||
previewNumPages: number = 1
|
||||
previewNumPages: number
|
||||
previewZoomSetting: ZoomSetting = ZoomSetting.One
|
||||
previewZoomScale: ZoomSetting = ZoomSetting.PageWidth
|
||||
|
||||
@@ -634,11 +636,14 @@ export class DocumentDetailComponent
|
||||
// in case data changed while saving eg removing inbox_tags
|
||||
this.documentForm.patchValue(docValues)
|
||||
this.store.next(this.documentForm.value)
|
||||
this.openDocumentService.setDirty(this.document, false)
|
||||
this.toastService.showInfo($localize`Document saved successfully.`)
|
||||
close && this.close()
|
||||
this.networkActive = false
|
||||
this.error = null
|
||||
this.openDocumentService.refreshDocument(this.documentId)
|
||||
close &&
|
||||
this.close(() =>
|
||||
this.openDocumentService.refreshDocument(this.documentId)
|
||||
)
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive = false
|
||||
@@ -693,12 +698,13 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
|
||||
close() {
|
||||
close(closedCallback: () => void = null) {
|
||||
this.openDocumentService
|
||||
.closeDocument(this.document)
|
||||
.pipe(first())
|
||||
.subscribe((closed) => {
|
||||
if (!closed) return
|
||||
if (closedCallback) closedCallback()
|
||||
if (this.documentListViewService.activeSavedViewId) {
|
||||
this.router.navigate([
|
||||
'view',
|
||||
@@ -1036,4 +1042,83 @@ export class DocumentDetailComponent
|
||||
this.updateFormForCustomFields(true)
|
||||
this.documentForm.updateValueAndValidity()
|
||||
}
|
||||
|
||||
splitDocument() {
|
||||
let modal = this.modalService.open(SplitConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Split confirm`
|
||||
modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.`
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.documentID = this.document.id
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.documentsService
|
||||
.bulkEdit([this.document.id], 'split', {
|
||||
pages: modal.componentInstance.pagesString,
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Split operation will begin in the background.`
|
||||
)
|
||||
modal.close()
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
}
|
||||
this.toastService.showError(
|
||||
$localize`Error executing split operation`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
rotateDocument() {
|
||||
let modal = this.modalService.open(RotateConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Rotate confirm`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of the current document.`
|
||||
modal.componentInstance.message = $localize`This will alter the original copy.`
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.documentID = this.document.id
|
||||
modal.componentInstance.showPDFNote = false
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.documentsService
|
||||
.bulkEdit([this.document.id], 'rotate', {
|
||||
degrees: modal.componentInstance.degrees,
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.show({
|
||||
content: $localize`Rotation will begin in the background. Close and re-open the document after the operation has completed to see the changes.`,
|
||||
delay: 8000,
|
||||
action: this.close.bind(this),
|
||||
actionName: $localize`Close`,
|
||||
})
|
||||
modal.close()
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
}
|
||||
this.toastService.showError(
|
||||
$localize`Error executing rotate operation`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -80,18 +80,26 @@
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
|
||||
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div>
|
||||
</button>
|
||||
</button>
|
||||
|
||||
<div ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
|
||||
<i-bs name="three-dots"></i-bs>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div>
|
||||
<div ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
|
||||
<i-bs name="three-dots"></i-bs>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||
<button ngbDropdownItem (click)="redoOcrSelected()" [disabled]="!userCanEditAll">
|
||||
<i-bs name="body-text"></i-bs> <ng-container i18n>Redo OCR</ng-container>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll">
|
||||
<i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanEditAll || list.selected.size < 2">
|
||||
<i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||
<button ngbDropdownItem (click)="redoOcrSelected()" [disabled]="!userCanEditAll" i18n>Redo OCR</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
||||
@@ -113,22 +121,16 @@
|
||||
<div class="form-group ps-3 mb-2">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
|
||||
<label class="form-check-label" for="downloadFileType_archive" i18n>
|
||||
Archived files
|
||||
</label>
|
||||
<label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
|
||||
<label class="form-check-label" for="downloadFileType_originals" i18n>
|
||||
Original files
|
||||
</label>
|
||||
<label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
|
||||
<label class="form-check-label" for="downloadUseFormatting" i18n>
|
||||
Use formatted filename
|
||||
</label>
|
||||
<label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
@@ -52,6 +52,9 @@ import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
|
||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||
|
||||
const selectionData: SelectionData = {
|
||||
selected_tags: [
|
||||
@@ -97,6 +100,9 @@ describe('BulkEditorComponent', () => {
|
||||
PermissionsGroupComponent,
|
||||
PermissionsUserComponent,
|
||||
SwitchComponent,
|
||||
RotateConfirmDialogComponent,
|
||||
IsNumberPipe,
|
||||
MergeConfirmDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
PermissionsService,
|
||||
@@ -818,6 +824,79 @@ describe('BulkEditorComponent', () => {
|
||||
) // listAllFilteredIds
|
||||
})
|
||||
|
||||
it('should support rotate', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'documents', 'get')
|
||||
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([3, 4]))
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||
.mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
component.rotateSelected()
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.rotate()
|
||||
modal.componentInstance.confirm()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
method: 'rotate',
|
||||
parameters: { degrees: 90 },
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
})
|
||||
|
||||
it('should support merge', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'documents', 'get')
|
||||
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||
jest
|
||||
.spyOn(documentService, 'getCachedMany')
|
||||
.mockReturnValue(of([{ id: 3 }, { id: 4 }]))
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([3, 4]))
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||
.mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
component.mergeSelected()
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.metadataDocumentID = 3
|
||||
modal.componentInstance.confirm()
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
method: 'merge',
|
||||
parameters: { metadata_document_id: 3 },
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
})
|
||||
|
||||
it('should support bulk download with archive, originals or both and file formatting', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
|
@@ -6,7 +6,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||
import {
|
||||
DocumentService,
|
||||
SelectionDataItem,
|
||||
@@ -39,6 +39,8 @@ import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-bulk-editor',
|
||||
@@ -192,12 +194,21 @@ export class BulkEditorComponent
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
|
||||
private executeBulkOperation(modal, method: string, args) {
|
||||
private executeBulkOperation(
|
||||
modal: NgbModalRef,
|
||||
method: string,
|
||||
args: any,
|
||||
overrideDocumentIDs?: number[]
|
||||
) {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
}
|
||||
this.documentService
|
||||
.bulkEdit(Array.from(this.list.selected), method, args)
|
||||
.bulkEdit(
|
||||
overrideDocumentIDs ?? Array.from(this.list.selected),
|
||||
method,
|
||||
args
|
||||
)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: () => {
|
||||
@@ -641,4 +652,49 @@ export class BulkEditorComponent
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
rotateSelected() {
|
||||
let modal = this.modalService.open(RotateConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
|
||||
rotateDialog.title = $localize`Rotate confirm`
|
||||
rotateDialog.messageBold = $localize`This operation will permanently rotate the original version of ${this.list.selected.size} document(s).`
|
||||
rotateDialog.message = $localize`This will alter the original copy.`
|
||||
rotateDialog.btnClass = 'btn-danger'
|
||||
rotateDialog.btnCaption = $localize`Proceed`
|
||||
rotateDialog.documentID = Array.from(this.list.selected)[0]
|
||||
rotateDialog.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
rotateDialog.buttonsEnabled = false
|
||||
this.executeBulkOperation(modal, 'rotate', {
|
||||
degrees: rotateDialog.degrees,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
mergeSelected() {
|
||||
let modal = this.modalService.open(MergeConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
const mergeDialog = modal.componentInstance as MergeConfirmDialogComponent
|
||||
mergeDialog.title = $localize`Merge confirm`
|
||||
mergeDialog.messageBold = $localize`This operation will merge ${this.list.selected.size} selected documents into a new document.`
|
||||
mergeDialog.btnCaption = $localize`Proceed`
|
||||
mergeDialog.documentIDs = Array.from(this.list.selected)
|
||||
mergeDialog.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
const args = {}
|
||||
if (mergeDialog.metadataDocumentID > -1) {
|
||||
args['metadata_document_id'] = mergeDialog.metadataDocumentID
|
||||
}
|
||||
mergeDialog.buttonsEnabled = false
|
||||
this.executeBulkOperation(modal, 'merge', args, mergeDialog.documentIDs)
|
||||
this.toastService.showInfo(
|
||||
$localize`Merged document will be queued for consumption.`
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
<div class="card mb-3 shadow-sm" [class.card-selected]="selected" [class.document-card]="selectable" [class.popover-hidden]="popoverHidden" (mouseleave)="mouseLeaveCard()">
|
||||
<div class="card mb-3 shadow-sm bg-light" [class.card-selected]="selected" [class.document-card]="selectable" [class.popover-hidden]="popoverHidden" (mouseleave)="mouseLeaveCard()">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-2 doc-img-background rounded-start" [class.doc-img-background-selected]="selected" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
|
||||
<img [src]="getThumbUrl()" class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
|
||||
@@ -12,8 +12,7 @@
|
||||
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card-body bg-light">
|
||||
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title">
|
||||
@if (document.correspondent) {
|
||||
@@ -45,82 +44,79 @@
|
||||
}
|
||||
</p>
|
||||
|
||||
|
||||
<div class="d-flex flex-column flex-md-row align-items-md-center">
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()">
|
||||
<i-bs name="diagram-3"></i-bs> <span class="d-none d-md-inline" i18n>More like this</span>
|
||||
<i-bs name="diagram-3"></i-bs> <span class="d-none d-md-inline" i18n>More like this</span>
|
||||
</a>
|
||||
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<i-bs name="pencil"></i-bs> <span class="d-none d-md-inline" i18n>Edit</span>
|
||||
</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" target="_blank" [href]="previewUrl"
|
||||
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle"
|
||||
autoClose="true" popoverClass="shadow popover-preview" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
|
||||
<i-bs name="eye"></i-bs> <span class="d-none d-md-inline" i18n>View</span>
|
||||
</a>
|
||||
<ng-template #previewContent>
|
||||
<pngx-preview-popup [document]="document"></pngx-preview-popup>
|
||||
</ng-template>
|
||||
<a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
|
||||
<i-bs name="download"></i-bs> <span class="d-none d-md-inline" i18n>Download</span>
|
||||
</a>
|
||||
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<i-bs name="pencil"></i-bs> <span class="d-none d-md-inline" i18n>Edit</span>
|
||||
</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" target="_blank" [href]="previewUrl"
|
||||
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle"
|
||||
autoClose="true" popoverClass="shadow popover-preview" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
|
||||
<i-bs name="eye"></i-bs> <span class="d-none d-md-inline" i18n>View</span>
|
||||
</a>
|
||||
<ng-template #previewContent>
|
||||
<pngx-preview-popup [document]="document"></pngx-preview-popup>
|
||||
</ng-template>
|
||||
<a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
|
||||
<i-bs name="download"></i-bs> <span class="d-none d-md-inline" i18n>Download</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0">
|
||||
@if (notesEnabled && document.notes.length) {
|
||||
<button routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="View notes" i18n-title>
|
||||
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="chat-left-text"></i-bs><small i18n>{{document.notes.length}} Notes</small>
|
||||
</button>
|
||||
}
|
||||
@if (document.document_type) {
|
||||
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by document type" i18n-title
|
||||
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
|
||||
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="file-earmark"></i-bs><small>{{(document.document_type$ | async)?.name}}</small>
|
||||
</button>
|
||||
}
|
||||
@if (document.storage_path) {
|
||||
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by storage path" i18n-title
|
||||
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
|
||||
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="archive"></i-bs><small>{{(document.storage_path$ | async)?.name}}</small>
|
||||
</button>
|
||||
}
|
||||
@if (document.archive_serial_number | isNumber) {
|
||||
<div class="list-group-item me-2 bg-light text-dark p-1 border-0 d-flex align-items-center">
|
||||
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="upc-scan"></i-bs><small>#{{document.archive_serial_number}}</small>
|
||||
</div>
|
||||
}
|
||||
<ng-template #dateTooltip>
|
||||
<div class="d-flex flex-column text-light">
|
||||
<span i18n>Created: {{ document.created | customDate }}</span>
|
||||
<span i18n>Added: {{ document.added | customDate }}</span>
|
||||
<span i18n>Modified: {{ document.modified | customDate }}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
|
||||
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created_date | customDate:'mediumDate'}}</small>
|
||||
</div>
|
||||
@if (document.owner && document.owner !== settingsService.currentUser.id) {
|
||||
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
|
||||
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="person-fill-lock"></i-bs><small>{{document.owner | username}}</small>
|
||||
</div>
|
||||
}
|
||||
@if (document.is_shared_by_requester) {
|
||||
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
|
||||
<i-bs width="09.rem" height="0.9rem" class="me-2 text-muted" name="people-fill"></i-bs><small i18n>Shared</small>
|
||||
</div>
|
||||
}
|
||||
@if (document.__search_hit__?.score) {
|
||||
<div class="list-group-item bg-light text-dark border-0 d-flex p-0 ps-4 search-score">
|
||||
<small class="text-muted" i18n>Score:</small>
|
||||
<ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0">
|
||||
@if (notesEnabled && document.notes.length) {
|
||||
<button routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="View notes" i18n-title>
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="chat-left-text"></i-bs><small i18n>{{document.notes.length}} Notes</small>
|
||||
</button>
|
||||
}
|
||||
@if (document.document_type) {
|
||||
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by document type" i18n-title
|
||||
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="file-earmark"></i-bs><small>{{(document.document_type$ | async)?.name}}</small>
|
||||
</button>
|
||||
}
|
||||
@if (document.storage_path) {
|
||||
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by storage path" i18n-title
|
||||
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="archive"></i-bs><small>{{(document.storage_path$ | async)?.name}}</small>
|
||||
</button>
|
||||
}
|
||||
@if (document.archive_serial_number | isNumber) {
|
||||
<div class="list-group-item me-2 bg-light text-dark p-1 border-0 d-flex align-items-center">
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="upc-scan"></i-bs><small>#{{document.archive_serial_number}}</small>
|
||||
</div>
|
||||
|
||||
}
|
||||
<ng-template #dateTooltip>
|
||||
<div class="d-flex flex-column text-light">
|
||||
<span i18n>Created: {{ document.created | customDate }}</span>
|
||||
<span i18n>Added: {{ document.added | customDate }}</span>
|
||||
<span i18n>Modified: {{ document.modified | customDate }}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created_date | customDate:'mediumDate'}}</small>
|
||||
</div>
|
||||
@if (document.owner && document.owner !== settingsService.currentUser.id) {
|
||||
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="person-fill-lock"></i-bs><small>{{document.owner | username}}</small>
|
||||
</div>
|
||||
}
|
||||
@if (document.is_shared_by_requester) {
|
||||
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="people-fill"></i-bs><small i18n>Shared</small>
|
||||
</div>
|
||||
}
|
||||
@if (document.__search_hit__?.score) {
|
||||
<div class="list-group-item bg-light text-dark border-0 d-flex p-0 ps-4 search-score">
|
||||
<small class="me-2 text-muted" i18n>Score:</small>
|
||||
<ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<pngx-page-header [title]="getTitle()">
|
||||
|
||||
<div ngbDropdown class="me-2 d-flex">
|
||||
<div ngbDropdown class="d-flex">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
|
||||
<i-bs name="text-indent-left"></i-bs>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div>
|
||||
@@ -26,7 +26,7 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div ngbDropdown class="btn-group ms-2 flex-fill">
|
||||
<div ngbDropdown class="btn-group flex-fill">
|
||||
<button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle i18n>Sort</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right">
|
||||
<div class="w-100 d-flex pb-2 mb-1 border-bottom">
|
||||
@@ -49,7 +49,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group ms-2 flex-fill" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" ngbDropdown role="group">
|
||||
<div class="btn-group flex-fill" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" ngbDropdown role="group">
|
||||
<button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" tourAnchor="tour.documents-views" ngbDropdownToggle>
|
||||
<ng-container i18n>Views</ng-container>
|
||||
@if (savedViewIsModified) {
|
||||
@@ -99,7 +99,7 @@
|
||||
@if (list.selected.size === 0) {
|
||||
<span i18n>{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span>
|
||||
} @if (isFiltered) {
|
||||
<span i18n>(filtered)</span>
|
||||
<span i18n>(filtered)</span>
|
||||
}
|
||||
}
|
||||
@if (!list.isReloading && isFiltered) {
|
||||
|
@@ -86,7 +86,7 @@ export class DocumentListComponent
|
||||
}
|
||||
|
||||
get isFiltered() {
|
||||
return this.list.filterRules?.length > 0
|
||||
return !!this.filterEditor?.rulesModified
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
|
@@ -381,6 +381,28 @@ describe('FilterEditorComponent', () => {
|
||||
expect(component.textFilter).toBeNull()
|
||||
}))
|
||||
|
||||
it('should ingest text filter content with relative dates that are not in quick list', fakeAsync(() => {
|
||||
expect(component.dateAddedRelativeDate).toBeNull()
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: 'added:[-2 week to now]',
|
||||
},
|
||||
]
|
||||
expect(component.dateAddedRelativeDate).toBeNull()
|
||||
expect(component.textFilter).toEqual('added:[-2 week to now]')
|
||||
|
||||
expect(component.dateCreatedRelativeDate).toBeNull()
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: 'created:[-2 week to now]',
|
||||
},
|
||||
]
|
||||
expect(component.dateCreatedRelativeDate).toBeNull()
|
||||
expect(component.textFilter).toEqual('created:[-2 week to now]')
|
||||
}))
|
||||
|
||||
it('should ingest text filter rules for more like', fakeAsync(() => {
|
||||
const moreLikeSpy = jest.spyOn(documentService, 'get')
|
||||
moreLikeSpy.mockReturnValue(of({ id: 1, title: 'Foo Bar' }))
|
||||
@@ -1372,6 +1394,34 @@ describe('FilterEditorComponent', () => {
|
||||
])
|
||||
}))
|
||||
|
||||
it('should leave relative dates not in quick list intact', fakeAsync(() => {
|
||||
component.textFilterInput.nativeElement.value = 'created:[-2 week to now]'
|
||||
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
|
||||
const textFieldTargetDropdown = fixture.debugElement.queryAll(
|
||||
By.directive(NgbDropdownItem)
|
||||
)[4]
|
||||
textFieldTargetDropdown.triggerEventHandler('click')
|
||||
fixture.detectChanges()
|
||||
tick(400)
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: 'created:[-2 week to now]',
|
||||
},
|
||||
])
|
||||
|
||||
component.textFilterInput.nativeElement.value = 'added:[-2 month to now]'
|
||||
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
|
||||
fixture.detectChanges()
|
||||
tick(400)
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: 'added:[-2 month to now]',
|
||||
},
|
||||
])
|
||||
}))
|
||||
|
||||
it('should convert user input to correct filter rules on date added after', fakeAsync(() => {
|
||||
const dateAddedDropdown = fixture.debugElement.queryAll(
|
||||
By.directive(DateDropdownComponent)
|
||||
|
@@ -362,10 +362,11 @@ export class FilterEditorComponent
|
||||
this.dateCreatedRelativeDate =
|
||||
RELATIVE_DATE_QUERYSTRINGS.find(
|
||||
(qS) => qS.dateQuery == match[1]
|
||||
)?.relativeDate
|
||||
)?.relativeDate ?? null
|
||||
}
|
||||
}
|
||||
)
|
||||
if (this.dateCreatedRelativeDate === null) textQueryArgs.push(arg) // relative query not in the quick list
|
||||
} else if (arg.match(RELATIVE_DATE_QUERY_REGEXP_ADDED)) {
|
||||
;[...arg.matchAll(RELATIVE_DATE_QUERY_REGEXP_ADDED)].forEach(
|
||||
(match) => {
|
||||
@@ -373,10 +374,11 @@ export class FilterEditorComponent
|
||||
this.dateAddedRelativeDate =
|
||||
RELATIVE_DATE_QUERYSTRINGS.find(
|
||||
(qS) => qS.dateQuery == match[1]
|
||||
)?.relativeDate
|
||||
)?.relativeDate ?? null
|
||||
}
|
||||
}
|
||||
)
|
||||
if (this.dateAddedRelativeDate === null) textQueryArgs.push(arg) // relative query not in the quick list
|
||||
} else {
|
||||
textQueryArgs.push(arg)
|
||||
}
|
||||
@@ -787,27 +789,6 @@ export class FilterEditorComponent
|
||||
})
|
||||
}
|
||||
}
|
||||
if (
|
||||
this.dateCreatedRelativeDate == null &&
|
||||
this.dateAddedRelativeDate == null
|
||||
) {
|
||||
const existingRule = filterRules.find(
|
||||
(fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
|
||||
)
|
||||
if (
|
||||
existingRule?.value.match(RELATIVE_DATE_QUERY_REGEXP_CREATED) ||
|
||||
existingRule?.value.match(RELATIVE_DATE_QUERY_REGEXP_ADDED)
|
||||
) {
|
||||
// remove any existing date query
|
||||
existingRule.value = existingRule.value
|
||||
.replace(RELATIVE_DATE_QUERY_REGEXP_CREATED, '')
|
||||
.replace(RELATIVE_DATE_QUERY_REGEXP_ADDED, '')
|
||||
if (existingRule.value.replace(',', '').trim() === '') {
|
||||
// if its empty now, remove it entirely
|
||||
filterRules.splice(filterRules.indexOf(existingRule), 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SELF) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_OWNER,
|
||||
|
@@ -5,7 +5,7 @@
|
||||
i18n-info
|
||||
infoLink="usage/#custom-fields"
|
||||
>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editField()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="editField()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
|
||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Field</ng-container>
|
||||
</button>
|
||||
</pngx-page-header>
|
||||
@@ -23,7 +23,7 @@
|
||||
@for (field of fields; track field) {
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
|
||||
<div class="col d-flex align-items-center">{{getDataType(field)}}</div>
|
||||
<div class="col">
|
||||
<div class="btn-group">
|
||||
|
@@ -26,7 +26,7 @@
|
||||
@for (account of mailAccounts; track account) {
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailAccount)">{{account.name}}</button></div>
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailAccount(account)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailAccount)">{{account.name}}</button></div>
|
||||
<div class="col d-flex align-items-center">{{account.imap_server}}</div>
|
||||
<div class="col">
|
||||
<div class="btn-group">
|
||||
@@ -70,7 +70,7 @@
|
||||
@for (rule of mailRules; track rule) {
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule)">{{rule.name}}</button></div>
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule)">{{rule.name}}</button></div>
|
||||
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
|
||||
<div class="col">
|
||||
<div class="btn-group">
|
||||
|
@@ -1,126 +1,120 @@
|
||||
<pngx-page-header title="{{ typeNamePlural | titlecase }}">
|
||||
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedObjects.size === 0">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedObjects.size === 0">
|
||||
<i-bs name="x"></i-bs> <ng-container i18n>Clear selection</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || selectedObjects.size === 0">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
|
||||
<i-bs name="person-fill-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger me-5" (click)="delete()" [disabled]="!userOwnsAll || selectedObjects.size === 0">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
|
||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
|
||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Create</ng-container>
|
||||
</button>
|
||||
</pngx-page-header>
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md mb-2 mb-xl-0">
|
||||
<div class="form-inline d-flex align-items-center">
|
||||
<label class="text-muted me-2 mb-0" i18n>Filter by:</label>
|
||||
<input class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md mb-2 mb-xl-0">
|
||||
<div class="form-inline d-flex align-items-center">
|
||||
<label class="text-muted me-2 mb-0" i18n>Filter by:</label>
|
||||
<input class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border mb-3">
|
||||
<table class="table table-striped align-middle shadow-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<div class="form-check m-0 ms-2 me-n2">
|
||||
<input type="checkbox" class="form-check-input" id="all-objects" [disabled]="data.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="all-objects"></label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th>
|
||||
<th scope="col" class="fw-normal d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
|
||||
@for (column of extraColumns; track column) {
|
||||
<th scope="col" class="fw-normal" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
|
||||
}
|
||||
<th scope="col" class="fw-normal" i18n>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (isLoading) {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<ng-container i18n>Loading...</ng-container>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@for (object of data; track object) {
|
||||
<tr (click)="toggleSelected(object); $event.stopPropagation();">
|
||||
<td>
|
||||
<div class="form-check m-0 ms-2 me-n2">
|
||||
<input type="checkbox" class="form-check-input" id="{{typeName}}{{object.id}}" [checked]="selectedObjects.has(object.id)" (click)="toggleSelected(object); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
|
||||
</div>
|
||||
</td>
|
||||
<td scope="row"><button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null">{{ object.name }}</button> </td>
|
||||
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
||||
<td scope="row">{{ object.document_count }}</td>
|
||||
@for (column of extraColumns; track column) {
|
||||
<td scope="row">
|
||||
@if (column.rendersHtml) {
|
||||
<div [innerHtml]="column.valueFn.call(null, object) | safeHtml"></div>
|
||||
} @else {
|
||||
{{ column.valueFn.call(null, object) }}
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td scope="row">
|
||||
<div class="btn-group d-block d-sm-none">
|
||||
<div ngbDropdown class="d-inline-block">
|
||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" ngbDropdownToggle>
|
||||
<i-bs name="three-dots-vertical"></i-bs>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
||||
<button (click)="filterDocuments(object)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents</button>
|
||||
<button (click)="openEditDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" ngbDropdownItem i18n>Edit</button>
|
||||
<button class="text-danger" (click)="openDeleteDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" ngbDropdownItem i18n>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group d-none d-sm-block">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<i-bs width="1em" height="1em" name="filter"></i-bs> <ng-container i18n>Documents</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<pngx-confirm-button
|
||||
label="Delete"
|
||||
i18n-label
|
||||
(confirm)="deleteObject(object)"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }"
|
||||
[disabled]="!userCanDelete(object)"
|
||||
buttonClasses=" btn-sm btn-outline-danger"
|
||||
iconName="trash">
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
</div>
|
||||
|
||||
@if (!isLoading) {
|
||||
<div class="d-flex mb-2">
|
||||
@if (collectionSize > 0) {
|
||||
<div>
|
||||
<ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
||||
@if (selectedObjects.size > 0) {
|
||||
({{selectedObjects.size}} selected)
|
||||
}
|
||||
<div class="card border mb-3">
|
||||
<table class="table table-striped align-middle shadow-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<div class="form-check m-0 ms-2 me-n2">
|
||||
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="all-objects"></label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th>
|
||||
<th scope="col" class="fw-normal d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
|
||||
@for (column of extraColumns; track column) {
|
||||
<th scope="col" class="fw-normal" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
|
||||
}
|
||||
@if (collectionSize > 20) {
|
||||
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
<th scope="col" class="fw-normal" i18n>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (isLoading) {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<ng-container i18n>Loading...</ng-container>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@for (object of data; track object) {
|
||||
<tr (click)="toggleSelected(object); $event.stopPropagation();">
|
||||
<td>
|
||||
<div class="form-check m-0 ms-2 me-n2">
|
||||
<input type="checkbox" class="form-check-input" id="{{typeName}}{{object.id}}" [checked]="selectedObjects.has(object.id)" (click)="toggleSelected(object); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
|
||||
</div>
|
||||
</td>
|
||||
<td scope="row"><button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button> </td>
|
||||
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
||||
<td scope="row">{{ object.document_count }}</td>
|
||||
@for (column of extraColumns; track column) {
|
||||
<td scope="row">
|
||||
@if (column.rendersHtml) {
|
||||
<div [innerHtml]="column.valueFn.call(null, object) | safeHtml"></div>
|
||||
} @else {
|
||||
{{ column.valueFn.call(null, object) }}
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td scope="row">
|
||||
<div class="btn-group d-block d-sm-none">
|
||||
<div ngbDropdown class="d-inline-block">
|
||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||
<i-bs name="three-dots-vertical"></i-bs>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
||||
<button (click)="filterDocuments(object)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents</button>
|
||||
<button (click)="openEditDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" ngbDropdownItem i18n>Edit</button>
|
||||
<button class="text-danger" (click)="openDeleteDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" ngbDropdownItem i18n>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group d-none d-sm-block">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<i-bs width="1em" height="1em" name="filter"></i-bs> <ng-container i18n>Documents</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (!isLoading) {
|
||||
<div class="d-flex mb-2">
|
||||
@if (collectionSize > 0) {
|
||||
<div>
|
||||
<ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
||||
@if (selectedObjects.size > 0) {
|
||||
({{selectedObjects.size}} selected)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (collectionSize > 20) {
|
||||
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user