mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Merge branch 'dev'
This commit is contained in:
commit
f4e75c7fb7
44
.github/workflows/ci.yml
vendored
44
.github/workflows/ci.yml
vendored
@ -37,7 +37,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Install python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
-
|
||||
@ -56,7 +56,7 @@ jobs:
|
||||
-
|
||||
name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
cache: "pipenv"
|
||||
@ -87,7 +87,7 @@ jobs:
|
||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} run mkdocs gh-deploy --force --no-history
|
||||
-
|
||||
name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: documentation
|
||||
path: site/
|
||||
@ -114,7 +114,7 @@ jobs:
|
||||
-
|
||||
name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
cache: "pipenv"
|
||||
@ -155,7 +155,7 @@ jobs:
|
||||
-
|
||||
name: Upload coverage
|
||||
if: ${{ matrix.python-version == env.DEFAULT_PYTHON_VERSION }}
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-coverage-report
|
||||
path: src/coverage.xml
|
||||
@ -238,7 +238,7 @@ jobs:
|
||||
-
|
||||
name: Upload Jest coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: jest-coverage-report-${{ matrix.shard-index }}
|
||||
path: |
|
||||
@ -253,9 +253,9 @@ jobs:
|
||||
-
|
||||
name: Upload Playwright test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
name: playwright-report-${{ matrix.shard-index }}
|
||||
path: src-ui/playwright-report
|
||||
retention-days: 7
|
||||
|
||||
@ -269,10 +269,18 @@ jobs:
|
||||
-
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Download frontend coverage
|
||||
uses: actions/download-artifact@v3
|
||||
name: Download frontend jest coverage
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: src-ui/coverage/
|
||||
pattern: jest-coverage-report-*
|
||||
-
|
||||
name: Download frontend playwright coverage
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: src-ui/coverage/
|
||||
pattern: playwright-report-*
|
||||
merge-multiple: true
|
||||
-
|
||||
name: Upload frontend coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
@ -285,7 +293,7 @@ jobs:
|
||||
files: '!coverage.xml'
|
||||
-
|
||||
name: Download backend coverage
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: backend-coverage-report
|
||||
path: src/
|
||||
@ -416,7 +424,7 @@ jobs:
|
||||
docker cp frontend-extract:/usr/src/paperless/src/documents/static/frontend src/documents/static/frontend/
|
||||
-
|
||||
name: Upload frontend artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-compiled
|
||||
path: src/documents/static/frontend/
|
||||
@ -435,7 +443,7 @@ jobs:
|
||||
-
|
||||
name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
cache: "pipenv"
|
||||
@ -461,13 +469,13 @@ jobs:
|
||||
sudo apt-get install -qq --no-install-recommends gettext liblept5
|
||||
-
|
||||
name: Download frontend artifact
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-compiled
|
||||
path: src/documents/static/frontend/
|
||||
-
|
||||
name: Download documentation artifact
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: documentation
|
||||
path: docs/_build/html/
|
||||
@ -533,7 +541,7 @@ jobs:
|
||||
tar -cJf paperless-ngx.tar.xz paperless-ngx/
|
||||
-
|
||||
name: Upload release artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release
|
||||
path: dist/paperless-ngx.tar.xz
|
||||
@ -552,7 +560,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Download release artifact
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release
|
||||
path: ./
|
||||
@ -603,7 +611,7 @@ jobs:
|
||||
ref: main
|
||||
-
|
||||
name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
cache: "pipenv"
|
||||
|
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@ -51,4 +51,4 @@ jobs:
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
|
2
.github/workflows/repo-maintenance.yml
vendored
2
.github/workflows/repo-maintenance.yml
vendored
@ -18,7 +18,7 @@ jobs:
|
||||
name: 'Stale'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v8
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
days-before-stale: 7
|
||||
days-before-close: 14
|
||||
|
@ -11,6 +11,8 @@ repos:
|
||||
- id: check-json
|
||||
exclude: "tsconfig.*json"
|
||||
- id: check-yaml
|
||||
args:
|
||||
- "--unsafe"
|
||||
- id: check-toml
|
||||
- id: check-executables-have-shebangs
|
||||
- id: end-of-file-fixer
|
||||
|
@ -270,3 +270,5 @@ ENTRYPOINT ["/sbin/docker-entrypoint.sh"]
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["/usr/local/bin/paperless_cmd.sh"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=5 CMD [ "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000" ]
|
||||
|
@ -86,13 +86,13 @@ initialize() {
|
||||
"${CONSUME_DIR}"; do
|
||||
if [[ ! -d "${dir}" ]]; then
|
||||
echo "Creating directory ${dir}"
|
||||
mkdir "${dir}"
|
||||
mkdir --parents "${dir}"
|
||||
fi
|
||||
done
|
||||
|
||||
local -r tmp_dir="/tmp/paperless"
|
||||
echo "Creating directory ${tmp_dir}"
|
||||
mkdir -p "${tmp_dir}"
|
||||
mkdir --parents "${tmp_dir}"
|
||||
|
||||
set +e
|
||||
echo "Adjusting permissions of paperless files. This may take a while."
|
||||
|
@ -136,6 +136,11 @@ script can access the following relevant environment variables set:
|
||||
be triggered, leading to failures as two tasks work on the
|
||||
same document path
|
||||
|
||||
!!! warning
|
||||
|
||||
If your script modifies `DOCUMENT_WORKING_PATH` in a non-deterministic
|
||||
way, this may allow duplicate documents to be stored
|
||||
|
||||
A simple but common example for this would be creating a simple script
|
||||
like this:
|
||||
|
||||
|
@ -8,7 +8,6 @@ most of the available filters and ordering fields.
|
||||
|
||||
The API provides the following main endpoints:
|
||||
|
||||
- `/api/consumption_templates/`: Full CRUD support.
|
||||
- `/api/correspondents/`: Full CRUD support.
|
||||
- `/api/custom_fields/`: Full CRUD support.
|
||||
- `/api/documents/`: Full CRUD support, except POSTing new documents.
|
||||
@ -24,6 +23,7 @@ The API provides the following main endpoints:
|
||||
- `/api/tags/`: Full CRUD support.
|
||||
- `/api/tasks/`: Read-only.
|
||||
- `/api/users/`: Full CRUD support.
|
||||
- `/api/workflows/`: Full CRUD support.
|
||||
|
||||
All of these endpoints except for the logging endpoint allow you to
|
||||
fetch (and edit and delete where appropriate) individual objects by
|
||||
@ -274,6 +274,7 @@ The endpoint supports the following optional form fields:
|
||||
- `correspondent`: Specify the ID of a correspondent that the consumer
|
||||
should use for the document.
|
||||
- `document_type`: Similar to correspondent.
|
||||
- `storage_path`: Similar to correspondent.
|
||||
- `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.
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 160 KiB |
BIN
docs/assets/screenshots/workflow.png
Normal file
BIN
docs/assets/screenshots/workflow.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 137 KiB |
@ -3,6 +3,11 @@
|
||||
Paperless provides a wide range of customizations. Depending on how you
|
||||
run paperless, these settings have to be defined in different places.
|
||||
|
||||
Certain configuration options may be set via the UI. This currently includes
|
||||
common [OCR](#ocr) related settings. If set, these will take preference over the
|
||||
settings via environment variables. If not set, the environment setting or applicable
|
||||
default will be utilized instead.
|
||||
|
||||
- If you run paperless on docker, `paperless.conf` is not used.
|
||||
Rather, configure paperless by copying necessary options to
|
||||
`docker-compose.env`.
|
||||
|
@ -18,6 +18,7 @@ physical documents into a searchable online archive so you can keep, well, _less
|
||||
## Features
|
||||
|
||||
- **Organize and index** your scanned documents with tags, correspondents, types, and more.
|
||||
- _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way.
|
||||
- Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images.
|
||||
- Utilizes the open-source Tesseract engine to recognize more than 100 languages.
|
||||
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
|
||||
@ -41,7 +42,7 @@ physical documents into a searchable online archive so you can keep, well, _less
|
||||
- Configure multiple accounts and rules for each account.
|
||||
- After processing, paperless can perform actions on the messages such as marking as read, deleting and more.
|
||||
- A built-in robust **multi-user permissions** system that supports 'global' permissions as well as per document or object.
|
||||
- A powerful templating system that gives you more control over the consumption pipeline.
|
||||
- A powerful workflow system that gives you even more control.
|
||||
- **Optimized** for multi core systems: Paperless-ngx consumes multiple documents in parallel.
|
||||
- The integrated sanity checker makes sure that your document archive is in good health.
|
||||
|
||||
@ -156,9 +157,9 @@ Tag, correspondent, document type and storage path editing.
|
||||
|
||||
</div>
|
||||
<div class="grid-half-right" markdown>
|
||||
Consumption templates provide finer control over the document pipeline.
|
||||
Workflows provide finer control over the document pipeline and trigger actions.
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
<div class="clear"></div>
|
||||
|
@ -28,7 +28,8 @@ steps described in [Docker setup](#docker_hub) automatically.
|
||||
1. Make sure that Docker and Docker Compose are installed.
|
||||
|
||||
!!! tip
|
||||
See the Docker installation instructions at https://docs.docker.com/engine/install/
|
||||
|
||||
See the Docker installation instructions at https://docs.docker.com/engine/install/
|
||||
|
||||
2. Download and run the installation script:
|
||||
|
||||
@ -72,7 +73,7 @@ steps described in [Docker setup](#docker_hub) automatically.
|
||||
|
||||
If you want to use the included `docker-compose.*.yml` file, you
|
||||
need to have at least Docker version **17.09.0** and Docker Compose
|
||||
version **v2**. To check do: `docker compose -v` or `docker -v`
|
||||
version **v2**. To check do: `docker compose version` or `docker -v`
|
||||
|
||||
See the [Docker installation guide](https://docs.docker.com/engine/install/) on how to install the current
|
||||
version of Docker for your operating system or Linux distribution of
|
||||
|
120
docs/usage.md
120
docs/usage.md
@ -238,7 +238,7 @@ do not have an owner set.
|
||||
|
||||
### Default permissions
|
||||
|
||||
Default permissions for documents can be set using consumption templates.
|
||||
Default permissions for documents can be set using workflows.
|
||||
|
||||
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.
|
||||
@ -255,29 +255,80 @@ permissions can be granted to limit access to certain parts of the UI (and corre
|
||||
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)
|
||||
|
||||
## Consumption templates
|
||||
## Workflows
|
||||
|
||||
Consumption templates were introduced in v2.0 and allow for finer control over what metadata (tags, doc
|
||||
types) and permissions (owner, privileges) are assigned to documents during consumption. In general,
|
||||
templates are applied sequentially (by sort order) but subsequent templates will never override an
|
||||
assignment from a preceding template. The same is true for mail rules, e.g. if you set the correspondent
|
||||
in a mail rule any subsequent consumption templates that are applied _will not_ overwrite this. The
|
||||
exception to this is assignments that can be multiple e.g. tags and permissions, which will be merged.
|
||||
!!! note
|
||||
|
||||
Consumption templates allow you to filter by:
|
||||
v2.3 added "Workflows" and existing "Consumption Templates" were converted automatically to the new more powerful format.
|
||||
|
||||
Workflows allow hooking into the Paperless-ngx document pipeline, for example to alter what metadata (tags, doc types) and
|
||||
permissions (owner, privileges) are assigned to documents. Workflows can have multiple 'triggers' and 'actions'. Triggers
|
||||
are events (with optional filtering rules) that will cause the workflow to be run and actions are the set of sequential
|
||||
actions to apply.
|
||||
|
||||
In general, workflows and any actions they contain are applied sequentially by sort order. For "assignment" actions, subsequent
|
||||
workflow actions will override previous assignments, except for assignments that accept multiple items e.g. tags, custom
|
||||
fields and permissions, which will be merged.
|
||||
|
||||
### Workflow Triggers
|
||||
|
||||
Currently, there are three events that correspond to workflow trigger 'types':
|
||||
|
||||
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
|
||||
folder or API), file path, file name, mail rule
|
||||
2. **Document Added**: _after_ a document is added. At this time, file path and source information is no longer available,
|
||||
but the document content has been extracted and metadata such as document type, tags, etc. have been set, so these can now
|
||||
be used for filtering.
|
||||
3. **Document Updated**: when a document is updated. Similar to 'added' events, triggers can include filtering by content matching,
|
||||
tags, doc type, or correspondent.
|
||||
|
||||
The following flow diagram illustrates the three trigger types:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
consumption{"Matching
|
||||
'Consumption'
|
||||
trigger(s)"}
|
||||
|
||||
added{"Matching
|
||||
'Added'
|
||||
trigger(s)"}
|
||||
|
||||
updated{"Matching
|
||||
'Updated'
|
||||
trigger(s)"}
|
||||
|
||||
A[New Document] --> consumption
|
||||
consumption --> |Yes| C[Workflow Actions Run]
|
||||
consumption --> |No| D
|
||||
C --> D[Document Added]
|
||||
D -- Paperless-ngx 'matching' of tags, etc. --> added
|
||||
added --> |Yes| F[Workflow Actions Run]
|
||||
added --> |No| G
|
||||
F --> G[Document Finalized]
|
||||
H[Existing Document Changed] --> updated
|
||||
updated --> |Yes| J[Workflow Actions Run]
|
||||
updated --> |No| K
|
||||
J --> K[Document Saved]
|
||||
```
|
||||
|
||||
#### Filters {#workflow-trigger-filters}
|
||||
|
||||
Workflows allow you to filter by:
|
||||
|
||||
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
|
||||
- File name, including wildcards e.g. \*.pdf will apply to all pdfs
|
||||
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
||||
example, automatically assigning documents to different owners based on the upload directory.
|
||||
- Mail rule. Choosing this option will force 'mail fetch' to be the template source.
|
||||
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
||||
- Content matching (`Added` and `Updated` triggers only). Filter document content using the matching settings.
|
||||
- Tags (`Added` and `Updated` triggers only). Filter for documents with any of the specified tags
|
||||
- Document type (`Added` and `Updated` triggers only). Filter documents with this doc type
|
||||
- Correspondent (`Added` and `Updated` triggers only). Filter documents with this correspondent
|
||||
|
||||
!!! note
|
||||
### Workflow Actions
|
||||
|
||||
You must include a file name filter, a path filter or a mail rule filter. Use * for either to apply
|
||||
to all files.
|
||||
|
||||
Consumption templates can assign:
|
||||
There is currently one type of workflow action, "Assignment", which can assign:
|
||||
|
||||
- Title, see [title placeholders](usage.md#title-placeholders) below
|
||||
- Tags, correspondent, document types
|
||||
@ -285,21 +336,11 @@ Consumption templates can assign:
|
||||
- View and / or edit permissions to users or groups
|
||||
- Custom fields. Note that no value for the field will be set
|
||||
|
||||
### Consumption template permissions
|
||||
#### Title placeholders
|
||||
|
||||
All users who have application permissions for editing consumption templates can see the same set
|
||||
of templates. In other words, templates themselves intentionally do not have an owner or permissions.
|
||||
|
||||
Given their potentially far-reaching capabilities, you may want to restrict access to templates.
|
||||
|
||||
Upon migration, existing installs will grant access to consumption templates to users who can add
|
||||
documents (and superusers who can always access all parts of the app).
|
||||
|
||||
### Title placeholders
|
||||
|
||||
Consumption template titles can include placeholders, _only for items that are assigned within the template_.
|
||||
This is because at the time of consumption (when the title is to be set), no automatic tags etc. have been
|
||||
applied. You can use the following placeholders:
|
||||
Workflow titles can include placeholders but the available options differ depending on the type of
|
||||
workflow trigger. This is because at the time of consumption (when the title is to be set), no automatic tags etc. have been
|
||||
applied. You can use the following placeholders with any trigger type:
|
||||
|
||||
- `{correspondent}`: assigned correspondent name
|
||||
- `{document_type}`: assigned document type name
|
||||
@ -314,6 +355,27 @@ applied. You can use the following placeholders:
|
||||
- `{added_time}`: added time in HH:MM format
|
||||
- `{original_filename}`: original file name without extension
|
||||
|
||||
The following placeholders are only available for "added" or "updated" triggers
|
||||
|
||||
- `{created}`: created datetime
|
||||
- `{created_year}`: created year
|
||||
- `{created_year_short}`: created year
|
||||
- `{created_month}`: created month
|
||||
- `{created_month_name}`: created month name
|
||||
- `{created_month_name_short}`: created month short name
|
||||
- `{created_day}`: created day
|
||||
- `{created_time}`: created time in HH:MM format
|
||||
|
||||
### Workflow permissions
|
||||
|
||||
All users who have application permissions for editing workflows can see the same set
|
||||
of workflows. In other words, workflows themselves intentionally do not have an owner or permissions.
|
||||
|
||||
Given their potentially far-reaching capabilities, you may want to restrict access to workflows.
|
||||
|
||||
Upon migration, existing installs will grant access to workflows to users who can add
|
||||
documents (and superusers who can always access all parts of the app).
|
||||
|
||||
## Custom Fields {#custom-fields}
|
||||
|
||||
Paperless-ngx supports the use of custom fields for documents as of v2.0, allowing a user
|
||||
|
@ -44,6 +44,11 @@ markdown_extensions:
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.snippets
|
||||
- footnotes
|
||||
- pymdownx.superfences:
|
||||
custom_fences:
|
||||
- name: mermaid
|
||||
class: mermaid
|
||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||
strict: true
|
||||
nav:
|
||||
- index.md
|
||||
|
1308
src-ui/messages.xlf
1308
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
327
src-ui/package-lock.json
generated
327
src-ui/package-lock.json
generated
@ -10,14 +10,14 @@
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^17.0.4",
|
||||
"@angular/common": "~17.0.7",
|
||||
"@angular/compiler": "~17.0.7",
|
||||
"@angular/core": "~17.0.7",
|
||||
"@angular/forms": "~17.0.7",
|
||||
"@angular/localize": "~17.0.7",
|
||||
"@angular/platform-browser": "~17.0.7",
|
||||
"@angular/platform-browser-dynamic": "~17.0.7",
|
||||
"@angular/router": "~17.0.7",
|
||||
"@angular/common": "~17.0.8",
|
||||
"@angular/compiler": "~17.0.8",
|
||||
"@angular/core": "~17.0.8",
|
||||
"@angular/forms": "~17.0.8",
|
||||
"@angular/localize": "~17.0.8",
|
||||
"@angular/platform-browser": "~17.0.8",
|
||||
"@angular/platform-browser-dynamic": "~17.0.8",
|
||||
"@angular/router": "~17.0.8",
|
||||
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
||||
"@ng-select/ng-select": "^12.0.4",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
@ -37,21 +37,21 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/jest": "17.0.0",
|
||||
"@angular-devkit/build-angular": "~17.0.7",
|
||||
"@angular-devkit/build-angular": "~17.0.8",
|
||||
"@angular-eslint/builder": "17.1.1",
|
||||
"@angular-eslint/eslint-plugin": "17.1.1",
|
||||
"@angular-eslint/eslint-plugin-template": "17.1.1",
|
||||
"@angular-eslint/schematics": "17.1.1",
|
||||
"@angular-eslint/template-parser": "17.1.1",
|
||||
"@angular/cli": "~17.0.7",
|
||||
"@angular/cli": "~17.0.8",
|
||||
"@angular/compiler-cli": "~17.0.7",
|
||||
"@playwright/test": "^1.40.1",
|
||||
"@types/jest": "^29.5.10",
|
||||
"@types/node": "^20.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@types/node": "^20.10.6",
|
||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||
"@typescript-eslint/parser": "^6.17.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-preset-angular": "^13.1.4",
|
||||
@ -107,12 +107,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/architect": {
|
||||
"version": "0.1700.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1700.7.tgz",
|
||||
"integrity": "sha512-32uitQKsYLGXAKoXBsmOnPsTt9pS+b9cnFI9ZvBFVhJ31I2EOM7vGcMFalhTxdB/DkVHk4TyO78efV0V26DwCA==",
|
||||
"version": "0.1700.8",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1700.8.tgz",
|
||||
"integrity": "sha512-SWVr3CvwO6T0yW2ytszCwBT1g92vyFkwbVUxqE93urYnoD8PvP+81GH5YwVjHQTgvhP4eXQMGZ9hpHx57VOrWQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": "17.0.7",
|
||||
"@angular-devkit/core": "17.0.8",
|
||||
"rxjs": "7.8.1"
|
||||
},
|
||||
"engines": {
|
||||
@ -122,15 +122,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular": {
|
||||
"version": "17.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.0.7.tgz",
|
||||
"integrity": "sha512-AtEzLk6n6BXqQzk0Bsupe6GV0IgUe7RbpBfqROi+NZqMA7OUAHCX3xA6M68Qu+5KxBtW7T5lHeZZ7iP/y39wtQ==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.0.8.tgz",
|
||||
"integrity": "sha512-u7R5yX92ZxOL/LfxiKGGqlBo86100sJ5Rabavn8DeGtYP8N0qgwCcNwlW2zaMoUlkw2geMnxcxIX5VJI4iFPUA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "2.2.1",
|
||||
"@angular-devkit/architect": "0.1700.7",
|
||||
"@angular-devkit/build-webpack": "0.1700.7",
|
||||
"@angular-devkit/core": "17.0.7",
|
||||
"@angular-devkit/architect": "0.1700.8",
|
||||
"@angular-devkit/build-webpack": "0.1700.8",
|
||||
"@angular-devkit/core": "17.0.8",
|
||||
"@babel/core": "7.23.2",
|
||||
"@babel/generator": "7.23.0",
|
||||
"@babel/helper-annotate-as-pure": "7.22.5",
|
||||
@ -141,7 +141,7 @@
|
||||
"@babel/preset-env": "7.23.2",
|
||||
"@babel/runtime": "7.23.2",
|
||||
"@discoveryjs/json-ext": "0.5.7",
|
||||
"@ngtools/webpack": "17.0.7",
|
||||
"@ngtools/webpack": "17.0.8",
|
||||
"@vitejs/plugin-basic-ssl": "1.0.1",
|
||||
"ansi-colors": "4.1.3",
|
||||
"autoprefixer": "10.4.16",
|
||||
@ -245,12 +245,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-webpack": {
|
||||
"version": "0.1700.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1700.7.tgz",
|
||||
"integrity": "sha512-B9Mg/qYDpE5my8PJ3VPQyRSUV0Oq1bFUzU8s0ZpqEZl1URKc04pm0LtLmebrMIcUZgDiGk0RHaD+O1E9IV/bdQ==",
|
||||
"version": "0.1700.8",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1700.8.tgz",
|
||||
"integrity": "sha512-GA7QlCAlYB3uBkRaUYgIC/Vfajb9jMmouwYiAAEm34ZyP3ThFjdqsYd/A/exnuESt5o6Bh++C/PI34sV3lawRA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/architect": "0.1700.7",
|
||||
"@angular-devkit/architect": "0.1700.8",
|
||||
"rxjs": "7.8.1"
|
||||
},
|
||||
"engines": {
|
||||
@ -264,9 +264,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/core": {
|
||||
"version": "17.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.0.7.tgz",
|
||||
"integrity": "sha512-vATobHo5O5tJba424hJfQWLb40GzvZPNsI74dcgSUTgrDph8ksmk5xB9OvEvf0INorQZ2IMphj/VIWj4/+JqSA==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.0.8.tgz",
|
||||
"integrity": "sha512-gI8+SOwGUwr0WOlFrhLjohLolMzcguuoR0LTZEcGjdXvQyPgH4NDSRIIrfWCdu+ZVhfy76o3zQYdYc9QN8NrjQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ajv": "8.12.0",
|
||||
@ -291,12 +291,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics": {
|
||||
"version": "17.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.0.7.tgz",
|
||||
"integrity": "sha512-BY11OkJkM3xyXcvyD7x5kGY/c8Ufd4AfPvI0D9imhVxbns45Q48b1DlvCQvSnCJ/s+OwnkrYb/Efa70ZiaGu8A==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.0.8.tgz",
|
||||
"integrity": "sha512-syo814SVWfJvne448IijjZvpWbuqJsEutdNqHWLTewTfX2U3KrIAr/XRVcXQMuyMvLCDiuxjMgEJxOIP7mcIPw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": "17.0.7",
|
||||
"@angular-devkit/core": "17.0.8",
|
||||
"jsonc-parser": "3.2.0",
|
||||
"magic-string": "0.30.5",
|
||||
"ora": "5.4.1",
|
||||
@ -423,15 +423,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cli": {
|
||||
"version": "17.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.0.7.tgz",
|
||||
"integrity": "sha512-oSa0GVAQNA7wFbLJYeaO3kV4iUcbKEqXDLxcIE8s1GfHddBOlXH2P1T4fXonCBl5qvV+joP0G0+fs7I0w2utZQ==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.0.8.tgz",
|
||||
"integrity": "sha512-yZXYNLAFv9u2qypsVqtS+rRCsnjsIPYXr6TcI/r5buzOtC7UQ2lleYsWJqX47SsyGMk/o3gaYg5Bj2I5mmRDLA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/architect": "0.1700.7",
|
||||
"@angular-devkit/core": "17.0.7",
|
||||
"@angular-devkit/schematics": "17.0.7",
|
||||
"@schematics/angular": "17.0.7",
|
||||
"@angular-devkit/architect": "0.1700.8",
|
||||
"@angular-devkit/core": "17.0.8",
|
||||
"@angular-devkit/schematics": "17.0.8",
|
||||
"@schematics/angular": "17.0.8",
|
||||
"@yarnpkg/lockfile": "1.1.0",
|
||||
"ansi-colors": "4.1.3",
|
||||
"ini": "4.1.1",
|
||||
@ -457,9 +457,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/common": {
|
||||
"version": "17.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-17.0.7.tgz",
|
||||
"integrity": "sha512-bPPL6x0KOAOTxKSE2j4EWmEUOnqZYzOYiHzroa5b9UEyA9NvGkd9bm3zIxw8xcndRj1Ehcmvpi6KBLcYBBbWfg==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-17.0.8.tgz",
|
||||
"integrity": "sha512-fFfwtdg7H+OkqnvV/ENu8F8KGfgIiH16DDbQqYY5KQyyQB+SMsoVW29F1fGx6Y30s7ZlsLOy6cHhgrw74itkSw==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -467,14 +467,14 @@
|
||||
"node": "^18.13.0 || >=20.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "17.0.7",
|
||||
"@angular/core": "17.0.8",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler": {
|
||||
"version": "17.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-17.0.7.tgz",
|
||||
"integrity": "sha512-QHPuLti2c2tGZmOGZ0cfCHo4LxiHUkC27I0aZFDyQSSQqEI5obQGVlEREHysw0nsS3sYIcLvqcwcKcRtXlXtxQ==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-17.0.8.tgz",
|
||||
"integrity": "sha512-48jWypuhBGTrUUbkz1vB9gjbKKZ3hpuJ2DUUncd331Yw4tqkqZQbBa/E3ei4IHiCxEvW2uX3lI4AwlhuozmUtA==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -482,7 +482,7 @@
|
||||
"node": "^18.13.0 || >=20.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "17.0.7"
|
||||
"@angular/core": "17.0.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@angular/core": {
|
||||
@ -491,9 +491,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler-cli": {
|
||||
"version": "17.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.0.7.tgz",
|
||||
"integrity": "sha512-YnL38idjIYtl3BXYpv+sVJKWGbUjHT6eyQSQVAfO/1AwWqVa21K9hnE+Q37VmUKEcKFMnQembeuErA+KVsGI6A==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.0.8.tgz",
|
||||
"integrity": "sha512-ny2SMVgl+icjMuU5ZM57yFGUrhjR0hNxfCn0otAD3jUFliz/Onu9l6EPRKA5Cr8MZx3mg3rTLSBMD17YT8rsOg==",
|
||||
"dependencies": {
|
||||
"@babel/core": "7.23.2",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
@ -513,14 +513,14 @@
|
||||
"node": "^18.13.0 || >=20.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "17.0.7",
|
||||
"@angular/compiler": "17.0.8",
|
||||
"typescript": ">=5.2 <5.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/core": {
|
||||
"version": "17.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-17.0.7.tgz",
|
||||
"integrity": "sha512-mEkelXkzEi6+A9GjdKOSGGzQAfo1iAjVTn6YsplNUeGE5JgDZYZ7sXGQqs0Lin7dzJxnPAgGjCOl7SpWLXIPSQ==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-17.0.8.tgz",
|
||||
"integrity": "sha512-tzYsK24LdkNuKNJK6efF4XOqspvF/qOe9j/n1Y61a6mNvFwsJFGbcmdZMby4hI/YRm6oIDoIIFjSep8ycp6Pbw==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -533,9 +533,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/forms": {
|
||||
"version": "17.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-17.0.7.tgz",
|
||||
"integrity": "sha512-28BxRxEmgZIofGwVp6s2v3ri/kuWW+/EY/ZXhavlWKJEh4ATJl72k0RkRWNcQi4wnvn0Qb8tFdnVJnvRZvvKEw==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-17.0.8.tgz",
|
||||
"integrity": "sha512-WZBHbMQjaSovAzOMhKqZN+m7eUPGfOzh9rKFKvj6UQLIJ9qSpEpqlvL0omU1z/47s3XXeLiBzomMiRfQISJvvw==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -543,16 +543,16 @@
|
||||
"node": "^18.13.0 || >=20.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "17.0.7",
|
||||
"@angular/core": "17.0.7",
|
||||
"@angular/platform-browser": "17.0.7",
|
||||
"@angular/common": "17.0.8",
|
||||
"@angular/core": "17.0.8",
|
||||
"@angular/platform-browser": "17.0.8",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/localize": {
|
||||
"version": "17.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/localize/-/localize-17.0.7.tgz",
|
||||
"integrity": "sha512-avYYQ8zin2thzvsH2YP3WxlwkvOzjNEXxjv4yyZBx6wul68e/753kQK/0RmSUYaBpDTUEZYzrPpDay00TKwBOA==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@angular/localize/-/localize-17.0.8.tgz",
|
||||
"integrity": "sha512-1zW8qWKNMH3r/x4KpwzzUmVY+iN76vYdhjA6gzZDnpJxpon9eyljNEildj9+zSWeNUr2LgJ6HnkIX9q1f3mXfA==",
|
||||
"dependencies": {
|
||||
"@babel/core": "7.23.2",
|
||||
"fast-glob": "3.3.1",
|
||||
@ -567,14 +567,14 @@
|
||||
"node": "^18.13.0 || >=20.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "17.0.7",
|
||||
"@angular/compiler-cli": "17.0.7"
|
||||
"@angular/compiler": "17.0.8",
|
||||
"@angular/compiler-cli": "17.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser": {
|
||||
"version": "17.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-17.0.7.tgz",
|
||||
"integrity": "sha512-bm9/wt51nc/MPjft/FlRNIgFSeLjDtfJOT7M32Rt6kOHhNKSK7ZTPWdMe9ahuHSbAhLzd0G/4NsT5sKrWSeVZg==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-17.0.8.tgz",
|
||||
"integrity": "sha512-XaI+p2AxQaIHzR761lhPUf4OcOp46WDW0IfbvOzaezHE+8r81joZyVSDQPgXSa/aRfI58YhcfUavuGqyU3PphA==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -582,9 +582,9 @@
|
||||
"node": "^18.13.0 || >=20.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/animations": "17.0.7",
|
||||
"@angular/common": "17.0.7",
|
||||
"@angular/core": "17.0.7"
|
||||
"@angular/animations": "17.0.8",
|
||||
"@angular/common": "17.0.8",
|
||||
"@angular/core": "17.0.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@angular/animations": {
|
||||
@ -593,9 +593,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser-dynamic": {
|
||||
"version": "17.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-17.0.7.tgz",
|
||||
"integrity": "sha512-OquwUX9fLWA2JUZW5Jm6atk0CPt0sA7Tg24eGLsr6g1XfTS7jRZprlGaa72NgPLnQVV6m84o/ZiNYS6yPmq1Gg==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-17.0.8.tgz",
|
||||
"integrity": "sha512-BIXNKnfBZb8sdluQ7WIhIXFuVnsJJ0SV+aiMKzQ7B6XhWoAXZQnlvON2thydjIIVuCvaF3YmWTbILI2K8YZ2jQ==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -603,16 +603,16 @@
|
||||
"node": "^18.13.0 || >=20.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "17.0.7",
|
||||
"@angular/compiler": "17.0.7",
|
||||
"@angular/core": "17.0.7",
|
||||
"@angular/platform-browser": "17.0.7"
|
||||
"@angular/common": "17.0.8",
|
||||
"@angular/compiler": "17.0.8",
|
||||
"@angular/core": "17.0.8",
|
||||
"@angular/platform-browser": "17.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/router": {
|
||||
"version": "17.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-17.0.7.tgz",
|
||||
"integrity": "sha512-rUFPe1uDlYYw6+3Gq68czW7WxBH7zT/D3UsT1otqwUV4RnQQsVze4fIit9FqJh7tuP4y3WpB4XBNf7p7Oi6TJw==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-17.0.8.tgz",
|
||||
"integrity": "sha512-ptphcRe1RG/mIS60R7ZPilkkrxautqB0sOhds3h5VP3g628G1a2HWzvnmvjEfpJWDMFivV32VJMMBtTLqGr+0Q==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -620,9 +620,9 @@
|
||||
"node": "^18.13.0 || >=20.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "17.0.7",
|
||||
"@angular/core": "17.0.7",
|
||||
"@angular/platform-browser": "17.0.7",
|
||||
"@angular/common": "17.0.8",
|
||||
"@angular/core": "17.0.8",
|
||||
"@angular/platform-browser": "17.0.8",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
@ -2895,9 +2895,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.55.0.tgz",
|
||||
"integrity": "sha512-qQfo2mxH5yVom1kacMtZZJFVdW+E70mqHMJvVg6WTLo+VBuQJ4TojZlfWBjK0ve5BdEeNAVxOsl/nvNMpJOaJA==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz",
|
||||
"integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
@ -3941,9 +3941,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ngtools/webpack": {
|
||||
"version": "17.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.0.7.tgz",
|
||||
"integrity": "sha512-gwhUhpwXn0trwwKdSu9WlJbEcLt+s/2fPwoD9lZ0y3wXfrOogsfcNBJKeO5BZf1h+A3AWt7ePmgrZXSJM+865Q==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.0.8.tgz",
|
||||
"integrity": "sha512-wx0XBMrbpDeailK2uIhp/ZVMC3GK3BWwJjUu5SbT4BFrcoi2Zd9/9m0RCBAY54UXLBCqKd+ih7pJ6JSvprZmWw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^18.13.0 || >=20.9.0",
|
||||
@ -4459,13 +4459,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@schematics/angular": {
|
||||
"version": "17.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.7.tgz",
|
||||
"integrity": "sha512-d7QKmcKrM4owb/2bR7Ipf23roiNbvbD/x7reNhQAtKAPLSHJ3Ulkf1+Yv+dj+9f+K7y9SBviEUSrD27BQ9WaxQ==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.0.8.tgz",
|
||||
"integrity": "sha512-1h5mwKFv1B/L5JWZ0mxnC4ms06iwnSi/w+GgRZPeM3P5BpuZuvAkFiClNnM55iLlQJXRQioPNLM3sOsz7spR6w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": "17.0.7",
|
||||
"@angular-devkit/schematics": "17.0.7",
|
||||
"@angular-devkit/core": "17.0.8",
|
||||
"@angular-devkit/schematics": "17.0.8",
|
||||
"jsonc-parser": "3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
@ -4878,9 +4878,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.4.tgz",
|
||||
"integrity": "sha512-D08YG6rr8X90YB56tSIuBaddy/UXAA9RKJoFvrsnogAum/0pmjkgi4+2nx96A330FmioegBWmEYQ+syqCFaveg==",
|
||||
"version": "20.10.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz",
|
||||
"integrity": "sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
@ -4896,9 +4896,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.10.tgz",
|
||||
"integrity": "sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw==",
|
||||
"version": "6.9.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz",
|
||||
"integrity": "sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
@ -4995,16 +4995,16 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.14.0.tgz",
|
||||
"integrity": "sha512-1ZJBykBCXaSHG94vMMKmiHoL0MhNHKSVlcHVYZNw+BKxufhqQVTOawNpwwI1P5nIFZ/4jLVop0mcY6mJJDFNaw==",
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.17.0.tgz",
|
||||
"integrity": "sha512-Vih/4xLXmY7V490dGwBQJTpIZxH4ZFH6eCVmQ4RFkB+wmaCTDAx4dtgoWwMNGKLkqRY1L6rPqzEbjorRnDo4rQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.5.1",
|
||||
"@typescript-eslint/scope-manager": "6.14.0",
|
||||
"@typescript-eslint/type-utils": "6.14.0",
|
||||
"@typescript-eslint/utils": "6.14.0",
|
||||
"@typescript-eslint/visitor-keys": "6.14.0",
|
||||
"@typescript-eslint/scope-manager": "6.17.0",
|
||||
"@typescript-eslint/type-utils": "6.17.0",
|
||||
"@typescript-eslint/utils": "6.17.0",
|
||||
"@typescript-eslint/visitor-keys": "6.17.0",
|
||||
"debug": "^4.3.4",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^5.2.4",
|
||||
@ -5030,13 +5030,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.14.0.tgz",
|
||||
"integrity": "sha512-x6OC9Q7HfYKqjnuNu5a7kffIYs3No30isapRBJl1iCHLitD8O0lFbRcVGiOcuyN837fqXzPZ1NS10maQzZMKqw==",
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.17.0.tgz",
|
||||
"integrity": "sha512-hDXcWmnbtn4P2B37ka3nil3yi3VCQO2QEB9gBiHJmQp5wmyQWqnjA85+ZcE8c4FqnaB6lBwMrPkgd4aBYz3iNg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/typescript-estree": "6.14.0",
|
||||
"@typescript-eslint/utils": "6.14.0",
|
||||
"@typescript-eslint/typescript-estree": "6.17.0",
|
||||
"@typescript-eslint/utils": "6.17.0",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^1.0.1"
|
||||
},
|
||||
@ -5057,17 +5057,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.14.0.tgz",
|
||||
"integrity": "sha512-XwRTnbvRr7Ey9a1NT6jqdKX8y/atWG+8fAIu3z73HSP8h06i3r/ClMhmaF/RGWGW1tHJEwij1uEg2GbEmPYvYg==",
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.17.0.tgz",
|
||||
"integrity": "sha512-LofsSPjN/ITNkzV47hxas2JCsNCEnGhVvocfyOcLzT9c/tSZE7SfhS/iWtzP1lKNOEfLhRTZz6xqI8N2RzweSQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.4.0",
|
||||
"@types/json-schema": "^7.0.12",
|
||||
"@types/semver": "^7.5.0",
|
||||
"@typescript-eslint/scope-manager": "6.14.0",
|
||||
"@typescript-eslint/types": "6.14.0",
|
||||
"@typescript-eslint/typescript-estree": "6.14.0",
|
||||
"@typescript-eslint/scope-manager": "6.17.0",
|
||||
"@typescript-eslint/types": "6.17.0",
|
||||
"@typescript-eslint/typescript-estree": "6.17.0",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
@ -5082,15 +5082,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.14.0.tgz",
|
||||
"integrity": "sha512-QjToC14CKacd4Pa7JK4GeB/vHmWFJckec49FR4hmIRf97+KXole0T97xxu9IFiPxVQ1DBWrQ5wreLwAGwWAVQA==",
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.17.0.tgz",
|
||||
"integrity": "sha512-C4bBaX2orvhK+LlwrY8oWGmSl4WolCfYm513gEccdWZj0CwGadbIADb0FtVEcI+WzUyjyoBj2JRP8g25E6IB8A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "6.14.0",
|
||||
"@typescript-eslint/types": "6.14.0",
|
||||
"@typescript-eslint/typescript-estree": "6.14.0",
|
||||
"@typescript-eslint/visitor-keys": "6.14.0",
|
||||
"@typescript-eslint/scope-manager": "6.17.0",
|
||||
"@typescript-eslint/types": "6.17.0",
|
||||
"@typescript-eslint/typescript-estree": "6.17.0",
|
||||
"@typescript-eslint/visitor-keys": "6.17.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@ -5110,13 +5110,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.14.0.tgz",
|
||||
"integrity": "sha512-VT7CFWHbZipPncAZtuALr9y3EuzY1b1t1AEkIq2bTXUPKw+pHoXflGNG5L+Gv6nKul1cz1VH8fz16IThIU0tdg==",
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.17.0.tgz",
|
||||
"integrity": "sha512-RX7a8lwgOi7am0k17NUO0+ZmMOX4PpjLtLRgLmT1d3lBYdWH4ssBUbwdmc5pdRX8rXon8v9x8vaoOSpkHfcXGA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "6.14.0",
|
||||
"@typescript-eslint/visitor-keys": "6.14.0"
|
||||
"@typescript-eslint/types": "6.17.0",
|
||||
"@typescript-eslint/visitor-keys": "6.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
@ -5211,9 +5211,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.14.0.tgz",
|
||||
"integrity": "sha512-uty9H2K4Xs8E47z3SnXEPRNDfsis8JO27amp2GNCnzGETEW3yTqEIVg5+AI7U276oGF/tw6ZA+UesxeQ104ceA==",
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.17.0.tgz",
|
||||
"integrity": "sha512-qRKs9tvc3a4RBcL/9PXtKSehI/q8wuU9xYJxe97WFxnzH8NWWtcW3ffNS+EWg8uPvIerhjsEZ+rHtDqOCiH57A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
@ -5224,16 +5224,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.14.0.tgz",
|
||||
"integrity": "sha512-yPkaLwK0yH2mZKFE/bXkPAkkFgOv15GJAUzgUVonAbv0Hr4PK/N2yaA/4XQbTZQdygiDkpt5DkxPELqHguNvyw==",
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.17.0.tgz",
|
||||
"integrity": "sha512-gVQe+SLdNPfjlJn5VNGhlOhrXz4cajwFd5kAgWtZ9dCZf4XJf8xmgCTLIqec7aha3JwgLI2CK6GY1043FRxZwg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "6.14.0",
|
||||
"@typescript-eslint/visitor-keys": "6.14.0",
|
||||
"@typescript-eslint/types": "6.17.0",
|
||||
"@typescript-eslint/visitor-keys": "6.17.0",
|
||||
"debug": "^4.3.4",
|
||||
"globby": "^11.1.0",
|
||||
"is-glob": "^4.0.3",
|
||||
"minimatch": "9.0.3",
|
||||
"semver": "^7.5.4",
|
||||
"ts-api-utils": "^1.0.1"
|
||||
},
|
||||
@ -5250,6 +5251,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
|
||||
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "6.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.13.1.tgz",
|
||||
@ -5350,12 +5375,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.14.0.tgz",
|
||||
"integrity": "sha512-fB5cw6GRhJUz03MrROVuj5Zm/Q+XWlVdIsFj+Zb1Hvqouc8t+XP2H5y53QYU/MGtd2dPg6/vJJlhoX3xc2ehfw==",
|
||||
"version": "6.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.17.0.tgz",
|
||||
"integrity": "sha512-H6VwB/k3IuIeQOyYczyyKN8wH6ed8EwliaYHLxOIhyF0dYEIsN8+Bk3GE19qafeMKyZJJHP8+O1HiFhFLUNKSg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "6.14.0",
|
||||
"@typescript-eslint/types": "6.17.0",
|
||||
"eslint-visitor-keys": "^3.4.1"
|
||||
},
|
||||
"engines": {
|
||||
@ -8687,15 +8712,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.55.0.tgz",
|
||||
"integrity": "sha512-iyUUAM0PCKj5QpwGfmCAG9XXbZCWsqP/eWAWrG/W0umvjuLRBECwSFdt+rCntju0xEH7teIABPwXpahftIaTdA==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz",
|
||||
"integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
"@eslint/eslintrc": "^2.1.4",
|
||||
"@eslint/js": "8.55.0",
|
||||
"@eslint/js": "8.56.0",
|
||||
"@humanwhocodes/config-array": "^0.11.13",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@nodelib/fs.walk": "^1.2.8",
|
||||
|
@ -12,14 +12,14 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^17.0.4",
|
||||
"@angular/common": "~17.0.7",
|
||||
"@angular/compiler": "~17.0.7",
|
||||
"@angular/core": "~17.0.7",
|
||||
"@angular/forms": "~17.0.7",
|
||||
"@angular/localize": "~17.0.7",
|
||||
"@angular/platform-browser": "~17.0.7",
|
||||
"@angular/platform-browser-dynamic": "~17.0.7",
|
||||
"@angular/router": "~17.0.7",
|
||||
"@angular/common": "~17.0.8",
|
||||
"@angular/compiler": "~17.0.8",
|
||||
"@angular/core": "~17.0.8",
|
||||
"@angular/forms": "~17.0.8",
|
||||
"@angular/localize": "~17.0.8",
|
||||
"@angular/platform-browser": "~17.0.8",
|
||||
"@angular/platform-browser-dynamic": "~17.0.8",
|
||||
"@angular/router": "~17.0.8",
|
||||
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
||||
"@ng-select/ng-select": "^12.0.4",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
@ -39,21 +39,21 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/jest": "17.0.0",
|
||||
"@angular-devkit/build-angular": "~17.0.7",
|
||||
"@angular-devkit/build-angular": "~17.0.8",
|
||||
"@angular-eslint/builder": "17.1.1",
|
||||
"@angular-eslint/eslint-plugin": "17.1.1",
|
||||
"@angular-eslint/eslint-plugin-template": "17.1.1",
|
||||
"@angular-eslint/schematics": "17.1.1",
|
||||
"@angular-eslint/template-parser": "17.1.1",
|
||||
"@angular/cli": "~17.0.7",
|
||||
"@angular/cli": "~17.0.8",
|
||||
"@angular/compiler-cli": "~17.0.7",
|
||||
"@playwright/test": "^1.40.1",
|
||||
"@types/jest": "^29.5.10",
|
||||
"@types/node": "^20.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@types/node": "^20.10.6",
|
||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||
"@typescript-eslint/parser": "^6.17.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-preset-angular": "^13.1.4",
|
||||
|
@ -21,10 +21,11 @@ import {
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
} from './services/permissions.service'
|
||||
import { ConsumptionTemplatesComponent } from './components/manage/consumption-templates/consumption-templates.component'
|
||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||
import { MailComponent } from './components/manage/mail/mail.component'
|
||||
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
|
||||
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
||||
import { ConfigComponent } from './components/admin/config/config.component'
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
@ -179,6 +180,17 @@ export const routes: Routes = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'config',
|
||||
component: ConfigComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.Admin,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
component: TasksComponent,
|
||||
@ -202,13 +214,13 @@ export const routes: Routes = [
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'templates',
|
||||
component: ConsumptionTemplatesComponent,
|
||||
path: 'workflows',
|
||||
component: WorkflowsComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.ConsumptionTemplate,
|
||||
type: PermissionType.Workflow,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -176,9 +176,9 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
},
|
||||
{
|
||||
anchorId: 'tour.consumption-templates',
|
||||
content: $localize`Consumption templates give you finer control over the document ingestion process.`,
|
||||
route: '/templates',
|
||||
anchorId: 'tour.workflows',
|
||||
content: $localize`Workflows give you more control over the document pipeline.`,
|
||||
route: '/workflows',
|
||||
backdropConfig: {
|
||||
offset: 0,
|
||||
},
|
||||
|
@ -95,8 +95,8 @@ import { UsernamePipe } from './pipes/username.pipe'
|
||||
import { LogoComponent } from './components/common/logo/logo.component'
|
||||
import { IsNumberPipe } from './pipes/is-number.pipe'
|
||||
import { ShareLinksDropdownComponent } from './components/common/share-links-dropdown/share-links-dropdown.component'
|
||||
import { ConsumptionTemplatesComponent } from './components/manage/consumption-templates/consumption-templates.component'
|
||||
import { ConsumptionTemplateEditDialogComponent } from './components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
|
||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||
import { WorkflowEditDialogComponent } from './components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
|
||||
import { MailComponent } from './components/manage/mail/mail.component'
|
||||
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||
@ -108,6 +108,8 @@ import { ProfileEditDialogComponent } from './components/common/profile-edit-dia
|
||||
import { PdfViewerComponent } from './components/common/pdf-viewer/pdf-viewer.component'
|
||||
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
|
||||
import { PreviewPopupComponent } from './components/common/preview-popup/preview-popup.component'
|
||||
import { SwitchComponent } from './components/common/input/switch/switch.component'
|
||||
import { ConfigComponent } from './components/admin/config/config.component'
|
||||
|
||||
import localeAf from '@angular/common/locales/af'
|
||||
import localeAr from '@angular/common/locales/ar'
|
||||
@ -251,8 +253,8 @@ function initializeApp(settings: SettingsService) {
|
||||
LogoComponent,
|
||||
IsNumberPipe,
|
||||
ShareLinksDropdownComponent,
|
||||
ConsumptionTemplatesComponent,
|
||||
ConsumptionTemplateEditDialogComponent,
|
||||
WorkflowsComponent,
|
||||
WorkflowEditDialogComponent,
|
||||
MailComponent,
|
||||
UsersAndGroupsComponent,
|
||||
FileDropComponent,
|
||||
@ -263,6 +265,8 @@ function initializeApp(settings: SettingsService) {
|
||||
PdfViewerComponent,
|
||||
DocumentLinkComponent,
|
||||
PreviewPopupComponent,
|
||||
SwitchComponent,
|
||||
ConfigComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
|
54
src-ui/src/app/components/admin/config/config.component.html
Normal file
54
src-ui/src/app/components/admin/config/config.component.html
Normal file
@ -0,0 +1,54 @@
|
||||
<pngx-page-header title="Configuration" i18n-title></pngx-page-header>
|
||||
|
||||
<form [formGroup]="configForm" (ngSubmit)="saveConfig()" class="pb-4">
|
||||
|
||||
<ul ngbNav #nav="ngbNav" class="nav-tabs">
|
||||
@for (category of optionCategories; track category) {
|
||||
<li [ngbNavItem]="category">
|
||||
<a ngbNavLink i18n>{{category}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="p-3">
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||
@for (option of getCategoryOptions(category); track option.key) {
|
||||
<div class="col">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<h6>
|
||||
{{option.title}}
|
||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#info-circle"/>
|
||||
</svg>
|
||||
</a>
|
||||
</h6>
|
||||
</div>
|
||||
<div class="mb-n3">
|
||||
@switch (option.type) {
|
||||
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
|
||||
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
|
||||
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
|
||||
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" (click)="discardChanges()" class="btn btn-secondary" [disabled]="loading || (isDirty$ | async) === false" i18n>Discard</button>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-primary" [disabled]="loading || !configForm.valid || (isDirty$ | async) === false" i18n>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
103
src-ui/src/app/components/admin/config/config.component.spec.ts
Normal file
103
src-ui/src/app/components/admin/config/config.component.spec.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { ConfigComponent } from './config.component'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { OutputTypeConfig } from 'src/app/data/paperless-config'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { BrowserModule } from '@angular/platform-browser'
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { TextComponent } from '../../common/input/text/text.component'
|
||||
import { NumberComponent } from '../../common/input/number/number.component'
|
||||
import { SwitchComponent } from '../../common/input/switch/switch.component'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { SelectComponent } from '../../common/input/select/select.component'
|
||||
|
||||
describe('ConfigComponent', () => {
|
||||
let component: ConfigComponent
|
||||
let fixture: ComponentFixture<ConfigComponent>
|
||||
let configService: ConfigService
|
||||
let toastService: ToastService
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
ConfigComponent,
|
||||
TextComponent,
|
||||
SelectComponent,
|
||||
NumberComponent,
|
||||
SwitchComponent,
|
||||
PageHeaderComponent,
|
||||
],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
BrowserModule,
|
||||
NgbModule,
|
||||
NgSelectModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
configService = TestBed.inject(ConfigService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
fixture = TestBed.createComponent(ConfigComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should load config on init, show error if necessary', () => {
|
||||
const getSpy = jest.spyOn(configService, 'getConfig')
|
||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||
getSpy.mockReturnValueOnce(
|
||||
throwError(() => new Error('Error getting config'))
|
||||
)
|
||||
component.ngOnInit()
|
||||
expect(getSpy).toHaveBeenCalled()
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
getSpy.mockReturnValueOnce(
|
||||
of({ output_type: OutputTypeConfig.PDF_A } as any)
|
||||
)
|
||||
component.ngOnInit()
|
||||
expect(component.initialConfig).toEqual({
|
||||
output_type: OutputTypeConfig.PDF_A,
|
||||
})
|
||||
})
|
||||
|
||||
it('should save config, show error if necessary', () => {
|
||||
const saveSpy = jest.spyOn(configService, 'saveConfig')
|
||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||
saveSpy.mockReturnValueOnce(
|
||||
throwError(() => new Error('Error saving config'))
|
||||
)
|
||||
component.saveConfig()
|
||||
expect(saveSpy).toHaveBeenCalled()
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
saveSpy.mockReturnValueOnce(
|
||||
of({ output_type: OutputTypeConfig.PDF_A } as any)
|
||||
)
|
||||
component.saveConfig()
|
||||
expect(component.initialConfig).toEqual({
|
||||
output_type: OutputTypeConfig.PDF_A,
|
||||
})
|
||||
})
|
||||
|
||||
it('should support discard changes', () => {
|
||||
component.initialConfig = { output_type: OutputTypeConfig.PDF_A2 } as any
|
||||
component.configForm.patchValue({ output_type: OutputTypeConfig.PDF_A })
|
||||
component.discardChanges()
|
||||
expect(component.configForm.get('output_type').value).toEqual(
|
||||
OutputTypeConfig.PDF_A2
|
||||
)
|
||||
})
|
||||
|
||||
it('should support JSON validation for e.g. user_args', () => {
|
||||
component.configForm.patchValue({ user_args: '{ foo bar }' })
|
||||
expect(component.errors).toEqual({ user_args: 'Invalid JSON' })
|
||||
component.configForm.patchValue({ user_args: '{ "foo": "bar" }' })
|
||||
expect(component.errors).toEqual({ user_args: null })
|
||||
})
|
||||
})
|
163
src-ui/src/app/components/admin/config/config.component.ts
Normal file
163
src-ui/src/app/components/admin/config/config.component.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||
import { AbstractControl, FormControl, FormGroup } from '@angular/forms'
|
||||
import {
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
Subject,
|
||||
Subscription,
|
||||
first,
|
||||
takeUntil,
|
||||
} from 'rxjs'
|
||||
import {
|
||||
PaperlessConfigOptions,
|
||||
ConfigCategory,
|
||||
ConfigOption,
|
||||
ConfigOptionType,
|
||||
PaperlessConfig,
|
||||
} from 'src/app/data/paperless-config'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-config',
|
||||
templateUrl: './config.component.html',
|
||||
styleUrl: './config.component.scss',
|
||||
})
|
||||
export class ConfigComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, OnDestroy, DirtyComponent
|
||||
{
|
||||
public readonly ConfigOptionType = ConfigOptionType
|
||||
|
||||
// generated dynamically
|
||||
public configForm = new FormGroup({})
|
||||
|
||||
public errors = {}
|
||||
|
||||
get optionCategories(): string[] {
|
||||
return Object.values(ConfigCategory)
|
||||
}
|
||||
|
||||
getCategoryOptions(category: string): ConfigOption[] {
|
||||
return PaperlessConfigOptions.filter((o) => o.category === category)
|
||||
}
|
||||
|
||||
public loading: boolean = false
|
||||
|
||||
initialConfig: PaperlessConfig
|
||||
store: BehaviorSubject<any>
|
||||
storeSub: Subscription
|
||||
isDirty$: Observable<boolean>
|
||||
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private toastService: ToastService
|
||||
) {
|
||||
super()
|
||||
this.configForm.addControl('id', new FormControl())
|
||||
PaperlessConfigOptions.forEach((option) => {
|
||||
this.configForm.addControl(option.key, new FormControl())
|
||||
})
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loading = true
|
||||
this.configService
|
||||
.getConfig()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (config) => {
|
||||
this.loading = false
|
||||
this.initialize(config)
|
||||
},
|
||||
error: (e) => {
|
||||
this.loading = false
|
||||
this.toastService.showError($localize`Error retrieving config`, e)
|
||||
},
|
||||
})
|
||||
|
||||
// validate JSON inputs
|
||||
PaperlessConfigOptions.filter(
|
||||
(o) => o.type === ConfigOptionType.JSON
|
||||
).forEach((option) => {
|
||||
this.configForm
|
||||
.get(option.key)
|
||||
.addValidators((control: AbstractControl) => {
|
||||
if (!control.value || control.value.toString().length === 0)
|
||||
return null
|
||||
try {
|
||||
JSON.parse(control.value)
|
||||
} catch (e) {
|
||||
return [
|
||||
{
|
||||
user_args: e,
|
||||
},
|
||||
]
|
||||
}
|
||||
return null
|
||||
})
|
||||
this.configForm.get(option.key).statusChanges.subscribe((status) => {
|
||||
this.errors[option.key] =
|
||||
status === 'INVALID' ? $localize`Invalid JSON` : null
|
||||
})
|
||||
this.configForm.get(option.key).updateValueAndValidity()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeNotifier.next(true)
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
|
||||
private initialize(config: PaperlessConfig) {
|
||||
if (!this.store) {
|
||||
this.store = new BehaviorSubject(config)
|
||||
|
||||
this.store
|
||||
.asObservable()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((state) => {
|
||||
this.configForm.patchValue(state, { emitEvent: false })
|
||||
})
|
||||
|
||||
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable())
|
||||
}
|
||||
this.configForm.patchValue(config)
|
||||
|
||||
this.initialConfig = config
|
||||
}
|
||||
|
||||
getDocsUrl(key: string) {
|
||||
return `https://docs.paperless-ngx.com/configuration/#${key}`
|
||||
}
|
||||
|
||||
public saveConfig() {
|
||||
this.loading = true
|
||||
this.configService
|
||||
.saveConfig(this.configForm.value as PaperlessConfig)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier), first())
|
||||
.subscribe({
|
||||
next: (config) => {
|
||||
this.loading = false
|
||||
this.initialize(config)
|
||||
this.store.next(config)
|
||||
this.toastService.showInfo($localize`Configuration updated`)
|
||||
},
|
||||
error: (e) => {
|
||||
this.loading = false
|
||||
this.toastService.showError(
|
||||
$localize`An error occurred updating configuration`,
|
||||
e
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public discardChanges() {
|
||||
this.configForm.reset(this.initialConfig)
|
||||
}
|
||||
}
|
@ -235,14 +235,14 @@
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ConsumptionTemplate }"
|
||||
tourAnchor="tour.consumption-templates">
|
||||
<a class="nav-link" routerLink="templates" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Consumption templates" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
*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">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#file-earmark-ruled" />
|
||||
</svg><span> <ng-container i18n>Templates</ng-container></span>
|
||||
<use xlink:href="assets/bootstrap-icons.svg#boxes" />
|
||||
</svg><span> <ng-container i18n>Workflows</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||
@ -271,6 +271,15 @@
|
||||
</svg><span> <ng-container i18n>Settings</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
|
||||
<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">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#sliders2-vertical" />
|
||||
</svg><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"
|
||||
|
@ -1,95 +0,0 @@
|
||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text>
|
||||
</div>
|
||||
<div class="col">
|
||||
<pngx-input-number i18n-title title="Sort order" formControlName="order" [showAdd]="false" [error]="error?.order"></pngx-input-number>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<h5 class="border-bottom pb-2" i18n>Filters</h5>
|
||||
<p class="small" i18n>Process documents that match <em>all</em> filters specified below.</p>
|
||||
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
|
||||
<pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text>
|
||||
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case insensitive.</a>" [error]="error?.filter_path"></pngx-input-text>
|
||||
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h5 class="border-bottom pb-2" i18n>Assignments</h5>
|
||||
</div>
|
||||
</div>
|
||||
<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/#consumption-templates'>documentation</a>." [error]="error?.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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@if (error?.non_field_errors) {
|
||||
<span class="text-danger"><ng-container i18n>Error</ng-container>: {{error.non_field_errors}}</span>
|
||||
}
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
|
||||
</div>
|
||||
</form>
|
@ -1,125 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { FormGroup, FormControl } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { first } from 'rxjs'
|
||||
import {
|
||||
DocumentSource,
|
||||
ConsumptionTemplate,
|
||||
} from 'src/app/data/consumption-template'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-template.service'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { EditDialogComponent } from '../edit-dialog.component'
|
||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||
import { MailRule } from 'src/app/data/mail-rule'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
|
||||
export const DOCUMENT_SOURCE_OPTIONS = [
|
||||
{
|
||||
id: DocumentSource.ConsumeFolder,
|
||||
name: $localize`Consume Folder`,
|
||||
},
|
||||
{
|
||||
id: DocumentSource.ApiUpload,
|
||||
name: $localize`API Upload`,
|
||||
},
|
||||
{
|
||||
id: DocumentSource.MailFetch,
|
||||
name: $localize`Mail Fetch`,
|
||||
},
|
||||
]
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-consumption-template-edit-dialog',
|
||||
templateUrl: './consumption-template-edit-dialog.component.html',
|
||||
styleUrls: ['./consumption-template-edit-dialog.component.scss'],
|
||||
})
|
||||
export class ConsumptionTemplateEditDialogComponent extends EditDialogComponent<ConsumptionTemplate> {
|
||||
templates: ConsumptionTemplate[]
|
||||
correspondents: Correspondent[]
|
||||
documentTypes: DocumentType[]
|
||||
storagePaths: StoragePath[]
|
||||
mailRules: MailRule[]
|
||||
customFields: CustomField[]
|
||||
|
||||
constructor(
|
||||
service: ConsumptionTemplateService,
|
||||
activeModal: NgbActiveModal,
|
||||
correspondentService: CorrespondentService,
|
||||
documentTypeService: DocumentTypeService,
|
||||
storagePathService: StoragePathService,
|
||||
mailRuleService: MailRuleService,
|
||||
userService: UserService,
|
||||
settingsService: SettingsService,
|
||||
customFieldsService: CustomFieldsService
|
||||
) {
|
||||
super(service, activeModal, userService, settingsService)
|
||||
|
||||
correspondentService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.correspondents = result.results))
|
||||
|
||||
documentTypeService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.documentTypes = result.results))
|
||||
|
||||
storagePathService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.storagePaths = result.results))
|
||||
|
||||
mailRuleService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.mailRules = result.results))
|
||||
|
||||
customFieldsService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.customFields = result.results))
|
||||
}
|
||||
|
||||
getCreateTitle() {
|
||||
return $localize`Create new consumption template`
|
||||
}
|
||||
|
||||
getEditTitle() {
|
||||
return $localize`Edit consumption template`
|
||||
}
|
||||
|
||||
getForm(): FormGroup {
|
||||
return new FormGroup({
|
||||
name: new FormControl(null),
|
||||
account: new FormControl(null),
|
||||
filter_filename: new FormControl(null),
|
||||
filter_path: new FormControl(null),
|
||||
filter_mailrule: new FormControl(null),
|
||||
order: new FormControl(null),
|
||||
sources: new FormControl([]),
|
||||
assign_title: new FormControl(null),
|
||||
assign_tags: new FormControl([]),
|
||||
assign_owner: new FormControl(null),
|
||||
assign_document_type: new FormControl(null),
|
||||
assign_correspondent: new FormControl(null),
|
||||
assign_storage_path: new FormControl(null),
|
||||
assign_view_users: new FormControl([]),
|
||||
assign_view_groups: new FormControl([]),
|
||||
assign_change_users: new FormControl([]),
|
||||
assign_change_groups: new FormControl([]),
|
||||
assign_custom_fields: new FormControl([]),
|
||||
})
|
||||
}
|
||||
|
||||
get sourceOptions() {
|
||||
return DOCUMENT_SOURCE_OPTIONS
|
||||
}
|
||||
}
|
@ -0,0 +1,207 @@
|
||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<pngx-input-number i18n-title title="Sort order" formControlName="order" [showAdd]="false" [error]="error?.order"></pngx-input-number>
|
||||
</div>
|
||||
<div class="col">
|
||||
<pngx-input-switch i18n-title title="Enabled" formControlName="enabled" [error]="error?.enabled"></pngx-input-switch>
|
||||
</div>
|
||||
</div>
|
||||
<div ngbAccordion>
|
||||
<div ngbAccordionItem>
|
||||
<h2 ngbAccordionHeader>
|
||||
<button ngbAccordionButton i18n>Triggers</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="d-flex">
|
||||
<p class="p-2" i18n>Trigger Workflow On:</p>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-auto mb-3" (click)="addTrigger()">
|
||||
<svg class="sidebaricon me-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
|
||||
</svg>
|
||||
<ng-container i18n>Add Trigger</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
<div ngbAccordion [closeOthers]="true">
|
||||
@for (trigger of object?.triggers; track trigger; let i = $index){
|
||||
<div ngbAccordionItem>
|
||||
<div ngbAccordionHeader>
|
||||
<button ngbAccordionButton>{{i + 1}}. {{getTriggerTypeOptionName(triggerFields.controls[i].value.type)}}
|
||||
@if(trigger.id > -1) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{trigger.id}}</span>
|
||||
}
|
||||
<button type="button" class="btn btn-link text-danger ms-2" (click)="removeTrigger(i)">
|
||||
<svg class="sidebaricon me-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
||||
</svg>
|
||||
<ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template [ngTemplateOutlet]="triggerForm" [ngTemplateOutletContext]="{ formGroup: triggerFields.controls[i], trigger: trigger }"></ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ngbAccordionItem>
|
||||
<h2 ngbAccordionHeader>
|
||||
<button class="btn-lg" ngbAccordionButton i18n>Actions</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="d-flex">
|
||||
<p class="p-2" i18n>Apply Actions:</p>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-auto mb-3" (click)="addAction()">
|
||||
<svg class="sidebaricon me-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
|
||||
</svg>
|
||||
<ng-container i18n>Add Action</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
<div ngbAccordion [closeOthers]="true" cdkDropList (cdkDropListDropped)="onActionDrop($event)">
|
||||
@for (action of object?.actions; track action; let i = $index){
|
||||
<div ngbAccordionItem cdkDrag [formGroup]="actionFields.controls[i]">
|
||||
<div ngbAccordionHeader>
|
||||
<button ngbAccordionButton>{{i + 1}}. {{getActionTypeOptionName(actionFields.controls[i].value.type)}}
|
||||
@if(action.id > -1) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{action.id}}</span>
|
||||
}
|
||||
<button type="button" class="btn btn-link text-danger ms-2" (click)="removeAction(i)">
|
||||
<svg class="sidebaricon me-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
||||
</svg>
|
||||
<ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</button>
|
||||
</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?.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>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@if (error?.non_field_errors) {
|
||||
<span class="text-danger"><ng-container i18n>Error</ng-container>: {{error.non_field_errors}}</span>
|
||||
}
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ng-template #triggerForm let-formGroup="formGroup" let-trigger="trigger">
|
||||
<div [formGroup]="formGroup">
|
||||
<input type="hidden" formControlName="id" />
|
||||
<pngx-input-select i18n-title title="Trigger type" [horizontal]="true" [items]="triggerTypeOptions" formControlName="type"></pngx-input-select>
|
||||
<p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text>
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.Consumption) {
|
||||
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
|
||||
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case insensitive.</a>" [error]="error?.filter_path"></pngx-input-text>
|
||||
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
||||
}
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated) {
|
||||
<pngx-input-select i18n-title title="Content matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||
@if (patternRequired) {
|
||||
<pngx-input-text i18n-title title="Content matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
|
||||
}
|
||||
@if (patternRequired) {
|
||||
<pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive"></pngx-input-check>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated) {
|
||||
<div class="col-md-6">
|
||||
<pngx-input-tags [allowCreate]="false" i18n-title title="Has tags" formControlName="filter_has_tags"></pngx-input-tags>
|
||||
<pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
@ -0,0 +1,5 @@
|
||||
.btn.text-danger {
|
||||
&:hover, &:focus {
|
||||
color: rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important;
|
||||
}
|
||||
}
|
@ -18,24 +18,69 @@ import { PermissionsUserComponent } from '../../input/permissions/permissions-us
|
||||
import { SelectComponent } from '../../input/select/select.component'
|
||||
import { TagsComponent } from '../../input/tags/tags.component'
|
||||
import { TextComponent } from '../../input/text/text.component'
|
||||
import { SwitchComponent } from '../../input/switch/switch.component'
|
||||
import { EditDialogMode } from '../edit-dialog.component'
|
||||
import { ConsumptionTemplateEditDialogComponent } from './consumption-template-edit-dialog.component'
|
||||
import {
|
||||
DOCUMENT_SOURCE_OPTIONS,
|
||||
WORKFLOW_ACTION_OPTIONS,
|
||||
WORKFLOW_TYPE_OPTIONS,
|
||||
WorkflowEditDialogComponent,
|
||||
} from './workflow-edit-dialog.component'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import {
|
||||
WorkflowTriggerType,
|
||||
DocumentSource,
|
||||
} from 'src/app/data/workflow-trigger'
|
||||
import { CdkDragDrop } from '@angular/cdk/drag-drop'
|
||||
import {
|
||||
WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/app/data/workflow-action'
|
||||
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
|
||||
|
||||
const workflow: Workflow = {
|
||||
name: 'Workflow 1',
|
||||
id: 1,
|
||||
order: 1,
|
||||
enabled: true,
|
||||
triggers: [
|
||||
{
|
||||
id: 1,
|
||||
type: WorkflowTriggerType.Consumption,
|
||||
sources: [DocumentSource.ConsumeFolder],
|
||||
filter_filename: '*',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 1,
|
||||
type: WorkflowActionType.Assignment,
|
||||
assign_title: 'foo',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: WorkflowActionType.Assignment,
|
||||
assign_owner: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
describe('ConsumptionTemplateEditDialogComponent', () => {
|
||||
let component: ConsumptionTemplateEditDialogComponent
|
||||
let component: WorkflowEditDialogComponent
|
||||
let settingsService: SettingsService
|
||||
let fixture: ComponentFixture<ConsumptionTemplateEditDialogComponent>
|
||||
let fixture: ComponentFixture<WorkflowEditDialogComponent>
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
ConsumptionTemplateEditDialogComponent,
|
||||
WorkflowEditDialogComponent,
|
||||
IfPermissionsDirective,
|
||||
IfOwnerDirective,
|
||||
SelectComponent,
|
||||
TextComponent,
|
||||
NumberComponent,
|
||||
SwitchComponent,
|
||||
TagsComponent,
|
||||
PermissionsUserComponent,
|
||||
PermissionsGroupComponent,
|
||||
@ -113,7 +158,7 @@ describe('ConsumptionTemplateEditDialogComponent', () => {
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(ConsumptionTemplateEditDialogComponent)
|
||||
fixture = TestBed.createComponent(WorkflowEditDialogComponent)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||
component = fixture.componentInstance
|
||||
@ -121,15 +166,70 @@ describe('ConsumptionTemplateEditDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should support create and edit modes', () => {
|
||||
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
||||
component.dialogMode = EditDialogMode.CREATE
|
||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||
fixture.detectChanges()
|
||||
expect(createTitleSpy).toHaveBeenCalled()
|
||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||
expect(component.object).toBeUndefined()
|
||||
component.addAction()
|
||||
expect(component.object).not.toBeUndefined()
|
||||
expect(component.object.actions).toHaveLength(1)
|
||||
component.object = undefined
|
||||
component.addTrigger()
|
||||
expect(component.object).not.toBeUndefined()
|
||||
expect(component.object.triggers).toHaveLength(1)
|
||||
|
||||
component.dialogMode = EditDialogMode.EDIT
|
||||
fixture.detectChanges()
|
||||
expect(editTitleSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return source options, type options, type name', () => {
|
||||
// coverage
|
||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
|
||||
expect(
|
||||
component.getTriggerTypeOptionName(WorkflowTriggerType.DocumentAdded)
|
||||
).toEqual('Document Added')
|
||||
expect(component.getTriggerTypeOptionName(null)).toEqual('')
|
||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||
expect(component.actionTypeOptions).toEqual(WORKFLOW_ACTION_OPTIONS)
|
||||
expect(
|
||||
component.getActionTypeOptionName(WorkflowActionType.Assignment)
|
||||
).toEqual('Assignment')
|
||||
expect(component.getActionTypeOptionName(null)).toEqual('')
|
||||
})
|
||||
|
||||
it('should support add and remove triggers and actions', () => {
|
||||
component.object = workflow
|
||||
component.addTrigger()
|
||||
expect(component.object.triggers.length).toEqual(2)
|
||||
component.addAction()
|
||||
expect(component.object.actions.length).toEqual(3)
|
||||
component.removeTrigger(1)
|
||||
expect(component.object.triggers.length).toEqual(1)
|
||||
component.removeAction(1)
|
||||
expect(component.object.actions.length).toEqual(2)
|
||||
})
|
||||
|
||||
it('should update order and remove ids from actions on drag n drop', () => {
|
||||
const action1 = workflow.actions[0]
|
||||
const action2 = workflow.actions[1]
|
||||
component.object = workflow
|
||||
component.onActionDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
|
||||
WorkflowAction[]
|
||||
>)
|
||||
expect(component.object.actions).toEqual([action2, action1])
|
||||
expect(action1.id).toBeNull()
|
||||
expect(action2.id).toBeNull()
|
||||
})
|
||||
|
||||
it('should not include auto matching in algorithms', () => {
|
||||
expect(component.getMatchingAlgorithms()).not.toContain(
|
||||
MATCHING_ALGORITHMS.find((a) => a.id === MATCH_AUTO)
|
||||
)
|
||||
})
|
||||
})
|
@ -0,0 +1,300 @@
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { FormGroup, FormControl, FormArray } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { first } from 'rxjs'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { EditDialogComponent } from '../edit-dialog.component'
|
||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||
import { MailRule } from 'src/app/data/mail-rule'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import {
|
||||
DocumentSource,
|
||||
WorkflowTriggerType,
|
||||
} from 'src/app/data/workflow-trigger'
|
||||
import {
|
||||
WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/app/data/workflow-action'
|
||||
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'
|
||||
import {
|
||||
MATCHING_ALGORITHMS,
|
||||
MATCH_AUTO,
|
||||
MATCH_NONE,
|
||||
} from 'src/app/data/matching-model'
|
||||
|
||||
export const DOCUMENT_SOURCE_OPTIONS = [
|
||||
{
|
||||
id: DocumentSource.ConsumeFolder,
|
||||
name: $localize`Consume Folder`,
|
||||
},
|
||||
{
|
||||
id: DocumentSource.ApiUpload,
|
||||
name: $localize`API Upload`,
|
||||
},
|
||||
{
|
||||
id: DocumentSource.MailFetch,
|
||||
name: $localize`Mail Fetch`,
|
||||
},
|
||||
]
|
||||
|
||||
export const WORKFLOW_TYPE_OPTIONS = [
|
||||
{
|
||||
id: WorkflowTriggerType.Consumption,
|
||||
name: $localize`Consumption Started`,
|
||||
},
|
||||
{
|
||||
id: WorkflowTriggerType.DocumentAdded,
|
||||
name: $localize`Document Added`,
|
||||
},
|
||||
{
|
||||
id: WorkflowTriggerType.DocumentUpdated,
|
||||
name: $localize`Document Updated`,
|
||||
},
|
||||
]
|
||||
|
||||
export const WORKFLOW_ACTION_OPTIONS = [
|
||||
{
|
||||
id: WorkflowActionType.Assignment,
|
||||
name: $localize`Assignment`,
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
||||
(a) => a.id !== MATCH_AUTO
|
||||
)
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-workflow-edit-dialog',
|
||||
templateUrl: './workflow-edit-dialog.component.html',
|
||||
styleUrls: ['./workflow-edit-dialog.component.scss'],
|
||||
})
|
||||
export class WorkflowEditDialogComponent
|
||||
extends EditDialogComponent<Workflow>
|
||||
implements OnInit
|
||||
{
|
||||
public WorkflowTriggerType = WorkflowTriggerType
|
||||
|
||||
templates: Workflow[]
|
||||
correspondents: Correspondent[]
|
||||
documentTypes: DocumentType[]
|
||||
storagePaths: StoragePath[]
|
||||
mailRules: MailRule[]
|
||||
customFields: CustomField[]
|
||||
|
||||
expandedItem: number = null
|
||||
|
||||
constructor(
|
||||
service: WorkflowService,
|
||||
activeModal: NgbActiveModal,
|
||||
correspondentService: CorrespondentService,
|
||||
documentTypeService: DocumentTypeService,
|
||||
storagePathService: StoragePathService,
|
||||
mailRuleService: MailRuleService,
|
||||
userService: UserService,
|
||||
settingsService: SettingsService,
|
||||
customFieldsService: CustomFieldsService
|
||||
) {
|
||||
super(service, activeModal, userService, settingsService)
|
||||
|
||||
correspondentService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.correspondents = result.results))
|
||||
|
||||
documentTypeService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.documentTypes = result.results))
|
||||
|
||||
storagePathService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.storagePaths = result.results))
|
||||
|
||||
mailRuleService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.mailRules = result.results))
|
||||
|
||||
customFieldsService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.customFields = result.results))
|
||||
}
|
||||
|
||||
getCreateTitle() {
|
||||
return $localize`Create new workflow`
|
||||
}
|
||||
|
||||
getEditTitle() {
|
||||
return $localize`Edit workflow`
|
||||
}
|
||||
|
||||
getForm(): FormGroup {
|
||||
return new FormGroup({
|
||||
name: new FormControl(null),
|
||||
order: new FormControl(null),
|
||||
enabled: new FormControl(true),
|
||||
triggers: new FormArray([]),
|
||||
actions: new FormArray([]),
|
||||
})
|
||||
}
|
||||
|
||||
getMatchingAlgorithms() {
|
||||
// No auto matching
|
||||
return TRIGGER_MATCHING_ALGORITHMS
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
super.ngOnInit()
|
||||
this.updateTriggerActionFields()
|
||||
}
|
||||
|
||||
get triggerFields(): FormArray {
|
||||
return this.objectForm.get('triggers') as FormArray
|
||||
}
|
||||
|
||||
get actionFields(): FormArray {
|
||||
return this.objectForm.get('actions') as FormArray
|
||||
}
|
||||
|
||||
private updateTriggerActionFields(emitEvent: boolean = false) {
|
||||
this.triggerFields.clear({ emitEvent: false })
|
||||
this.object?.triggers.forEach((trigger) => {
|
||||
this.triggerFields.push(
|
||||
new FormGroup({
|
||||
id: new FormControl(trigger.id),
|
||||
type: new FormControl(trigger.type),
|
||||
sources: new FormControl(trigger.sources),
|
||||
filter_filename: new FormControl(trigger.filter_filename),
|
||||
filter_path: new FormControl(trigger.filter_path),
|
||||
filter_mailrule: new FormControl(trigger.filter_mailrule),
|
||||
matching_algorithm: new FormControl(MATCH_NONE),
|
||||
match: new FormControl(''),
|
||||
is_insensitive: new FormControl(true),
|
||||
filter_has_tags: new FormControl(trigger.filter_has_tags),
|
||||
filter_has_correspondent: new FormControl(
|
||||
trigger.filter_has_correspondent
|
||||
),
|
||||
filter_has_document_type: new FormControl(
|
||||
trigger.filter_has_document_type
|
||||
),
|
||||
}),
|
||||
{ emitEvent }
|
||||
)
|
||||
})
|
||||
|
||||
this.actionFields.clear({ emitEvent: false })
|
||||
this.object?.actions.forEach((action) => {
|
||||
this.actionFields.push(
|
||||
new FormGroup({
|
||||
id: new FormControl(action.id),
|
||||
type: new FormControl(action.type),
|
||||
assign_title: new FormControl(action.assign_title),
|
||||
assign_tags: new FormControl(action.assign_tags),
|
||||
assign_owner: new FormControl(action.assign_owner),
|
||||
assign_document_type: new FormControl(action.assign_document_type),
|
||||
assign_correspondent: new FormControl(action.assign_correspondent),
|
||||
assign_storage_path: new FormControl(action.assign_storage_path),
|
||||
assign_view_users: new FormControl(action.assign_view_users),
|
||||
assign_view_groups: new FormControl(action.assign_view_groups),
|
||||
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),
|
||||
}),
|
||||
{ emitEvent }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
get sourceOptions() {
|
||||
return DOCUMENT_SOURCE_OPTIONS
|
||||
}
|
||||
|
||||
get triggerTypeOptions() {
|
||||
return WORKFLOW_TYPE_OPTIONS
|
||||
}
|
||||
|
||||
getTriggerTypeOptionName(type: WorkflowTriggerType): string {
|
||||
return this.triggerTypeOptions.find((t) => t.id === type)?.name ?? ''
|
||||
}
|
||||
|
||||
addTrigger() {
|
||||
if (!this.object) {
|
||||
this.object = Object.assign({}, this.objectForm.value)
|
||||
}
|
||||
this.object.triggers.push({
|
||||
type: WorkflowTriggerType.Consumption,
|
||||
sources: [],
|
||||
filter_filename: null,
|
||||
filter_path: null,
|
||||
filter_mailrule: null,
|
||||
filter_has_tags: [],
|
||||
filter_has_correspondent: null,
|
||||
filter_has_document_type: null,
|
||||
})
|
||||
|
||||
this.updateTriggerActionFields()
|
||||
}
|
||||
|
||||
get actionTypeOptions() {
|
||||
return WORKFLOW_ACTION_OPTIONS
|
||||
}
|
||||
|
||||
getActionTypeOptionName(type: WorkflowActionType): string {
|
||||
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
|
||||
}
|
||||
|
||||
addAction() {
|
||||
if (!this.object) {
|
||||
this.object = Object.assign({}, this.objectForm.value)
|
||||
}
|
||||
this.object.actions.push({
|
||||
type: WorkflowActionType.Assignment,
|
||||
assign_title: null,
|
||||
assign_tags: [],
|
||||
assign_document_type: null,
|
||||
assign_correspondent: null,
|
||||
assign_storage_path: null,
|
||||
assign_owner: null,
|
||||
assign_view_users: [],
|
||||
assign_view_groups: [],
|
||||
assign_change_users: [],
|
||||
assign_change_groups: [],
|
||||
assign_custom_fields: [],
|
||||
})
|
||||
|
||||
this.updateTriggerActionFields()
|
||||
}
|
||||
|
||||
removeTrigger(index: number) {
|
||||
this.object.triggers.splice(index, 1)
|
||||
this.updateTriggerActionFields()
|
||||
}
|
||||
|
||||
removeAction(index: number) {
|
||||
this.object.actions.splice(index, 1)
|
||||
this.updateTriggerActionFields()
|
||||
}
|
||||
|
||||
onActionDrop(event: CdkDragDrop<WorkflowAction[]>) {
|
||||
moveItemInArray(
|
||||
this.object.actions,
|
||||
event.previousIndex,
|
||||
event.currentIndex
|
||||
)
|
||||
// removing id will effectively re-create the actions in this order
|
||||
this.object.actions.forEach((a) => (a.id = null))
|
||||
this.updateTriggerActionFields()
|
||||
}
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
<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">
|
||||
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
||||
@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)">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
|
@ -0,0 +1,4 @@
|
||||
.accordion {
|
||||
--bs-accordion-btn-padding-x: 0.75rem;
|
||||
--bs-accordion-btn-padding-y: 0.375rem;
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
<div class="mb-3">
|
||||
<div class="row">
|
||||
@if (!horizontal) {
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
|
||||
<label class="form-label" [for]="inputId">{{title}}</label>
|
||||
@if (removable) {
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
||||
</svg> <ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div [ngClass]="{'col-md-9': horizontal, 'align-items-center': horizontal, 'd-flex': horizontal}">
|
||||
<div class="form-check form-switch">
|
||||
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
|
||||
@if (horizontal) {
|
||||
<label class="form-check-label" [for]="inputId">{{title}}</label>
|
||||
}
|
||||
@if (hint) {
|
||||
<div class="form-text text-muted">{{hint}}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,39 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { SwitchComponent } from './switch.component'
|
||||
import {
|
||||
FormsModule,
|
||||
NG_VALUE_ACCESSOR,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
|
||||
describe('SwitchComponent', () => {
|
||||
let component: SwitchComponent
|
||||
let fixture: ComponentFixture<SwitchComponent>
|
||||
let input: HTMLInputElement
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [SwitchComponent],
|
||||
providers: [],
|
||||
imports: [FormsModule, ReactiveFormsModule],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(SwitchComponent)
|
||||
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
input = component.inputField.nativeElement
|
||||
})
|
||||
|
||||
it('should support use of checkbox', () => {
|
||||
input.checked = true
|
||||
input.dispatchEvent(new Event('change'))
|
||||
fixture.detectChanges()
|
||||
expect(component.value).toBeTruthy()
|
||||
|
||||
input.checked = false
|
||||
input.dispatchEvent(new Event('change'))
|
||||
fixture.detectChanges()
|
||||
expect(component.value).toBeFalsy()
|
||||
})
|
||||
})
|
@ -0,0 +1,21 @@
|
||||
import { Component, forwardRef } from '@angular/core'
|
||||
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||
import { AbstractInputComponent } from '../abstract-input'
|
||||
|
||||
@Component({
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => SwitchComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
selector: 'pngx-input-switch',
|
||||
templateUrl: './switch.component.html',
|
||||
styleUrls: ['./switch.component.scss'],
|
||||
})
|
||||
export class SwitchComponent extends AbstractInputComponent<boolean> {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
<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">
|
||||
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
||||
@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)">
|
||||
<svg class="sidebaricon" fill="currentColor">
|
||||
|
@ -123,28 +123,73 @@
|
||||
<div [formGroup]="customFieldFormFields.controls[i]">
|
||||
@switch (getCustomFieldFromInstance(fieldInstance)?.data_type) {
|
||||
@case (PaperlessCustomFieldDataType.String) {
|
||||
<pngx-input-text formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-text>
|
||||
<pngx-input-text formControlName="value"
|
||||
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
|
||||
[removable]="userIsOwner"
|
||||
(removed)="removeField(fieldInstance)"
|
||||
[horizontal]="true"
|
||||
[error]="getCustomFieldError(i)"></pngx-input-text>
|
||||
}
|
||||
@case (PaperlessCustomFieldDataType.Date) {
|
||||
<pngx-input-date formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-date>
|
||||
<pngx-input-date formControlName="value"
|
||||
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
|
||||
[removable]="userIsOwner"
|
||||
(removed)="removeField(fieldInstance)"
|
||||
[horizontal]="true"
|
||||
[error]="getCustomFieldError(i)"></pngx-input-date>
|
||||
}
|
||||
@case (PaperlessCustomFieldDataType.Integer) {
|
||||
<pngx-input-number formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [error]="getCustomFieldError(i)"></pngx-input-number>
|
||||
<pngx-input-number formControlName="value"
|
||||
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
|
||||
[removable]="userIsOwner"
|
||||
(removed)="removeField(fieldInstance)"
|
||||
[horizontal]="true"
|
||||
[showAdd]="false"
|
||||
[error]="getCustomFieldError(i)"></pngx-input-number>
|
||||
}
|
||||
@case (PaperlessCustomFieldDataType.Float) {
|
||||
<pngx-input-number formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".1" [error]="getCustomFieldError(i)"></pngx-input-number>
|
||||
<pngx-input-number formControlName="value"
|
||||
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
|
||||
[removable]="userIsOwner"
|
||||
(removed)="removeField(fieldInstance)"
|
||||
[horizontal]="true"
|
||||
[showAdd]="false"
|
||||
[step]=".1"
|
||||
[error]="getCustomFieldError(i)"></pngx-input-number>
|
||||
}
|
||||
@case (PaperlessCustomFieldDataType.Monetary) {
|
||||
<pngx-input-number formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".01" [error]="getCustomFieldError(i)"></pngx-input-number>
|
||||
<pngx-input-number formControlName="value"
|
||||
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
|
||||
[removable]="userIsOwner"
|
||||
(removed)="removeField(fieldInstance)"
|
||||
[horizontal]="true"
|
||||
[showAdd]="false"
|
||||
[step]=".01"
|
||||
[error]="getCustomFieldError(i)"></pngx-input-number>
|
||||
}
|
||||
@case (PaperlessCustomFieldDataType.Boolean) {
|
||||
<pngx-input-check formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true"></pngx-input-check>
|
||||
<pngx-input-check formControlName="value"
|
||||
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
|
||||
[removable]="userIsOwner"
|
||||
(removed)="removeField(fieldInstance)"
|
||||
[horizontal]="true"></pngx-input-check>
|
||||
}
|
||||
@case (PaperlessCustomFieldDataType.Url) {
|
||||
<pngx-input-url formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-url>
|
||||
<pngx-input-url formControlName="value"
|
||||
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
|
||||
[removable]="userIsOwner"
|
||||
(removed)="removeField(fieldInstance)"
|
||||
[horizontal]="true"
|
||||
[error]="getCustomFieldError(i)"></pngx-input-url>
|
||||
}
|
||||
@case (PaperlessCustomFieldDataType.DocumentLink) {
|
||||
<pngx-input-document-link formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [parentDocumentID]="documentId" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-document-link>
|
||||
<pngx-input-document-link formControlName="value"
|
||||
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
|
||||
[parentDocumentID]="documentId"
|
||||
[removable]="userIsOwner"
|
||||
(removed)="removeField(fieldInstance)"
|
||||
[horizontal]="true"
|
||||
[error]="getCustomFieldError(i)"></pngx-input-document-link>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
@ -1,9 +1,9 @@
|
||||
<pngx-page-header title="Consumption Templates" i18n-title>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ConsumptionTemplate }">
|
||||
<pngx-page-header title="Workflows" i18n-title>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editWorkflow()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Workflow }">
|
||||
<svg class="sidebaricon me-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
|
||||
</svg>
|
||||
<ng-container i18n>Add Template</ng-container>
|
||||
<ng-container i18n>Add Workflow</ng-container>
|
||||
</button>
|
||||
</pngx-page-header>
|
||||
|
||||
@ -13,25 +13,27 @@
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col" i18n>Sort order</div>
|
||||
<div class="col" i18n>Document Sources</div>
|
||||
<div class="col" i18n>Status</div>
|
||||
<div class="col" i18n>Triggers</div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@for (template of templates; track template) {
|
||||
@for (workflow of workflows; track workflow.id) {
|
||||
<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)="editTemplate(template)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.ConsumptionTemplate)">{{template.name}}</button></div>
|
||||
<div class="col d-flex align-items-center"><code>{{template.order}}</code></div>
|
||||
<div class="col d-flex align-items-center">{{getSourceList(template)}}</div>
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editWorkflow(workflow)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Workflow)">{{workflow.name}}</button></div>
|
||||
<div class="col d-flex align-items-center"><code>{{workflow.order}}</code></div>
|
||||
<div class="col d-flex align-items-center"><code> @if(workflow.enabled) { <ng-container i18n>Enabled</ng-container> } @else { <span i18n class="text-muted">Disabled</span> }</code></div>
|
||||
<div class="col d-flex align-items-center">{{getTypesList(workflow)}}</div>
|
||||
<div class="col">
|
||||
<div class="btn-group">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editTemplate(template)">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Workflow }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editWorkflow(workflow)">
|
||||
<svg class="buttonicon-sm" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#pencil" />
|
||||
</svg> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(template)">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Workflow }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteWorkflow(workflow)">
|
||||
<svg class="buttonicon-sm" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
||||
</svg> <ng-container i18n>Delete</ng-container>
|
||||
@ -41,7 +43,7 @@
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (templates.length === 0) {
|
||||
<li class="list-group-item" i18n>No templates defined.</li>
|
||||
@if (workflows.length === 0) {
|
||||
<li class="list-group-item" i18n>No workflows defined.</li>
|
||||
}
|
||||
</ul>
|
@ -9,55 +9,76 @@ import {
|
||||
NgbModalModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import {
|
||||
DocumentSource,
|
||||
ConsumptionTemplate,
|
||||
} from 'src/app/data/consumption-template'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-template.service'
|
||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ConsumptionTemplatesComponent } from './consumption-templates.component'
|
||||
import { ConsumptionTemplateEditDialogComponent } from '../../common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
|
||||
import { WorkflowsComponent } from './workflows.component'
|
||||
import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import {
|
||||
DocumentSource,
|
||||
WorkflowTriggerType,
|
||||
} from 'src/app/data/workflow-trigger'
|
||||
import { WorkflowActionType } from 'src/app/data/workflow-action'
|
||||
|
||||
const templates: ConsumptionTemplate[] = [
|
||||
const workflows: Workflow[] = [
|
||||
{
|
||||
id: 0,
|
||||
name: 'Template 1',
|
||||
order: 0,
|
||||
sources: [
|
||||
DocumentSource.ConsumeFolder,
|
||||
DocumentSource.ApiUpload,
|
||||
DocumentSource.MailFetch,
|
||||
name: 'Workflow 1',
|
||||
id: 1,
|
||||
order: 1,
|
||||
enabled: true,
|
||||
triggers: [
|
||||
{
|
||||
id: 1,
|
||||
type: WorkflowTriggerType.Consumption,
|
||||
sources: [DocumentSource.ConsumeFolder],
|
||||
filter_filename: '*',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 1,
|
||||
type: WorkflowActionType.Assignment,
|
||||
assign_title: 'foo',
|
||||
},
|
||||
],
|
||||
filter_filename: 'foo',
|
||||
filter_path: 'bar',
|
||||
assign_tags: [1, 2, 3],
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Template 2',
|
||||
order: 1,
|
||||
sources: [DocumentSource.MailFetch],
|
||||
filter_filename: null,
|
||||
filter_path: 'foo/bar',
|
||||
assign_owner: 1,
|
||||
name: 'Workflow 2',
|
||||
id: 2,
|
||||
order: 2,
|
||||
enabled: true,
|
||||
triggers: [
|
||||
{
|
||||
id: 2,
|
||||
type: WorkflowTriggerType.DocumentAdded,
|
||||
filter_filename: 'foo',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 2,
|
||||
type: WorkflowActionType.Assignment,
|
||||
assign_title: 'bar',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
describe('ConsumptionTemplatesComponent', () => {
|
||||
let component: ConsumptionTemplatesComponent
|
||||
let fixture: ComponentFixture<ConsumptionTemplatesComponent>
|
||||
let consumptionTemplateService: ConsumptionTemplateService
|
||||
describe('WorkflowsComponent', () => {
|
||||
let component: WorkflowsComponent
|
||||
let fixture: ComponentFixture<WorkflowsComponent>
|
||||
let workflowService: WorkflowService
|
||||
let modalService: NgbModal
|
||||
let toastService: ToastService
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
ConsumptionTemplatesComponent,
|
||||
WorkflowsComponent,
|
||||
IfPermissionsDirective,
|
||||
PageHeaderComponent,
|
||||
ConfirmDialogComponent,
|
||||
@ -81,18 +102,18 @@ describe('ConsumptionTemplatesComponent', () => {
|
||||
],
|
||||
})
|
||||
|
||||
consumptionTemplateService = TestBed.inject(ConsumptionTemplateService)
|
||||
jest.spyOn(consumptionTemplateService, 'listAll').mockReturnValue(
|
||||
workflowService = TestBed.inject(WorkflowService)
|
||||
jest.spyOn(workflowService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
count: templates.length,
|
||||
all: templates.map((o) => o.id),
|
||||
results: templates,
|
||||
count: workflows.length,
|
||||
all: workflows.map((o) => o.id),
|
||||
results: workflows,
|
||||
})
|
||||
)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
|
||||
fixture = TestBed.createComponent(ConsumptionTemplatesComponent)
|
||||
fixture = TestBed.createComponent(WorkflowsComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
@ -108,8 +129,7 @@ describe('ConsumptionTemplatesComponent', () => {
|
||||
createButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog =
|
||||
modal.componentInstance as ConsumptionTemplateEditDialogComponent
|
||||
const editDialog = modal.componentInstance as WorkflowEditDialogComponent
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error creating item' })
|
||||
@ -117,7 +137,7 @@ describe('ConsumptionTemplatesComponent', () => {
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(templates[0])
|
||||
editDialog.succeeded.emit(workflows[0])
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
@ -133,9 +153,8 @@ describe('ConsumptionTemplatesComponent', () => {
|
||||
editButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog =
|
||||
modal.componentInstance as ConsumptionTemplateEditDialogComponent
|
||||
expect(editDialog.object).toEqual(templates[0])
|
||||
const editDialog = modal.componentInstance as WorkflowEditDialogComponent
|
||||
expect(editDialog.object).toEqual(workflows[0])
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error editing item' })
|
||||
@ -143,7 +162,7 @@ describe('ConsumptionTemplatesComponent', () => {
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(templates[0])
|
||||
editDialog.succeeded.emit(workflows[0])
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
@ -152,7 +171,7 @@ describe('ConsumptionTemplatesComponent', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const deleteSpy = jest.spyOn(consumptionTemplateService, 'delete')
|
||||
const deleteSpy = jest.spyOn(workflowService, 'delete')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[3]
|
@ -1,33 +1,33 @@
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-template.service'
|
||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import { ConsumptionTemplate } from 'src/app/data/consumption-template'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import {
|
||||
ConsumptionTemplateEditDialogComponent,
|
||||
DOCUMENT_SOURCE_OPTIONS,
|
||||
} from '../../common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
|
||||
WorkflowEditDialogComponent,
|
||||
WORKFLOW_TYPE_OPTIONS,
|
||||
} from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-consumption-templates',
|
||||
templateUrl: './consumption-templates.component.html',
|
||||
styleUrls: ['./consumption-templates.component.scss'],
|
||||
selector: 'pngx-workflows',
|
||||
templateUrl: './workflows.component.html',
|
||||
styleUrls: ['./workflows.component.scss'],
|
||||
})
|
||||
export class ConsumptionTemplatesComponent
|
||||
export class WorkflowsComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit
|
||||
{
|
||||
public templates: ConsumptionTemplate[] = []
|
||||
public workflows: Workflow[] = []
|
||||
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
constructor(
|
||||
private consumptionTemplateService: ConsumptionTemplateService,
|
||||
private workflowService: WorkflowService,
|
||||
public permissionsService: PermissionsService,
|
||||
private modalService: NgbModal,
|
||||
private toastService: ToastService
|
||||
@ -40,68 +40,74 @@ export class ConsumptionTemplatesComponent
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.consumptionTemplateService
|
||||
this.workflowService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((r) => {
|
||||
this.templates = r.results
|
||||
this.workflows = r.results
|
||||
})
|
||||
}
|
||||
|
||||
getSourceList(template: ConsumptionTemplate): string {
|
||||
return template.sources
|
||||
.map((id) => DOCUMENT_SOURCE_OPTIONS.find((s) => s.id === id).name)
|
||||
getTypesList(template: Workflow): string {
|
||||
return template.triggers
|
||||
.map(
|
||||
(trigger) =>
|
||||
WORKFLOW_TYPE_OPTIONS.find((t) => t.id === trigger.type).name
|
||||
)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
editTemplate(rule: ConsumptionTemplate) {
|
||||
const modal = this.modalService.open(
|
||||
ConsumptionTemplateEditDialogComponent,
|
||||
{
|
||||
backdrop: 'static',
|
||||
size: 'xl',
|
||||
}
|
||||
)
|
||||
modal.componentInstance.dialogMode = rule
|
||||
editWorkflow(workflow: Workflow) {
|
||||
const modal = this.modalService.open(WorkflowEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'xl',
|
||||
})
|
||||
modal.componentInstance.dialogMode = workflow
|
||||
? EditDialogMode.EDIT
|
||||
: EditDialogMode.CREATE
|
||||
modal.componentInstance.object = rule
|
||||
if (workflow) {
|
||||
// quick "deep" clone so original doesnt get modified
|
||||
const clone = Object.assign({}, workflow)
|
||||
clone.actions = [...workflow.actions]
|
||||
clone.triggers = [...workflow.triggers]
|
||||
modal.componentInstance.object = clone
|
||||
}
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((newTemplate) => {
|
||||
.subscribe((newWorkflow) => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Saved template "${newTemplate.name}".`
|
||||
$localize`Saved workflow "${newWorkflow.name}".`
|
||||
)
|
||||
this.consumptionTemplateService.clearCache()
|
||||
this.workflowService.clearCache()
|
||||
this.reload()
|
||||
})
|
||||
modal.componentInstance.failed
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error saving template.`, e)
|
||||
this.toastService.showError($localize`Error saving workflow.`, e)
|
||||
})
|
||||
}
|
||||
|
||||
deleteTemplate(rule: ConsumptionTemplate) {
|
||||
deleteWorkflow(workflow: Workflow) {
|
||||
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Confirm delete template`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete this template.`
|
||||
modal.componentInstance.title = $localize`Confirm delete workflow`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete this workflow.`
|
||||
modal.componentInstance.message = $localize`This operation cannot be undone.`
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.consumptionTemplateService.delete(rule).subscribe({
|
||||
this.workflowService.delete(workflow).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo($localize`Deleted template`)
|
||||
this.consumptionTemplateService.clearCache()
|
||||
this.toastService.showInfo($localize`Deleted workflow`)
|
||||
this.workflowService.clearCache()
|
||||
this.reload()
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error deleting template.`, e)
|
||||
this.toastService.showError($localize`Error deleting workflow.`, e)
|
||||
},
|
||||
})
|
||||
})
|
183
src-ui/src/app/data/paperless-config.ts
Normal file
183
src-ui/src/app/data/paperless-config.ts
Normal file
@ -0,0 +1,183 @@
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
|
||||
// see /src/paperless/models.py
|
||||
|
||||
export enum OutputTypeConfig {
|
||||
PDF = 'pdf',
|
||||
PDF_A = 'pdfa',
|
||||
PDF_A1 = 'pdfa-1',
|
||||
PDF_A2 = 'pdfa-2',
|
||||
PDF_A3 = 'pdfa-3',
|
||||
}
|
||||
|
||||
export enum ModeConfig {
|
||||
SKIP = 'skip',
|
||||
REDO = 'redo',
|
||||
FORCE = 'force',
|
||||
SKIP_NO_ARCHIVE = 'skip_noarchive',
|
||||
}
|
||||
|
||||
export enum ArchiveFileConfig {
|
||||
NEVER = 'never',
|
||||
WITH_TEXT = 'with_text',
|
||||
ALWAYS = 'always',
|
||||
}
|
||||
|
||||
export enum CleanConfig {
|
||||
CLEAN = 'clean',
|
||||
FINAL = 'clean-final',
|
||||
NONE = 'none',
|
||||
}
|
||||
|
||||
export enum ColorConvertConfig {
|
||||
UNCHANGED = 'LeaveColorUnchanged',
|
||||
RGB = 'RGB',
|
||||
INDEPENDENT = 'UseDeviceIndependentColor',
|
||||
GRAY = 'Gray',
|
||||
CMYK = 'CMYK',
|
||||
}
|
||||
|
||||
export enum ConfigOptionType {
|
||||
String = 'string',
|
||||
Number = 'number',
|
||||
Select = 'select',
|
||||
Boolean = 'boolean',
|
||||
JSON = 'json',
|
||||
}
|
||||
|
||||
export const ConfigCategory = {
|
||||
OCR: $localize`OCR Settings`,
|
||||
}
|
||||
|
||||
export interface ConfigOption {
|
||||
key: string
|
||||
title: string
|
||||
type: ConfigOptionType
|
||||
choices?: Array<{ id: string; name: string }>
|
||||
config_key?: string
|
||||
category: string
|
||||
}
|
||||
|
||||
function mapToItems(enumObj: Object): Array<{ id: string; name: string }> {
|
||||
return Object.keys(enumObj).map((key) => {
|
||||
return {
|
||||
id: enumObj[key],
|
||||
name: enumObj[key],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const PaperlessConfigOptions: ConfigOption[] = [
|
||||
{
|
||||
key: 'output_type',
|
||||
title: $localize`Output Type`,
|
||||
type: ConfigOptionType.Select,
|
||||
choices: mapToItems(OutputTypeConfig),
|
||||
config_key: 'PAPERLESS_OCR_OUTPUT_TYPE',
|
||||
category: ConfigCategory.OCR,
|
||||
},
|
||||
{
|
||||
key: 'language',
|
||||
title: $localize`Language`,
|
||||
type: ConfigOptionType.String,
|
||||
config_key: 'PAPERLESS_OCR_LANGUAGE',
|
||||
category: ConfigCategory.OCR,
|
||||
},
|
||||
{
|
||||
key: 'pages',
|
||||
title: $localize`Pages`,
|
||||
type: ConfigOptionType.Number,
|
||||
config_key: 'PAPERLESS_OCR_PAGES',
|
||||
category: ConfigCategory.OCR,
|
||||
},
|
||||
{
|
||||
key: 'mode',
|
||||
title: $localize`Mode`,
|
||||
type: ConfigOptionType.Select,
|
||||
choices: mapToItems(ModeConfig),
|
||||
config_key: 'PAPERLESS_OCR_MODE',
|
||||
category: ConfigCategory.OCR,
|
||||
},
|
||||
{
|
||||
key: 'skip_archive_file',
|
||||
title: $localize`Skip Archive File`,
|
||||
type: ConfigOptionType.Select,
|
||||
choices: mapToItems(ArchiveFileConfig),
|
||||
config_key: 'PAPERLESS_OCR_SKIP_ARCHIVE_FILE',
|
||||
category: ConfigCategory.OCR,
|
||||
},
|
||||
{
|
||||
key: 'image_dpi',
|
||||
title: $localize`Image DPI`,
|
||||
type: ConfigOptionType.Number,
|
||||
config_key: 'PAPERLESS_OCR_IMAGE_DPI',
|
||||
category: ConfigCategory.OCR,
|
||||
},
|
||||
{
|
||||
key: 'unpaper_clean',
|
||||
title: $localize`Clean`,
|
||||
type: ConfigOptionType.Select,
|
||||
choices: mapToItems(CleanConfig),
|
||||
config_key: 'PAPERLESS_OCR_CLEAN',
|
||||
category: ConfigCategory.OCR,
|
||||
},
|
||||
{
|
||||
key: 'deskew',
|
||||
title: $localize`Deskew`,
|
||||
type: ConfigOptionType.Boolean,
|
||||
config_key: 'PAPERLESS_OCR_DESKEW',
|
||||
category: ConfigCategory.OCR,
|
||||
},
|
||||
{
|
||||
key: 'rotate_pages',
|
||||
title: $localize`Rotate Pages`,
|
||||
type: ConfigOptionType.Boolean,
|
||||
config_key: 'PAPERLESS_OCR_ROTATE_PAGES',
|
||||
category: ConfigCategory.OCR,
|
||||
},
|
||||
{
|
||||
key: 'rotate_pages_threshold',
|
||||
title: $localize`Rotate Pages Threshold`,
|
||||
type: ConfigOptionType.Number,
|
||||
config_key: 'PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD',
|
||||
category: ConfigCategory.OCR,
|
||||
},
|
||||
{
|
||||
key: 'max_image_pixels',
|
||||
title: $localize`Max Image Pixels`,
|
||||
type: ConfigOptionType.Number,
|
||||
config_key: 'PAPERLESS_OCR_IMAGE_DPI',
|
||||
category: ConfigCategory.OCR,
|
||||
},
|
||||
{
|
||||
key: 'color_conversion_strategy',
|
||||
title: $localize`Color Conversion Strategy`,
|
||||
type: ConfigOptionType.Select,
|
||||
choices: mapToItems(ColorConvertConfig),
|
||||
config_key: 'PAPERLESS_OCR_COLOR_CONVERSION_STRATEGY',
|
||||
category: ConfigCategory.OCR,
|
||||
},
|
||||
{
|
||||
key: 'user_args',
|
||||
title: $localize`OCR Arguments`,
|
||||
type: ConfigOptionType.JSON,
|
||||
config_key: 'PAPERLESS_OCR_USER_ARGS',
|
||||
category: ConfigCategory.OCR,
|
||||
},
|
||||
]
|
||||
|
||||
export interface PaperlessConfig extends ObjectWithId {
|
||||
output_type: OutputTypeConfig
|
||||
pages: number
|
||||
language: string
|
||||
mode: ModeConfig
|
||||
skip_archive_file: ArchiveFileConfig
|
||||
image_dpi: number
|
||||
unpaper_clean: CleanConfig
|
||||
deskew: boolean
|
||||
rotate_pages: boolean
|
||||
rotate_pages_threshold: number
|
||||
max_image_pixels: number
|
||||
color_conversion_strategy: ColorConvertConfig
|
||||
user_args: object
|
||||
}
|
@ -1,23 +1,10 @@
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
|
||||
export enum DocumentSource {
|
||||
ConsumeFolder = 1,
|
||||
ApiUpload = 2,
|
||||
MailFetch = 3,
|
||||
export enum WorkflowActionType {
|
||||
Assignment = 1,
|
||||
}
|
||||
|
||||
export interface ConsumptionTemplate extends ObjectWithId {
|
||||
name: string
|
||||
|
||||
order: number
|
||||
|
||||
sources: DocumentSource[]
|
||||
|
||||
filter_filename: string
|
||||
|
||||
filter_path?: string
|
||||
|
||||
filter_mailrule?: number // MailRule.id
|
||||
export interface WorkflowAction extends ObjectWithId {
|
||||
type: WorkflowActionType
|
||||
|
||||
assign_title?: string
|
||||
|
37
src-ui/src/app/data/workflow-trigger.ts
Normal file
37
src-ui/src/app/data/workflow-trigger.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
|
||||
export enum DocumentSource {
|
||||
ConsumeFolder = 1,
|
||||
ApiUpload = 2,
|
||||
MailFetch = 3,
|
||||
}
|
||||
|
||||
export enum WorkflowTriggerType {
|
||||
Consumption = 1,
|
||||
DocumentAdded = 2,
|
||||
DocumentUpdated = 3,
|
||||
}
|
||||
|
||||
export interface WorkflowTrigger extends ObjectWithId {
|
||||
type: WorkflowTriggerType
|
||||
|
||||
sources?: DocumentSource[]
|
||||
|
||||
filter_filename?: string
|
||||
|
||||
filter_path?: string
|
||||
|
||||
filter_mailrule?: number // MailRule.id
|
||||
|
||||
match?: string
|
||||
|
||||
matching_algorithm?: number
|
||||
|
||||
is_insensitive?: boolean
|
||||
|
||||
filter_has_tags?: number[] // Tag.id[]
|
||||
|
||||
filter_has_correspondent?: number // Correspondent.id
|
||||
|
||||
filter_has_document_type?: number // DocumentType.id
|
||||
}
|
15
src-ui/src/app/data/workflow.ts
Normal file
15
src-ui/src/app/data/workflow.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
import { WorkflowAction } from './workflow-action'
|
||||
import { WorkflowTrigger } from './workflow-trigger'
|
||||
|
||||
export interface Workflow extends ObjectWithId {
|
||||
name: string
|
||||
|
||||
order: number
|
||||
|
||||
enabled: boolean
|
||||
|
||||
triggers: WorkflowTrigger[]
|
||||
|
||||
actions: WorkflowAction[]
|
||||
}
|
42
src-ui/src/app/services/config.service.spec.ts
Normal file
42
src-ui/src/app/services/config.service.spec.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
|
||||
import { ConfigService } from './config.service'
|
||||
import {
|
||||
HttpClientTestingModule,
|
||||
HttpTestingController,
|
||||
} from '@angular/common/http/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { OutputTypeConfig, PaperlessConfig } from '../data/paperless-config'
|
||||
|
||||
describe('ConfigService', () => {
|
||||
let service: ConfigService
|
||||
let httpTestingController: HttpTestingController
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
})
|
||||
service = TestBed.inject(ConfigService)
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
})
|
||||
|
||||
it('should call correct API endpoint on get config', () => {
|
||||
service.getConfig().subscribe()
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}config/`)
|
||||
.flush([{}])
|
||||
})
|
||||
|
||||
it('should call correct API endpoint on set config', () => {
|
||||
service
|
||||
.saveConfig({
|
||||
id: 1,
|
||||
output_type: OutputTypeConfig.PDF_A,
|
||||
} as PaperlessConfig)
|
||||
.subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}config/1/`
|
||||
)
|
||||
expect(req.request.method).toEqual('PATCH')
|
||||
})
|
||||
})
|
27
src-ui/src/app/services/config.service.ts
Normal file
27
src-ui/src/app/services/config.service.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Observable, first, map } from 'rxjs'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { PaperlessConfig } from '../data/paperless-config'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConfigService {
|
||||
protected baseUrl: string = environment.apiBaseUrl + 'config/'
|
||||
|
||||
constructor(protected http: HttpClient) {}
|
||||
|
||||
getConfig(): Observable<PaperlessConfig> {
|
||||
return this.http.get<[PaperlessConfig]>(this.baseUrl).pipe(
|
||||
first(),
|
||||
map((configs) => configs[0])
|
||||
)
|
||||
}
|
||||
|
||||
saveConfig(config: PaperlessConfig): Observable<PaperlessConfig> {
|
||||
return this.http
|
||||
.patch<PaperlessConfig>(`${this.baseUrl}${config.id}/`, config)
|
||||
.pipe(first())
|
||||
}
|
||||
}
|
@ -252,10 +252,18 @@ describe('PermissionsService', () => {
|
||||
'view_sharelink',
|
||||
'change_sharelink',
|
||||
'delete_sharelink',
|
||||
'add_consumptiontemplate',
|
||||
'view_consumptiontemplate',
|
||||
'change_consumptiontemplate',
|
||||
'delete_consumptiontemplate',
|
||||
'add_workflow',
|
||||
'view_workflow',
|
||||
'change_workflow',
|
||||
'delete_workflow',
|
||||
'add_workflowtrigger',
|
||||
'view_workflowtrigger',
|
||||
'change_workflowtrigger',
|
||||
'delete_workflowtrigger',
|
||||
'add_workflowaction',
|
||||
'view_workflowaction',
|
||||
'change_workflowaction',
|
||||
'delete_workflowaction',
|
||||
'add_customfield',
|
||||
'view_customfield',
|
||||
'change_customfield',
|
||||
|
@ -25,8 +25,10 @@ export enum PermissionType {
|
||||
Group = '%s_group',
|
||||
Admin = '%s_logentry',
|
||||
ShareLink = '%s_sharelink',
|
||||
ConsumptionTemplate = '%s_consumptiontemplate',
|
||||
CustomField = '%s_customfield',
|
||||
Workflow = '%s_workflow',
|
||||
WorkflowTrigger = '%s_workflowtrigger',
|
||||
WorkflowAction = '%s_workflowaction',
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
|
@ -1,64 +0,0 @@
|
||||
import { HttpTestingController } from '@angular/common/http/testing'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
|
||||
import { ConsumptionTemplateService } from './consumption-template.service'
|
||||
import {
|
||||
DocumentSource,
|
||||
ConsumptionTemplate,
|
||||
} from 'src/app/data/consumption-template'
|
||||
|
||||
let httpTestingController: HttpTestingController
|
||||
let service: ConsumptionTemplateService
|
||||
const endpoint = 'consumption_templates'
|
||||
const templates: ConsumptionTemplate[] = [
|
||||
{
|
||||
name: 'Template 1',
|
||||
id: 1,
|
||||
order: 1,
|
||||
filter_filename: '*test*',
|
||||
filter_path: null,
|
||||
sources: [DocumentSource.ApiUpload],
|
||||
assign_correspondent: 2,
|
||||
},
|
||||
{
|
||||
name: 'Template 2',
|
||||
id: 2,
|
||||
order: 2,
|
||||
filter_filename: null,
|
||||
filter_path: '/test/',
|
||||
sources: [DocumentSource.ConsumeFolder, DocumentSource.ApiUpload],
|
||||
assign_document_type: 1,
|
||||
},
|
||||
]
|
||||
|
||||
// run common tests
|
||||
commonAbstractPaperlessServiceTests(
|
||||
'consumption_templates',
|
||||
ConsumptionTemplateService
|
||||
)
|
||||
|
||||
describe(`Additional service tests for ConsumptionTemplateService`, () => {
|
||||
it('should reload', () => {
|
||||
service.reload()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
|
||||
)
|
||||
req.flush({
|
||||
results: templates,
|
||||
})
|
||||
expect(service.allTemplates).toEqual(templates)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
// Dont need to setup again
|
||||
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
service = TestBed.inject(ConsumptionTemplateService)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
httpTestingController.verify()
|
||||
})
|
||||
})
|
85
src-ui/src/app/services/rest/workflow.service.spec.ts
Normal file
85
src-ui/src/app/services/rest/workflow.service.spec.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { HttpTestingController } from '@angular/common/http/testing'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
|
||||
import { WorkflowService } from './workflow.service'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import {
|
||||
DocumentSource,
|
||||
WorkflowTriggerType,
|
||||
} from 'src/app/data/workflow-trigger'
|
||||
import { WorkflowActionType } from 'src/app/data/workflow-action'
|
||||
|
||||
let httpTestingController: HttpTestingController
|
||||
let service: WorkflowService
|
||||
const endpoint = 'workflows'
|
||||
const workflows: Workflow[] = [
|
||||
{
|
||||
name: 'Workflow 1',
|
||||
id: 1,
|
||||
order: 1,
|
||||
enabled: true,
|
||||
triggers: [
|
||||
{
|
||||
id: 1,
|
||||
type: WorkflowTriggerType.Consumption,
|
||||
sources: [DocumentSource.ConsumeFolder],
|
||||
filter_filename: '*',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 1,
|
||||
type: WorkflowActionType.Assignment,
|
||||
assign_title: 'foo',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Workflow 2',
|
||||
id: 2,
|
||||
order: 2,
|
||||
enabled: true,
|
||||
triggers: [
|
||||
{
|
||||
id: 2,
|
||||
type: WorkflowTriggerType.DocumentAdded,
|
||||
filter_filename: 'foo',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 2,
|
||||
type: WorkflowActionType.Assignment,
|
||||
assign_title: 'bar',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// run common tests
|
||||
commonAbstractPaperlessServiceTests(endpoint, WorkflowService)
|
||||
|
||||
describe(`Additional service tests for WorkflowService`, () => {
|
||||
it('should reload', () => {
|
||||
service.reload()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
|
||||
)
|
||||
req.flush({
|
||||
results: workflows,
|
||||
})
|
||||
expect(service.allWorkflows).toEqual(workflows)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
// Dont need to setup again
|
||||
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
service = TestBed.inject(WorkflowService)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
httpTestingController.verify()
|
||||
})
|
||||
})
|
@ -1,42 +1,42 @@
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { tap } from 'rxjs'
|
||||
import { ConsumptionTemplate } from 'src/app/data/consumption-template'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConsumptionTemplateService extends AbstractPaperlessService<ConsumptionTemplate> {
|
||||
export class WorkflowService extends AbstractPaperlessService<Workflow> {
|
||||
loading: boolean
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
super(http, 'consumption_templates')
|
||||
super(http, 'workflows')
|
||||
}
|
||||
|
||||
public reload() {
|
||||
this.loading = true
|
||||
this.listAll().subscribe((r) => {
|
||||
this.templates = r.results
|
||||
this.workflows = r.results
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
private templates: ConsumptionTemplate[] = []
|
||||
private workflows: Workflow[] = []
|
||||
|
||||
public get allTemplates(): ConsumptionTemplate[] {
|
||||
return this.templates
|
||||
public get allWorkflows(): Workflow[] {
|
||||
return this.workflows
|
||||
}
|
||||
|
||||
create(o: ConsumptionTemplate) {
|
||||
create(o: Workflow) {
|
||||
return super.create(o).pipe(tap(() => this.reload()))
|
||||
}
|
||||
|
||||
update(o: ConsumptionTemplate) {
|
||||
update(o: Workflow) {
|
||||
return super.update(o).pipe(tap(() => this.reload()))
|
||||
}
|
||||
|
||||
delete(o: ConsumptionTemplate) {
|
||||
delete(o: Workflow) {
|
||||
return super.delete(o).pipe(tap(() => this.reload()))
|
||||
}
|
||||
}
|
@ -3,7 +3,7 @@ const base_url = new URL(document.baseURI)
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiBaseUrl: document.baseURI + 'api/',
|
||||
apiVersion: '3',
|
||||
apiVersion: '4',
|
||||
appTitle: 'Paperless-ngx',
|
||||
version: '2.2.1',
|
||||
webSocketHost: window.location.host,
|
||||
|
@ -5,7 +5,7 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiBaseUrl: 'http://localhost:8000/api/',
|
||||
apiVersion: '3',
|
||||
apiVersion: '4',
|
||||
appTitle: 'Paperless-ngx',
|
||||
version: 'DEVELOPMENT',
|
||||
webSocketHost: 'localhost:8000',
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -647,8 +647,6 @@ code {
|
||||
}
|
||||
|
||||
.accordion {
|
||||
--bs-accordion-btn-padding-x: 0.75rem;
|
||||
--bs-accordion-btn-padding-y: 0.375rem;
|
||||
--bs-accordion-btn-bg: var(--bs-light);
|
||||
--bs-accordion-btn-color: var(--bs-primary);
|
||||
--bs-accordion-color: var(--bs-body-color);
|
||||
|
@ -9,8 +9,11 @@ class DocumentsConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
from documents.signals import document_consumption_finished
|
||||
from documents.signals import document_updated
|
||||
from documents.signals.handlers import add_inbox_tags
|
||||
from documents.signals.handlers import add_to_index
|
||||
from documents.signals.handlers import run_workflow_added
|
||||
from documents.signals.handlers import run_workflow_updated
|
||||
from documents.signals.handlers import set_correspondent
|
||||
from documents.signals.handlers import set_document_type
|
||||
from documents.signals.handlers import set_log_entry
|
||||
@ -24,5 +27,7 @@ class DocumentsConfig(AppConfig):
|
||||
document_consumption_finished.connect(set_storage_path)
|
||||
document_consumption_finished.connect(set_log_entry)
|
||||
document_consumption_finished.connect(add_to_index)
|
||||
document_consumption_finished.connect(run_workflow_added)
|
||||
document_updated.connect(run_workflow_updated)
|
||||
|
||||
AppConfig.ready(self)
|
||||
|
@ -90,6 +90,9 @@ class BarcodeReader:
|
||||
"""
|
||||
asn = None
|
||||
|
||||
if not self.supported_mime_type:
|
||||
return None
|
||||
|
||||
# Ensure the barcodes have been read
|
||||
self.detect()
|
||||
|
||||
@ -215,7 +218,7 @@ class BarcodeReader:
|
||||
# This file is really borked, allow the consumption to continue
|
||||
# but it may fail further on
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.warning(
|
||||
logger.exception(
|
||||
f"Exception during barcode scanning: {e}",
|
||||
)
|
||||
|
||||
|
@ -52,7 +52,7 @@ def load_classifier() -> Optional["DocumentClassifier"]:
|
||||
except OSError:
|
||||
logger.exception("IO error while loading document classification model")
|
||||
classifier = None
|
||||
except Exception: # pragma: nocover
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Unknown error while loading document classification model")
|
||||
classifier = None
|
||||
|
||||
@ -318,7 +318,7 @@ class DocumentClassifier:
|
||||
|
||||
return True
|
||||
|
||||
def preprocess_content(self, content: str) -> str: # pragma: nocover
|
||||
def preprocess_content(self, content: str) -> str: # pragma: no cover
|
||||
"""
|
||||
Process to contents of a document, distilling it down into
|
||||
words which are meaningful to the content
|
||||
|
@ -26,8 +26,7 @@ from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.loggers import LoggingMixin
|
||||
from documents.matching import document_matches_template
|
||||
from documents.models import ConsumptionTemplate
|
||||
from documents.matching import document_matches_workflow
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@ -36,6 +35,8 @@ from documents.models import DocumentType
|
||||
from documents.models import FileInfo
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.parsers import DocumentParser
|
||||
from documents.parsers import ParseError
|
||||
from documents.parsers import get_parser_class_for_mime_type
|
||||
@ -134,24 +135,24 @@ class Consumer(LoggingMixin):
|
||||
"""
|
||||
Confirm the input file still exists where it should
|
||||
"""
|
||||
if not os.path.isfile(self.path):
|
||||
if not os.path.isfile(self.original_path):
|
||||
self._fail(
|
||||
ConsumerStatusShortMessage.FILE_NOT_FOUND,
|
||||
f"Cannot consume {self.path}: File not found.",
|
||||
f"Cannot consume {self.original_path}: File not found.",
|
||||
)
|
||||
|
||||
def pre_check_duplicate(self):
|
||||
"""
|
||||
Using the MD5 of the file, check this exact file doesn't already exist
|
||||
"""
|
||||
with open(self.path, "rb") as f:
|
||||
with open(self.original_path, "rb") as f:
|
||||
checksum = hashlib.md5(f.read()).hexdigest()
|
||||
existing_doc = Document.objects.filter(
|
||||
Q(checksum=checksum) | Q(archive_checksum=checksum),
|
||||
)
|
||||
if existing_doc.exists():
|
||||
if settings.CONSUMER_DELETE_DUPLICATES:
|
||||
os.unlink(self.path)
|
||||
os.unlink(self.original_path)
|
||||
self._fail(
|
||||
ConsumerStatusShortMessage.DOCUMENT_ALREADY_EXISTS,
|
||||
f"Not consuming {self.filename}: It is a duplicate of"
|
||||
@ -210,7 +211,7 @@ class Consumer(LoggingMixin):
|
||||
|
||||
self.log.info(f"Executing pre-consume script {settings.PRE_CONSUME_SCRIPT}")
|
||||
|
||||
working_file_path = str(self.path)
|
||||
working_file_path = str(self.working_copy)
|
||||
original_file_path = str(self.original_path)
|
||||
|
||||
script_env = os.environ.copy()
|
||||
@ -342,8 +343,8 @@ class Consumer(LoggingMixin):
|
||||
Return the document object if it was successfully created.
|
||||
"""
|
||||
|
||||
self.path = Path(path).resolve()
|
||||
self.filename = override_filename or self.path.name
|
||||
self.original_path = Path(path).resolve()
|
||||
self.filename = override_filename or self.original_path.name
|
||||
self.override_title = override_title
|
||||
self.override_correspondent_id = override_correspondent_id
|
||||
self.override_document_type_id = override_document_type_id
|
||||
@ -376,17 +377,16 @@ class Consumer(LoggingMixin):
|
||||
self.log.info(f"Consuming {self.filename}")
|
||||
|
||||
# For the actual work, copy the file into a tempdir
|
||||
self.original_path = self.path
|
||||
tempdir = tempfile.TemporaryDirectory(
|
||||
prefix="paperless-ngx",
|
||||
dir=settings.SCRATCH_DIR,
|
||||
)
|
||||
self.path = Path(tempdir.name) / Path(self.filename)
|
||||
copy_file_with_basic_stats(self.original_path, self.path)
|
||||
self.working_copy = Path(tempdir.name) / Path(self.filename)
|
||||
copy_file_with_basic_stats(self.original_path, self.working_copy)
|
||||
|
||||
# Determine the parser class.
|
||||
|
||||
mime_type = magic.from_file(self.path, mime=True)
|
||||
mime_type = magic.from_file(self.working_copy, mime=True)
|
||||
|
||||
self.log.debug(f"Detected mime type: {mime_type}")
|
||||
|
||||
@ -405,7 +405,7 @@ class Consumer(LoggingMixin):
|
||||
|
||||
document_consumption_started.send(
|
||||
sender=self.__class__,
|
||||
filename=self.path,
|
||||
filename=self.working_copy,
|
||||
logging_group=self.logging_group,
|
||||
)
|
||||
|
||||
@ -420,7 +420,7 @@ class Consumer(LoggingMixin):
|
||||
|
||||
document_parser: DocumentParser = parser_class(
|
||||
self.logging_group,
|
||||
progress_callback,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
self.log.debug(f"Parser: {type(document_parser).__name__}")
|
||||
@ -443,7 +443,7 @@ class Consumer(LoggingMixin):
|
||||
ConsumerStatusShortMessage.PARSING_DOCUMENT,
|
||||
)
|
||||
self.log.debug(f"Parsing {self.filename}...")
|
||||
document_parser.parse(self.path, mime_type, self.filename)
|
||||
document_parser.parse(self.working_copy, mime_type, self.filename)
|
||||
|
||||
self.log.debug(f"Generating thumbnail for {self.filename}...")
|
||||
self._send_progress(
|
||||
@ -453,7 +453,7 @@ class Consumer(LoggingMixin):
|
||||
ConsumerStatusShortMessage.GENERATING_THUMBNAIL,
|
||||
)
|
||||
thumbnail = document_parser.get_thumbnail(
|
||||
self.path,
|
||||
self.working_copy,
|
||||
mime_type,
|
||||
self.filename,
|
||||
)
|
||||
@ -526,7 +526,7 @@ class Consumer(LoggingMixin):
|
||||
|
||||
self._write(
|
||||
document.storage_type,
|
||||
self.original_path,
|
||||
self.working_copy,
|
||||
document.source_path,
|
||||
)
|
||||
|
||||
@ -559,9 +559,9 @@ class Consumer(LoggingMixin):
|
||||
document.save()
|
||||
|
||||
# Delete the file only if it was successfully consumed
|
||||
self.log.debug(f"Deleting file {self.path}")
|
||||
os.unlink(self.path)
|
||||
self.log.debug(f"Deleting file {self.working_copy}")
|
||||
self.original_path.unlink()
|
||||
self.working_copy.unlink()
|
||||
|
||||
# https://github.com/jonaswinkler/paperless-ng/discussions/1037
|
||||
shadow_file = os.path.join(
|
||||
@ -602,66 +602,71 @@ class Consumer(LoggingMixin):
|
||||
|
||||
return document
|
||||
|
||||
def get_template_overrides(
|
||||
def get_workflow_overrides(
|
||||
self,
|
||||
input_doc: ConsumableDocument,
|
||||
) -> DocumentMetadataOverrides:
|
||||
"""
|
||||
Match consumption templates to a document based on source and
|
||||
file name filters, path filters or mail rule filter if specified
|
||||
Get overrides from matching workflows
|
||||
"""
|
||||
overrides = DocumentMetadataOverrides()
|
||||
for template in ConsumptionTemplate.objects.all().order_by("order"):
|
||||
for workflow in Workflow.objects.filter(enabled=True).order_by("order"):
|
||||
template_overrides = DocumentMetadataOverrides()
|
||||
|
||||
if document_matches_template(input_doc, template):
|
||||
if template.assign_title is not None:
|
||||
template_overrides.title = template.assign_title
|
||||
if template.assign_tags is not None:
|
||||
template_overrides.tag_ids = [
|
||||
tag.pk for tag in template.assign_tags.all()
|
||||
]
|
||||
if template.assign_correspondent is not None:
|
||||
template_overrides.correspondent_id = (
|
||||
template.assign_correspondent.pk
|
||||
if document_matches_workflow(
|
||||
input_doc,
|
||||
workflow,
|
||||
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||
):
|
||||
for action in workflow.actions.all():
|
||||
self.log.info(
|
||||
f"Applying overrides in {action} from {workflow}",
|
||||
)
|
||||
if template.assign_document_type is not None:
|
||||
template_overrides.document_type_id = (
|
||||
template.assign_document_type.pk
|
||||
)
|
||||
if template.assign_storage_path is not None:
|
||||
template_overrides.storage_path_id = template.assign_storage_path.pk
|
||||
if template.assign_owner is not None:
|
||||
template_overrides.owner_id = template.assign_owner.pk
|
||||
if template.assign_view_users is not None:
|
||||
template_overrides.view_users = [
|
||||
user.pk for user in template.assign_view_users.all()
|
||||
]
|
||||
if template.assign_view_groups is not None:
|
||||
template_overrides.view_groups = [
|
||||
group.pk for group in template.assign_view_groups.all()
|
||||
]
|
||||
if template.assign_change_users is not None:
|
||||
template_overrides.change_users = [
|
||||
user.pk for user in template.assign_change_users.all()
|
||||
]
|
||||
if template.assign_change_groups is not None:
|
||||
template_overrides.change_groups = [
|
||||
group.pk for group in template.assign_change_groups.all()
|
||||
]
|
||||
if template.assign_custom_fields is not None:
|
||||
template_overrides.custom_field_ids = [
|
||||
field.pk for field in template.assign_custom_fields.all()
|
||||
]
|
||||
if action.assign_title is not None:
|
||||
template_overrides.title = action.assign_title
|
||||
if action.assign_tags is not None:
|
||||
template_overrides.tag_ids = [
|
||||
tag.pk for tag in action.assign_tags.all()
|
||||
]
|
||||
if action.assign_correspondent is not None:
|
||||
template_overrides.correspondent_id = (
|
||||
action.assign_correspondent.pk
|
||||
)
|
||||
if action.assign_document_type is not None:
|
||||
template_overrides.document_type_id = (
|
||||
action.assign_document_type.pk
|
||||
)
|
||||
if action.assign_storage_path is not None:
|
||||
template_overrides.storage_path_id = (
|
||||
action.assign_storage_path.pk
|
||||
)
|
||||
if action.assign_owner is not None:
|
||||
template_overrides.owner_id = action.assign_owner.pk
|
||||
if action.assign_view_users is not None:
|
||||
template_overrides.view_users = [
|
||||
user.pk for user in action.assign_view_users.all()
|
||||
]
|
||||
if action.assign_view_groups is not None:
|
||||
template_overrides.view_groups = [
|
||||
group.pk for group in action.assign_view_groups.all()
|
||||
]
|
||||
if action.assign_change_users is not None:
|
||||
template_overrides.change_users = [
|
||||
user.pk for user in action.assign_change_users.all()
|
||||
]
|
||||
if action.assign_change_groups is not None:
|
||||
template_overrides.change_groups = [
|
||||
group.pk for group in action.assign_change_groups.all()
|
||||
]
|
||||
if action.assign_custom_fields is not None:
|
||||
template_overrides.custom_field_ids = [
|
||||
field.pk for field in action.assign_custom_fields.all()
|
||||
]
|
||||
|
||||
overrides.update(template_overrides)
|
||||
overrides.update(template_overrides)
|
||||
return overrides
|
||||
|
||||
def _parse_title_placeholders(self, title: str) -> str:
|
||||
"""
|
||||
Consumption template title placeholders can only include items that are
|
||||
assigned as part of this template (since auto-matching hasnt happened yet)
|
||||
"""
|
||||
local_added = timezone.localtime(timezone.now())
|
||||
|
||||
correspondent_name = (
|
||||
@ -680,20 +685,14 @@ class Consumer(LoggingMixin):
|
||||
else None
|
||||
)
|
||||
|
||||
return title.format(
|
||||
correspondent=correspondent_name,
|
||||
document_type=doc_type_name,
|
||||
added=local_added.isoformat(),
|
||||
added_year=local_added.strftime("%Y"),
|
||||
added_year_short=local_added.strftime("%y"),
|
||||
added_month=local_added.strftime("%m"),
|
||||
added_month_name=local_added.strftime("%B"),
|
||||
added_month_name_short=local_added.strftime("%b"),
|
||||
added_day=local_added.strftime("%d"),
|
||||
owner_username=owner_username,
|
||||
original_filename=Path(self.filename).stem,
|
||||
added_time=local_added.strftime("%H:%M"),
|
||||
).strip()
|
||||
return parse_doc_title_w_placeholders(
|
||||
title,
|
||||
correspondent_name,
|
||||
doc_type_name,
|
||||
owner_username,
|
||||
local_added,
|
||||
self.filename,
|
||||
)
|
||||
|
||||
def _store(
|
||||
self,
|
||||
@ -735,7 +734,7 @@ class Consumer(LoggingMixin):
|
||||
)[:127],
|
||||
content=text,
|
||||
mime_type=mime_type,
|
||||
checksum=hashlib.md5(self.original_path.read_bytes()).hexdigest(),
|
||||
checksum=hashlib.md5(self.working_copy.read_bytes()).hexdigest(),
|
||||
created=create_date,
|
||||
modified=create_date,
|
||||
storage_type=storage_type,
|
||||
@ -846,3 +845,47 @@ class Consumer(LoggingMixin):
|
||||
self.log.warning("Script stderr:")
|
||||
for line in stderr_str:
|
||||
self.log.warning(line)
|
||||
|
||||
|
||||
def parse_doc_title_w_placeholders(
|
||||
title: str,
|
||||
correspondent_name: str,
|
||||
doc_type_name: str,
|
||||
owner_username: str,
|
||||
local_added: datetime.datetime,
|
||||
original_filename: str,
|
||||
created: Optional[datetime.datetime] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Available title placeholders for Workflows depend on what has already been assigned,
|
||||
e.g. for pre-consumption triggers created will not have been parsed yet, but it will
|
||||
for added / updated triggers
|
||||
"""
|
||||
formatting = {
|
||||
"correspondent": correspondent_name,
|
||||
"document_type": doc_type_name,
|
||||
"added": local_added.isoformat(),
|
||||
"added_year": local_added.strftime("%Y"),
|
||||
"added_year_short": local_added.strftime("%y"),
|
||||
"added_month": local_added.strftime("%m"),
|
||||
"added_month_name": local_added.strftime("%B"),
|
||||
"added_month_name_short": local_added.strftime("%b"),
|
||||
"added_day": local_added.strftime("%d"),
|
||||
"added_time": local_added.strftime("%H:%M"),
|
||||
"owner_username": owner_username,
|
||||
"original_filename": Path(original_filename).stem,
|
||||
}
|
||||
if created is not None:
|
||||
formatting.update(
|
||||
{
|
||||
"created": created.isoformat(),
|
||||
"created_year": created.strftime("%Y"),
|
||||
"created_year_short": created.strftime("%y"),
|
||||
"created_month": created.strftime("%m"),
|
||||
"created_month_name": created.strftime("%B"),
|
||||
"created_month_name_short": created.strftime("%b"),
|
||||
"created_day": created.strftime("%d"),
|
||||
"created_time": created.strftime("%H:%M"),
|
||||
},
|
||||
)
|
||||
return title.format(**formatting).strip()
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user